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/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/wavetermdev/ssh_config v0.0.0-20240109090616-36c8da3d7376 h1:tFhJgTu7lgd+hldLfPSzDCoWUpXI8wHKR3rxq5jTLkQ=
github.com/wavetermdev/ssh_config v0.0.0-20240109090616-36c8da3d7376/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

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

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import cn from "classnames";
import { If } from "tsx-control-statements/components";
import type { RemoteType } from "../../types/types";
import ReactDOM from "react-dom";
import { GlobalModel } from "../../model/model";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.svg";
@ -99,23 +100,57 @@ class Toggle extends React.Component<{ checked: boolean; onChange: (value: boole
}
class Checkbox extends React.Component<
{ checked: boolean; onChange: (value: boolean) => void; label: React.ReactNode; id: string },
{}
{
checked?: boolean;
defaultChecked?: boolean;
onChange: (value: boolean) => void;
label: React.ReactNode;
className?: string;
id?: string;
},
{ checkedInternal: boolean }
> {
generatedId;
static idCounter = 0;
constructor(props) {
super(props);
this.state = {
checkedInternal: this.props.checked !== undefined ? this.props.checked : Boolean(this.props.defaultChecked),
};
this.generatedId = `checkbox-${Checkbox.idCounter++}`;
}
componentDidUpdate(prevProps) {
if (this.props.checked !== undefined && this.props.checked !== prevProps.checked) {
this.setState({ checkedInternal: this.props.checked });
}
}
handleChange = (e) => {
const newChecked = e.target.checked;
if (this.props.checked === undefined) {
this.setState({ checkedInternal: newChecked });
}
this.props.onChange(newChecked);
};
render() {
const { checked, onChange, label, id } = this.props;
const { label, className, id } = this.props;
const { checkedInternal } = this.state;
const checkboxId = id || this.generatedId;
return (
<div className="checkbox">
<div className={cn("checkbox", className)}>
<input
type="checkbox"
id={id}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-checked={checked}
id={checkboxId}
checked={checkedInternal}
onChange={this.handleChange}
aria-checked={checkedInternal}
role="checkbox"
/>
<label htmlFor={id}>
<label htmlFor={checkboxId}>
<span></span>
{label}
</label>
@ -231,6 +266,8 @@ interface ButtonProps {
rightIcon?: React.ReactNode;
color?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
className?: string;
}
class Button extends React.Component<ButtonProps> {
@ -257,6 +294,7 @@ class Button extends React.Component<ButtonProps> {
onClick={this.handleClick}
disabled={disabled}
style={style}
autoFocus={this.props.autoFocus}
>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
@ -283,25 +321,24 @@ export default IconButton;
interface LinkButtonProps extends ButtonProps {
href: string;
rel?: string;
target?: string;
}
class LinkButton extends IconButton {
class LinkButton extends React.Component<LinkButtonProps> {
render() {
// @ts-ignore
const { href, target, leftIcon, rightIcon, children, theme, variant }: LinkButtonProps = this.props;
const { leftIcon, rightIcon, children, className, ...rest } = this.props;
return (
<a href={href} target={target} className={`wave-button link-button`}>
<button {...this.props} className={`icon-button ${theme} ${variant}`}>
<a {...rest} className={cn(`wave-button link-button`, className)}>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
{rightIcon && <span className="icon-right">{rightIcon}</span>}
</button>
</a>
);
}
}
interface StatusProps {
status: "green" | "red" | "gray" | "yellow";
text: string;
@ -332,7 +369,7 @@ interface TextFieldDecorationProps {
endDecoration?: React.ReactNode;
}
interface TextFieldProps {
label: string;
label?: string;
value?: string;
className?: string;
onChange?: (value: string) => void;
@ -445,10 +482,11 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
return (
<div
className={cn(`wave-textfield ${className || ""}`, {
className={cn("wave-textfield", className, {
focused: focused,
error: error,
disabled: disabled,
"no-label": !label,
})}
onFocus={this.handleComponentFocus}
onBlur={this.handleComponentBlur}
@ -456,6 +494,7 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
>
{decoration?.startDecoration && <>{decoration.startDecoration}</>}
<div className="wave-textfield-inner">
<If condition={label}>
<label
className={cn("wave-textfield-inner-label", {
float: this.state.hasContent || this.state.focused || placeholder,
@ -465,6 +504,7 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
>
{label}
</label>
</If>
<input
className={cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration })}
ref={this.inputRef}
@ -789,9 +829,67 @@ function CodeRenderer(props: any): any {
}
@mobxReact.observer
class Markdown extends React.Component<{ text: string; style?: any; extraClassName?: string }, {}> {
class CodeBlockMarkdown extends React.Component<
{ children: React.ReactNode; blockText: string; codeSelectSelectedIndex?: number },
{}
> {
blockIndex: number;
blockRef: React.RefObject<HTMLPreElement>;
constructor(props) {
super(props);
this.blockRef = React.createRef();
this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef);
}
render() {
let codeText = this.props.blockText;
let clickHandler: (e: React.MouseEvent<HTMLElement>, blockIndex: number) => void;
let inputModel = GlobalModel.inputModel;
clickHandler = (e: React.MouseEvent<HTMLElement>, blockIndex: number) => {
inputModel.setCodeSelectSelectedCodeBlock(blockIndex);
};
let selected = this.blockIndex == this.props.codeSelectSelectedIndex;
return (
<pre
ref={this.blockRef}
className={cn({ selected: selected })}
onClick={(event) => clickHandler(event, this.blockIndex)}
>
{this.props.children}
</pre>
);
}
}
@mobxReact.observer
class Markdown extends React.Component<
{ text: string; style?: any; extraClassName?: string; codeSelect?: boolean },
{}
> {
CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number): any {
let codeText = codeSelect ? props.node.children[0].children[0].value : props.children;
if (codeText) {
codeText = codeText.replace(/\n$/, ""); // remove trailing newline
}
if (codeSelect) {
return (
<CodeBlockMarkdown blockText={codeText} codeSelectSelectedIndex={codeSelectIndex}>
{props.children}
</CodeBlockMarkdown>
);
} else {
let clickHandler = (e: React.MouseEvent<HTMLElement>) => {
navigator.clipboard.writeText(codeText);
};
return <pre onClick={(event) => clickHandler(event)}>{props.children}</pre>;
}
}
render() {
let text = this.props.text;
let codeSelect = this.props.codeSelect;
let curCodeSelectIndex = GlobalModel.inputModel.getCodeSelectSelectedIndex();
let markdownComponents = {
a: LinkRenderer,
h1: (props) => HeaderRenderer(props, 1),
@ -800,7 +898,8 @@ class Markdown extends React.Component<{ text: string; style?: any; extraClassNa
h4: (props) => HeaderRenderer(props, 4),
h5: (props) => HeaderRenderer(props, 5),
h6: (props) => HeaderRenderer(props, 6),
code: CodeRenderer,
code: (props) => CodeRenderer(props),
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex),
};
return (
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>
@ -1082,16 +1181,18 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
}
interface ModalHeaderProps {
onClose: () => void;
onClose?: () => void;
title: string;
}
const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, title }) => (
<div className="wave-modal-header">
{<div className="wave-modal-title">{title}</div>}
<If condition={onClose}>
<IconButton theme="secondary" variant="ghost" onClick={onClose}>
<i className="fa-sharp fa-solid fa-xmark"></i>
</IconButton>
</If>
</div>
);
@ -1141,7 +1242,7 @@ class Modal extends React.Component<ModalProps> {
}
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,6 +17,10 @@
}
.disconnected-modal {
.wave-modal-content {
.wave-modal-body {
padding: 0;
.modal-content {
footer {
.footer-text-link {
@ -27,14 +31,32 @@
}
.inner-content {
.ws-log {
padding: 5px;
background-color: @term-black;
height: 250px;
.log {
height: 335px;
margin-bottom: 20px;
overflow: auto;
.ws-logline {
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-corner {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
pre {
color: @term-white;
background-color: @term-black;
}
}
}
}
.wave-modal-footer {
button:first-child {
color: @term-green;
}
}
}
@ -453,6 +475,89 @@
}
}
.tabswitcher-modal {
width: 452px;
min-height: 384px;
.wave-modal-content {
.wave-modal-body {
display: flex;
padding: 0px;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
.textfield-wrapper {
padding: 20px 20px 0px;
.wave-input-decoration.start-position {
height: 100%;
.tabswitcher-search-prefix {
opacity: 0.5;
font-size: 13px;
}
}
}
.list-container {
overflow: hidden;
padding: 10px 0 20px;
width: 100%;
}
.list-container-inner {
width: 100%;
max-height: 300px;
overflow-y: scroll;
padding: 0 16px 0 20px;
&::-webkit-scrollbar-thumb {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
.options-list {
width: 100%;
.search-option {
padding: 5px 5px 5px 8px;
display: flex;
align-items: center;
border: 1px solid transparent;
width: 100%;
overflow: hidden;
div.tabname {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 5px;
}
div.icon {
flex-shrink: 0;
width: 20px;
margin-right: 6px;
}
}
.focused-option {
border: 1px solid rgba(241, 246, 243, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
}
}
}
}
}
}
.screen-settings-tooltip .wave-tooltip-icon {
i {
font-size: 13px;
@ -491,6 +596,10 @@
gap: 4px;
align-self: stretch;
width: 100%;
.settings-input .hotkey {
color: @text-secondary;
}
}
}
}
@ -564,11 +673,15 @@
}
.alert-modal {
width: 500px;
width: 510px;
.wave-modal-content {
.wave-modal-body {
padding: 40px 20px;
.dontshowagain-text {
margin-top: 15px;
}
}
}
}

View File

@ -23,12 +23,15 @@ import {
Tooltip,
Button,
Status,
Checkbox,
} from "../common";
import * as util from "../../../util/util";
import * as textmeasure from "../../../util/textmeasure";
import * as appconst from "../../appconst";
import { ClientDataType } from "../../../types/types";
import { Screen } from "../../../model/model";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
import { ReactComponent as WarningIcon } from "../../assets/icons/line/triangle-exclamation.svg";
import shield from "../../assets/icons/shield_check.svg";
import help from "../../assets/icons/help_filled.svg";
import github from "../../assets/icons/github.svg";
@ -42,9 +45,11 @@ const VERSION = __WAVETERM_VERSION__;
let BUILD = __WAVETERM_BUILD__;
type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
const RemotePtyRows = 9;
const RemotePtyCols = 80;
const NumOfLines = 50;
const PasswordUnchangedSentinel = "--unchanged--";
@mobxReact.observer
@ -67,7 +72,8 @@ class ModalsProvider extends React.Component {
@mobxReact.observer
class DisconnectedModal extends React.Component<{}, {}> {
logRef: any = React.createRef();
showLog: mobx.IObservableValue<boolean> = mobx.observable.box(false);
logs: mobx.IObservableValue<string> = mobx.observable.box("");
logInterval: NodeJS.Timeout = null;
@boundMethod
restartServer() {
@ -80,8 +86,16 @@ class DisconnectedModal extends React.Component<{}, {}> {
}
componentDidMount() {
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
this.fetchLogs();
this.logInterval = setInterval(() => {
this.fetchLogs();
}, 5000);
}
componentWillUnmount() {
if (this.logInterval) {
clearInterval(this.logInterval);
}
}
@ -91,58 +105,52 @@ class DisconnectedModal extends React.Component<{}, {}> {
}
}
@boundMethod
handleShowLog(): void {
mobx.action(() => {
this.showLog.set(!this.showLog.get());
})();
fetchLogs() {
GlobalModel.getLastLogs(
NumOfLines,
mobx.action((logs) => {
this.logs.set(logs);
if (this.logRef.current != null) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
})
);
}
render() {
let model = GlobalModel;
let logLine: string = null;
let idx: number = 0;
return (
<div className="prompt-modal disconnected-modal modal is-active">
<div className="modal-background"></div>
<Modal className="disconnected-modal">
<Modal.Header title="Wave Client Disconnected" />
<div className="wave-modal-body">
<div className="modal-content">
<div className="message-header">
<div className="modal-title">Wave Client Disconnected</div>
</div>
<If condition={this.showLog.get()}>
<div className="inner-content">
<div className="ws-log" ref={this.logRef}>
<For each="logLine" index="idx" of={GlobalModel.ws.wsLog}>
<div key={idx} className="ws-logline">
{logLine}
</div>
</For>
<div className="log" ref={this.logRef}>
<pre>{this.logs.get()}</pre>
</div>
</div>
</If>
<footer>
<div className="footer-text-link" style={{ marginLeft: 10 }} onClick={this.handleShowLog}>
<If condition={!this.showLog.get()}>
<i className="fa-sharp fa-solid fa-plus" /> Show Log
</If>
<If condition={this.showLog.get()}>
<i className="fa-sharp fa-solid fa-minus" /> Hide Log
</If>
</div>
<div className="flex-spacer" />
<button onClick={this.tryReconnect} className="button">
</div>
<div className="wave-modal-footer">
<Button
theme="secondary"
onClick={this.tryReconnect}
leftIcon={
<span className="icon">
<i className="fa-sharp fa-solid fa-rotate" />
</span>
<span>Try Reconnect</span>
</button>
<button onClick={this.restartServer} className="button is-danger" style={{ marginLeft: 10 }}>
<WarningIcon className="icon" />
<span>Restart Server</span>
</button>
</footer>
</div>
}
>
Try Reconnect
</Button>
<Button
theme="secondary"
onClick={this.restartServer}
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
>
Restart Server
</Button>
</div>
</Modal>
);
}
}
@ -210,6 +218,15 @@ class AlertModal extends React.Component<{}, {}> {
GlobalModel.confirmAlert();
}
@boundMethod
handleDontShowAgain(checked: boolean) {
let message = GlobalModel.alertMessage.get();
if (message.confirmflag == null) {
return;
}
GlobalCommandRunner.clientSetConfirmFlag(message.confirmflag, checked);
}
render() {
let message = GlobalModel.alertMessage.get();
let title = message?.title ?? (message?.confirm ? "Confirm" : "Alert");
@ -223,16 +240,27 @@ class AlertModal extends React.Component<{}, {}> {
<Markdown text={message?.message ?? ""} />
</If>
<If condition={!message?.markdown}>{message?.message}</If>
<If condition={message.confirmflag}>
<Checkbox
onChange={this.handleDontShowAgain}
label={"Don't show me this again"}
className="dontshowagain-text"
/>
</If>
</div>
<div className="wave-modal-footer">
<If condition={isConfirm}>
<Button theme="secondary" onClick={this.closeModal}>
Cancel
</Button>
<Button onClick={this.handleOK}>Ok</Button>
<Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If>
<If condition={!isConfirm}>
<Button onClick={this.handleOK}>Ok</Button>
<Button autoFocus={true} onClick={this.handleOK}>
Ok
</Button>
</If>
</div>
</Modal>
@ -497,6 +525,10 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
this.errorStr = mobx.observable.box(this.remoteEdit?.errorstr ?? null, { name: "CreateRemote-errorStr" });
}
componentDidMount(): void {
GlobalModel.getClientData();
}
remoteCName(): string {
let hostName = this.tempHostName.get();
if (hostName == "") {
@ -515,6 +547,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
return this.remoteEdit?.errorstr ?? null;
}
@boundMethod
handleOk(): void {
this.showShellPrompt(this.submitRemote);
}
@boundMethod
showShellPrompt(cb: () => void): void {
let prtn = GlobalModel.showAlert({
message:
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
confirm: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
cb();
});
}
@boundMethod
submitRemote(): void {
mobx.action(() => {
@ -552,26 +605,27 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
kwargs["connectmode"] = this.tempConnectMode.get();
kwargs["visual"] = "1";
kwargs["submit"] = "1";
let model = this.model;
let prtn = GlobalCommandRunner.createRemote(cname, kwargs, false);
prtn.then((crtn) => {
if (crtn.success) {
this.model.setRecentConnAdded(true);
this.model.closeModal();
let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false);
crRtn.then((crcrtn) => {
if (crcrtn.success) {
return;
}
mobx.action(() => {
this.errorStr.set(crcrtn.error ?? null);
this.errorStr.set(crcrtn.error);
})();
});
return;
}
mobx.action(() => {
this.errorStr.set(crtn.error ?? null);
this.errorStr.set(crtn.error);
})();
});
model.seRecentConnAdded(true);
}
@boundMethod
@ -789,7 +843,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If>
</div>
<Modal.Footer onCancel={this.model.closeModal} onOk={this.submitRemote} okLabel="Connect" />
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
</Modal>
);
}
@ -806,7 +860,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
}
@mobx.computed
get selectedRemote(): T.RemoteType {
getSelectedRemote(): T.RemoteType {
const selectedRemoteId = this.model.selectedRemoteId.get();
return GlobalModel.getRemote(selectedRemoteId);
}
@ -821,7 +875,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
}
componentDidUpdate() {
if (this.selectedRemote == null || this.selectedRemote.archived) {
if (this.getSelectedRemote() == null || this.getSelectedRemote().archived) {
this.model.deSelectRemote();
}
}
@ -885,7 +939,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
@boundMethod
clickArchive(): void {
if (this.selectedRemote && this.selectedRemote.status == "connected") {
if (this.getSelectedRemote() && this.getSelectedRemote().status == "connected") {
GlobalModel.showAlert({ message: "Cannot delete when connected. Disconnect and try again." });
return;
}
@ -897,21 +951,22 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
if (!confirm) {
return;
}
if (this.selectedRemote) {
GlobalCommandRunner.archiveRemote(this.selectedRemote.remoteid);
if (this.getSelectedRemote()) {
GlobalCommandRunner.archiveRemote(this.getSelectedRemote().remoteid);
}
GlobalModel.modalsModel.popModal();
});
}
@boundMethod
clickReinstall(): void {
GlobalCommandRunner.installRemote(this.selectedRemote?.remoteid);
GlobalCommandRunner.installRemote(this.getSelectedRemote().remoteid);
}
@boundMethod
handleClose(): void {
this.model.closeModal();
this.model.seRecentConnAdded(false);
this.model.setRecentConnAdded(false);
}
renderInstallStatus(remote: T.RemoteType): any {
@ -990,7 +1045,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
<Button theme="secondary" disabled={true}>
Edit
<Tooltip
message={`Remotes imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
message={`Connections imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
icon={<i className="fa-sharp fa-regular fa-fw fa-ban" />}
>
<i className="fa-sharp fa-regular fa-fw fa-ban" />
@ -1003,7 +1058,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
<Tooltip
message={
<span>
Remotes imported from an ssh config file can be deleted, but will come back upon
Connections imported from an ssh config file can be deleted, but will come back upon
importing again. They will stay removed if you follow{" "}
<a href="https://docs.waveterm.dev/features/sshconfig-imports">this procedure</a>.
</span>
@ -1072,7 +1127,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
}
render() {
let remote = this.selectedRemote;
let remote = this.getSelectedRemote();
if (remote == null) {
return null;
@ -1083,14 +1138,15 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
let termFontSize = GlobalModel.termFontSize.get();
let termWidth = textmeasure.termWidthFromCols(RemotePtyCols, termFontSize);
let remoteAliasText = util.isBlank(remote.remotealias) ? "(none)" : remote.remotealias;
let selectedRemoteStatus = this.getSelectedRemote().status;
return (
<Modal className="rconndetail-modal">
<Modal.Header title="Connection" onClose={this.model.closeModal} />
<Modal.Header title="Connection" onClose={this.handleClose} />
<div className="wave-modal-body">
<div className="name-header-actions-wrapper">
<div className="name text-primary name-wrapper">
{getName(remote)}&nbsp; {getImportTooltip(remote)}
{util.getRemoteName(remote)}&nbsp; {getImportTooltip(remote)}
</div>
<div className="header-actions">{this.renderHeaderBtns(remote)}</div>
</div>
@ -1161,7 +1217,18 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
</div>
</div>
</div>
<Modal.Footer onOk={this.model.closeModal} onCancel={this.model.closeModal} okLabel="Done" />
<div className="wave-modal-footer">
<Button
theme="secondary"
disabled={selectedRemoteStatus == "connecting"}
onClick={this.handleClose}
>
Cancel
</Button>
<Button disabled={selectedRemoteStatus == "connecting"} onClick={this.handleClose}>
Done
</Button>
</div>
</Modal>
);
}
@ -1343,7 +1410,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
<div className="wave-modal-body">
<div className="name-actions-section">
<div className="name text-primary">{getName(this.selectedRemote)}</div>
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
</div>
<div className="alias-section">
<TextField
@ -1459,14 +1526,309 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
}
}
const getName = (remote: T.RemoteType): string => {
if (remote == null) {
return "";
}
const { remotealias, remotecanonicalname } = remote;
return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname;
type SwitcherDataType = {
sessionId: string;
sessionName: string;
sessionIdx: number;
screenId: string;
screenIdx: number;
screenName: string;
icon: string;
color: string;
};
const MaxOptionsToDisplay = 100;
@mobxReact.observer
class TabSwitcherModal extends React.Component<{}, {}> {
screens: Map<string, OV<string>>[];
sessions: Map<string, OV<string>>[];
options: SwitcherDataType[] = [];
sOptions: OArr<SwitcherDataType> = mobx.observable.array(null, {
name: "TabSwitcherModal-sOptions",
});
focusedIdx: OV<number> = mobx.observable.box(0, { name: "TabSwitcherModal-selectedIdx" });
activeSessionIdx: number;
optionRefs = [];
listWrapperRef = React.createRef<HTMLDivElement>();
prevFocusedIdx = 0;
componentDidMount() {
this.activeSessionIdx = GlobalModel.getActiveSession().sessionIdx.get();
let oSessions = GlobalModel.sessionList;
let oScreens = GlobalModel.screenMap;
oScreens.forEach((oScreen) => {
// Find the matching session in the observable array
let foundSession = oSessions.find((s) => {
if (s.sessionId === oScreen.sessionId && s.archived.get() == false) {
return true;
}
return false;
});
if (foundSession) {
let data: SwitcherDataType = {
sessionName: foundSession.name.get(),
sessionId: foundSession.sessionId,
sessionIdx: foundSession.sessionIdx.get(),
screenName: oScreen.name.get(),
screenId: oScreen.screenId,
screenIdx: oScreen.screenIdx.get(),
icon: this.getTabIcon(oScreen),
color: this.getTabColor(oScreen),
};
this.options.push(data);
}
});
mobx.action(() => {
this.sOptions.replace(this.sortOptions(this.options).slice(0, MaxOptionsToDisplay));
})();
document.addEventListener("keydown", this.handleKeyDown);
}
componentWillUnmount() {
document.removeEventListener("keydown", this.handleKeyDown);
}
componentDidUpdate() {
let currFocusedIdx = this.focusedIdx.get();
// Check if selectedIdx has changed
if (currFocusedIdx !== this.prevFocusedIdx) {
let optionElement = this.optionRefs[currFocusedIdx]?.current;
if (optionElement) {
optionElement.scrollIntoView({ block: "nearest" });
}
// Update prevFocusedIdx for the next update cycle
this.prevFocusedIdx = currFocusedIdx;
}
if (currFocusedIdx >= this.sOptions.length && this.sOptions.length > 0) {
this.setFocusedIndex(this.sOptions.length - 1);
}
}
@boundMethod
getTabIcon(screen: Screen): string {
let tabIcon = "default";
let screenOpts = screen.opts.get();
if (screenOpts != null && !util.isBlank(screenOpts.tabicon)) {
tabIcon = screenOpts.tabicon;
}
return tabIcon;
}
@boundMethod
getTabColor(screen: Screen): string {
let tabColor = "default";
let screenOpts = screen.opts.get();
if (screenOpts != null && !util.isBlank(screenOpts.tabcolor)) {
tabColor = screenOpts.tabcolor;
}
return tabColor;
}
@boundMethod
handleKeyDown(e) {
if (e.key === "Escape") {
this.closeModal();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
let newIndex = this.calculateNewIndex(e.key === "ArrowUp");
this.setFocusedIndex(newIndex);
} else if (e.key === "Enter") {
e.preventDefault();
this.handleSelect(this.focusedIdx.get());
}
}
@boundMethod
calculateNewIndex(isUpKey) {
let currentIndex = this.focusedIdx.get();
if (isUpKey) {
return Math.max(currentIndex - 1, 0);
} else {
return Math.min(currentIndex + 1, this.sOptions.length - 1);
}
}
@boundMethod
setFocusedIndex(index) {
mobx.action(() => {
this.focusedIdx.set(index);
})();
}
@boundMethod
closeModal(): void {
GlobalModel.modalsModel.popModal();
}
@boundMethod
handleSelect(index: number): void {
const selectedOption = this.sOptions[index];
if (selectedOption) {
GlobalCommandRunner.switchScreen(selectedOption.screenId, selectedOption.sessionId);
this.closeModal();
}
}
@boundMethod
handleSearch(val: string): void {
let sOptions: SwitcherDataType[];
if (val == "") {
sOptions = this.sortOptions(this.options).slice(0, MaxOptionsToDisplay);
} else {
sOptions = this.filterOptions(val);
sOptions = this.sortOptions(sOptions);
if (sOptions.length > MaxOptionsToDisplay) {
sOptions = sOptions.slice(0, MaxOptionsToDisplay);
}
}
mobx.action(() => {
this.sOptions.replace(sOptions);
this.focusedIdx.set(0);
})();
}
@mobx.computed
@boundMethod
filterOptions(searchInput: string): SwitcherDataType[] {
let filteredScreens = [];
for (let i = 0; i < this.options.length; i++) {
let tab = this.options[i];
let match = false;
if (searchInput.includes("/")) {
let [sessionFilter, screenFilter] = searchInput.split("/").map((s) => s.trim().toLowerCase());
match =
tab.sessionName.toLowerCase().includes(sessionFilter) &&
tab.screenName.toLowerCase().includes(screenFilter);
} else {
match =
tab.sessionName.toLowerCase().includes(searchInput) ||
tab.screenName.toLowerCase().includes(searchInput);
}
// Add tab to filtered list if it matches the criteria
if (match) {
filteredScreens.push(tab);
}
}
return filteredScreens;
}
@mobx.computed
@boundMethod
sortOptions(options: SwitcherDataType[]): SwitcherDataType[] {
return options.sort((a, b) => {
let aInCurrentSession = a.sessionIdx === this.activeSessionIdx;
let bInCurrentSession = b.sessionIdx === this.activeSessionIdx;
// Tabs in the current session are sorted by screenIdx
if (aInCurrentSession && bInCurrentSession) {
return a.screenIdx - b.screenIdx;
}
// a is in the current session and b is not, so a comes first
else if (aInCurrentSession) {
return -1;
}
// b is in the current session and a is not, so b comes first
else if (bInCurrentSession) {
return 1;
}
// Both are in different, non-current sessions - sort by sessionIdx and then by screenIdx
else {
if (a.sessionIdx === b.sessionIdx) {
return a.screenIdx - b.screenIdx;
} else {
return a.sessionIdx - b.sessionIdx;
}
}
});
}
@boundMethod
renderIcon(option: SwitcherDataType): React.ReactNode {
let tabIcon = option.icon;
if (tabIcon === "default" || tabIcon === "square") {
return <SquareIcon className="left-icon" />;
}
return <i className={`fa-sharp fa-solid fa-${tabIcon}`}></i>;
}
@boundMethod
renderOption(option: SwitcherDataType, index: number): JSX.Element {
if (!this.optionRefs[index]) {
this.optionRefs[index] = React.createRef();
}
return (
<div
key={option.sessionId + "/" + option.screenId}
ref={this.optionRefs[index]}
className={cn("search-option unselectable", {
"focused-option": this.focusedIdx.get() === index,
})}
onClick={() => this.handleSelect(index)}
>
<div className={cn("icon", "color-" + option.color)}>{this.renderIcon(option)}</div>
<div className="tabname">
#{option.sessionName} / {option.screenName}
</div>
</div>
);
}
render() {
let option: SwitcherDataType;
let index: number;
return (
<Modal className="tabswitcher-modal">
<div className="wave-modal-body">
<div className="textfield-wrapper">
<TextField
onChange={this.handleSearch}
maxLength={400}
autoFocus={true}
decoration={{
startDecoration: (
<InputDecoration position="start">
<div className="tabswitcher-search-prefix">Switch to Tab:</div>
</InputDecoration>
),
endDecoration: (
<InputDecoration>
<Tooltip
message={`Type to filter workspaces and tabs.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />}
>
<i className="fa-sharp fa-regular fa-circle-question" />
</Tooltip>
</InputDecoration>
),
}}
/>
</div>
<div className="list-container">
<div ref={this.listWrapperRef} className="list-container-inner">
<div className="options-list">
<For each="option" index="index" of={this.sOptions}>
{this.renderOption(option, index)}
</For>
</div>
</div>
</div>
</div>
</Modal>
);
}
}
const getImportTooltip = (remote: T.RemoteType): React.ReactElement<any, any> => {
if (remote.sshconfigsrc == "sshconfig-import") {
return (
@ -1493,4 +1855,5 @@ export {
ViewRemoteConnDetailModal,
EditRemoteConnModal,
ModalsProvider,
TabSwitcherModal,
};

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

View File

@ -17,7 +17,16 @@ import {
Screen,
Session,
} from "../../../model/model";
import { Toggle, InlineSettingsTextEdit, SettingsError, InfoMessage, Modal, Dropdown, Tooltip } from "../common";
import {
Toggle,
InlineSettingsTextEdit,
SettingsError,
InfoMessage,
Modal,
Dropdown,
Tooltip,
Button,
} from "../common";
import { LineType, RendererPluginType, ClientDataType, CommandRtnType, RemoteType } from "../../../types/types";
import { PluginModel } from "../../../plugins/plugins";
import * as util from "../../../util/util";
@ -219,7 +228,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
return;
}
if (this.screen.getScreenLines().lines.length == 0) {
GlobalCommandRunner.screenDelete(this.screenId);
GlobalCommandRunner.screenDelete(this.screenId, false);
GlobalModel.modalsModel.popModal();
return;
}
@ -229,7 +238,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
if (!result) {
return;
}
let prtn = GlobalCommandRunner.screenDelete(this.screenId);
let prtn = GlobalCommandRunner.screenDelete(this.screenId, false);
commandRtnHandler(prtn, this.errorMessage);
GlobalModel.modalsModel.popModal();
});
@ -632,12 +641,6 @@ class LineSettingsModal extends React.Component<{}, {}> {
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Archived</div>
<div className="settings-input">
<Toggle checked={!!line.archived} onChange={this.handleChangeArchived} />
</div>
</div>
<SettingsError errorMessage={this.errorMessage} />
<div style={{ height: 50 }} />
</div>

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 * as T from "../../types/types";
import * as util from "../../util/util";
import * as appconst from "../appconst";
import "./connections.less";
@ -74,10 +75,31 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
}
@boundMethod
handleImportSshConfig(): void {
importSshConfig(): void {
GlobalCommandRunner.importSshConfig();
}
@boundMethod
handleImportSshConfig(): void {
this.showShellPrompt(this.importSshConfig);
}
@boundMethod
showShellPrompt(cb: () => void): void {
let prtn = GlobalModel.showAlert({
message:
"You are about to install WaveShell on a remote machine. Please be aware that WaveShell will be executed on the remote system.",
confirm: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
cb();
});
}
@boundMethod
handleRead(remoteId: string): void {
GlobalModel.remotesModel.openReadModal(remoteId);

View File

@ -354,6 +354,12 @@ class LineCmd extends React.Component<
GlobalCommandRunner.lineBookmark(line.lineid);
}
@boundMethod
clickDelete() {
let { line } = this.props;
GlobalCommandRunner.lineDelete(line.lineid, true);
}
@boundMethod
clickMinimize() {
mobx.action(() => {
@ -659,6 +665,9 @@ class LineCmd extends React.Component<
{this.renderMeta1(cmd)}
<If condition={!hidePrompt}>{this.renderCmdText(cmd)}</If>
</div>
<div key="delete" title="Delete Line (&#x2318;D)" className="line-icon" onClick={this.clickDelete}>
<i className="fa-sharp fa-regular fa-trash" />
</div>
<div
key="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%);
}
&.has-aichat {
max-height: max(300px, 70%);
}
.remote-status-warning {
display: flex;
flex-direction: row;
@ -197,6 +201,72 @@
}
}
.cmd-aichat {
display: flex;
justify-content: flex-end;
flex-flow: column nowrap;
margin-bottom: 10px;
flex-shrink: 1;
overflow-y: auto;
.chat-window {
overflow-y: auto;
margin-bottom: 5px;
flex-shrink: 1;
flex-direction: column-reverse;
}
.chat-textarea {
color: @term-bright-white;
background-color: @textarea-background;
padding: 0.5em;
resize: none;
overflow: auto;
overflow-wrap: anywhere;
border-color: transparent;
border: none;
font-family: @terminal-font;
flex-shrink: 0;
flex-grow: 1;
border-radius: 4px;
&:focus {
box-shadow: none;
border: none;
outline: none;
}
}
.chat-msg {
margin-top:5px;
margin-bottom:5px;
}
.chat-msg-assistant {
color: @term-white;
}
.chat-msg-user {
.msg-text {
font-family: @markdown-font;
font-size: 14px;
white-space: pre-wrap;
}
}
.chat-msg-error {
color: @term-bright-red;
font-family: @markdown-font;
font-size: 14px;
}
.grow-spacer {
flex: 1 0 10px;
}
}
.cmd-history {
color: @term-white;
margin-bottom: 5px;

View File

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

View File

@ -230,6 +230,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
if (inputModel.inputMode.get() != null) {
inputModel.resetInputMode();
}
inputModel.closeAIAssistantChat();
return;
}
if (e.code == "KeyE" && e.getModifierState("Meta")) {
@ -313,6 +314,10 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
scrollDiv(div, e.code == "PageUp" ? -amt : amt);
}
}
if (e.code == "Space" && e.getModifierState("Control")) {
e.preventDefault();
inputModel.openAIAssistantChat();
}
// console.log(e.code, e.keyCode, e.key, event.which, ctrlMod, e);
})();
}

View File

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

View File

@ -140,7 +140,7 @@ function getWaveSrvCmd() {
let waveSrvPath = getWaveSrvPath();
let waveHome = getWaveHomeDir();
let logFile = path.join(waveHome, "wavesrv.log");
return `${waveSrvPath} >> "${logFile}" 2>&1`;
return `"${waveSrvPath}" >> "${logFile}" 2>&1`;
}
function getWaveSrvCwd() {
@ -173,10 +173,10 @@ let menuTemplate = [
role: "appMenu",
submenu: [
{
label: 'About Wave Terminal',
label: "About Wave Terminal",
click: () => {
MainWindow?.webContents.send('menu-item-about');
}
MainWindow?.webContents.send("menu-item-about");
},
},
{ type: "separator" },
{ role: "services" },
@ -250,7 +250,7 @@ function createMainWindow(clientData) {
minWidth: 800,
minHeight: 600,
transparent: true,
icon: (unamePlatform == "linux") ? "public/logos/wave-logo-dark.png" : undefined,
icon: unamePlatform == "linux" ? "public/logos/wave-logo-dark.png" : undefined,
webPreferences: {
preload: path.join(getAppBasePath(), DistDir, "preload.js"),
},
@ -302,6 +302,11 @@ function createMainWindow(clientData) {
e.preventDefault();
return;
}
if (input.code == "KeyP" && input.meta) {
win.webContents.send("p-cmd", mods);
e.preventDefault();
return;
}
if (input.meta && (input.code == "ArrowUp" || input.code == "ArrowDown")) {
if (input.code == "ArrowUp") {
win.webContents.send("meta-arrowup");
@ -480,6 +485,41 @@ electron.ipcMain.on("reload-window", (event) => {
return;
});
electron.ipcMain.on("open-external-link", async (_, url) => {
try {
await electron.shell.openExternal(url);
} catch (err) {
console.warn("error opening external link", err);
}
});
electron.ipcMain.on("get-last-logs", async (event, numberOfLines) => {
try {
const logPath = path.join(getWaveHomeDir(), "wavesrv.log");
const lastLines = await readLastLinesOfFile(logPath, numberOfLines);
event.reply("last-logs", lastLines);
} catch (err) {
console.error("Error reading log file:", err);
event.reply("last-logs", "Error reading log file.");
}
});
function readLastLinesOfFile(filePath, lineCount) {
return new Promise((resolve, reject) => {
child_process.exec(`tail -n ${lineCount} "${filePath}"`, (err, stdout, stderr) => {
if (err) {
reject(err.message);
return;
}
if (stderr) {
reject(stderr);
return;
}
resolve(stdout);
});
});
}
function getContextMenu(): any {
let menu = new electron.Menu();
let menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") });
@ -535,8 +575,8 @@ function sendWSSC() {
}
function runWaveSrv() {
let pResolve = null;
let pReject = null;
let pResolve: (value: unknown) => void;
let pReject: (reason?: any) => void;
let rtnPromise = new Promise((argResolve, argReject) => {
pResolve = argResolve;
pReject = argReject;
@ -546,8 +586,9 @@ function runWaveSrv() {
if (isDev) {
envCopy[WaveDevVarName] = "1";
}
console.log("trying to run local server", getWaveSrvPath());
let proc = child_process.spawn("bash", ["-c", getWaveSrvCmd()], {
let waveSrvCmd = getWaveSrvCmd();
console.log("trying to run local server", waveSrvCmd);
let proc = child_process.spawn("bash", ["-c", waveSrvCmd], {
cwd: getWaveSrvCwd(),
env: envCopy,
});
@ -555,7 +596,7 @@ function runWaveSrv() {
console.log("wavesrv exit", e);
waveSrvProc = null;
sendWSSC();
pReject(new Error(sprintf("failed to start local server (%s)", getWaveSrvPath())));
pReject(new Error(sprintf("failed to start local server (%s)", waveSrvCmd)));
if (waveSrvShouldRestart) {
waveSrvShouldRestart = false;
this.runWaveSrv();

View File

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

View File

@ -9,6 +9,8 @@ import { boundMethod } from "autobind-decorator";
import { debounce } from "throttle-debounce";
import {
handleJsonFetchResponse,
base64ToString,
stringToBase64,
base64ToArray,
genMergeData,
genMergeDataMap,
@ -63,6 +65,7 @@ import type {
CommandRtnType,
WebCmd,
WebRemote,
OpenAICmdInfoChatMessageType,
} from "../types/types";
import * as T from "../types/types";
import { WSControl } from "./ws";
@ -78,7 +81,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { getRendererContext, cmdStatusIsRunning } from "../app/line/lineutil";
import { MagicLayout } from "../app/magiclayout";
import { modalsRegistry } from "../app/common/modals/modalsRegistry";
import { modalsRegistry } from "../app/common/modals/registry";
import * as appconst from "../app/appconst";
dayjs.extend(customParseFormat);
@ -193,10 +196,13 @@ type ElectronApi = {
getWaveSrvStatus: () => boolean;
restartWaveSrv: () => boolean;
reloadWindow: () => void;
openExternalLink: (url: string) => void;
onTCmd: (callback: (mods: KeyModsType) => void) => void;
onICmd: (callback: (mods: KeyModsType) => void) => void;
onLCmd: (callback: (mods: KeyModsType) => void) => void;
onHCmd: (callback: (mods: KeyModsType) => void) => void;
onPCmd: (callback: (mods: KeyModsType) => void) => void;
onWCmd: (callback: (mods: KeyModsType) => void) => void;
onMenuItemAbout: (callback: () => void) => void;
onMetaArrowUp: (callback: () => void) => void;
onMetaArrowDown: (callback: () => void) => void;
@ -207,6 +213,7 @@ type ElectronApi = {
contextScreen: (screenOpts: { screenId: string }, position: { x: number; y: number }) => void;
contextEditMenu: (position: { x: number; y: number }, opts: ContextMenuOpts) => void;
onWaveSrvStatusChange: (callback: (status: boolean, pid: number) => void) => void;
getLastLogs: (numOfLines: number, callback: (logs: any) => void) => void;
};
function getApi(): ElectronApi {
@ -334,7 +341,7 @@ class Cmd {
type: "feinput",
ck: this.screenId + "/" + this.lineId,
remote: this.remote,
inputdata64: btoa(data),
inputdata64: stringToBase64(data),
};
GlobalModel.sendInputPacket(inputPacket);
}
@ -1228,7 +1235,18 @@ function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
class InputModel {
historyShow: OV<boolean> = mobx.observable.box(false);
infoShow: OV<boolean> = mobx.observable.box(false);
aIChatShow: OV<boolean> = mobx.observable.box(false);
cmdInputHeight: OV<number> = mobx.observable.box(0);
aiChatTextAreaRef: React.RefObject<HTMLTextAreaElement>;
aiChatWindowRef: React.RefObject<HTMLDivElement>;
codeSelectBlockRefArray: Array<React.RefObject<HTMLElement>>;
codeSelectSelectedIndex: OV<number> = mobx.observable.box(-1);
AICmdInfoChatItems: mobx.IObservableArray<OpenAICmdInfoChatMessageType> = mobx.observable.array([], {
name: "aicmdinfo-chat",
});
readonly codeSelectTop: number = -2;
readonly codeSelectBottom: number = -1;
historyType: mobx.IObservableValue<HistoryTypeStrs> = mobx.observable.box("screen");
historyLoading: mobx.IObservableValue<boolean> = mobx.observable.box(false);
@ -1266,6 +1284,10 @@ class InputModel {
this.filteredHistoryItems = mobx.computed(() => {
return this._getFilteredHistoryItems();
});
mobx.action(() => {
this.codeSelectSelectedIndex.set(-1);
this.codeSelectBlockRefArray = [];
})();
}
setInputMode(inputMode: null | "comment" | "global"): void {
@ -1390,6 +1412,11 @@ class InputModel {
})();
}
setOpenAICmdInfoChat(chat: OpenAICmdInfoChatMessageType[]): void {
this.AICmdInfoChatItems.replace(chat);
this.codeSelectBlockRefArray = [];
}
setHistoryShow(show: boolean): void {
if (this.historyShow.get() == show) {
return;
@ -1678,6 +1705,152 @@ class InputModel {
}
}
setCmdInfoChatRefs(
textAreaRef: React.RefObject<HTMLTextAreaElement>,
chatWindowRef: React.RefObject<HTMLDivElement>
) {
this.aiChatTextAreaRef = textAreaRef;
this.aiChatWindowRef = chatWindowRef;
}
setAIChatFocus() {
if (this.aiChatTextAreaRef != null && this.aiChatTextAreaRef.current != null) {
this.aiChatTextAreaRef.current.focus();
}
}
grabCodeSelectSelection() {
if (
this.codeSelectSelectedIndex.get() >= 0 &&
this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length
) {
let curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()];
let codeText = curBlockRef.current.innerText;
codeText = codeText.replace(/\n$/, ""); // remove trailing newline
let newLineValue = this.getCurLine() + " " + codeText;
this.setCurLine(newLineValue);
this.giveFocus();
}
}
addCodeBlockToCodeSelect(blockRef: React.RefObject<HTMLElement>): number {
let rtn = -1;
rtn = this.codeSelectBlockRefArray.length;
this.codeSelectBlockRefArray.push(blockRef);
return rtn;
}
setCodeSelectSelectedCodeBlock(blockIndex: number) {
mobx.action(() => {
if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) {
this.codeSelectSelectedIndex.set(blockIndex);
let currentRef = this.codeSelectBlockRefArray[blockIndex].current;
if (currentRef != null) {
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
let chatWindowTop = this.aiChatWindowRef.current.scrollTop;
let chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100;
let elemTop = currentRef.offsetTop;
let elemBottom = elemTop - currentRef.offsetHeight;
let elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop;
if (!elementIsInView) {
this.aiChatWindowRef.current.scrollTop =
elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
}
}
}
this.codeSelectBlockRefArray = [];
this.setAIChatFocus();
}
})();
}
codeSelectSelectNextNewestCodeBlock() {
// oldest code block = index 0 in array
// this decrements codeSelectSelected index
mobx.action(() => {
if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
this.codeSelectSelectedIndex.set(this.codeSelectBottom);
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
return;
}
let incBlockIndex = this.codeSelectSelectedIndex.get() + 1;
if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) {
this.codeSelectDeselectAll();
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
this.aiChatWindowRef.current.scrollTop = this.aiChatWindowRef.current.scrollHeight;
}
}
if (incBlockIndex >= 0 && incBlockIndex < this.codeSelectBlockRefArray.length) {
this.setCodeSelectSelectedCodeBlock(incBlockIndex);
}
})();
}
codeSelectSelectNextOldestCodeBlock() {
mobx.action(() => {
if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
if (this.codeSelectBlockRefArray.length > 0) {
this.codeSelectSelectedIndex.set(this.codeSelectBlockRefArray.length);
} else {
return;
}
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
return;
}
let decBlockIndex = this.codeSelectSelectedIndex.get() - 1;
if (decBlockIndex < 0) {
this.codeSelectDeselectAll(this.codeSelectTop);
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
this.aiChatWindowRef.current.scrollTop = 0;
}
}
if (decBlockIndex >= 0 && decBlockIndex < this.codeSelectBlockRefArray.length) {
this.setCodeSelectSelectedCodeBlock(decBlockIndex);
}
})();
}
getCodeSelectSelectedIndex() {
return this.codeSelectSelectedIndex.get();
}
getCodeSelectRefArrayLength() {
return this.codeSelectBlockRefArray.length;
}
codeBlockIsSelected(blockIndex: number): boolean {
return blockIndex == this.codeSelectSelectedIndex.get();
}
codeSelectDeselectAll(direction: number = this.codeSelectBottom) {
mobx.action(() => {
this.codeSelectSelectedIndex.set(direction);
this.codeSelectBlockRefArray = [];
})();
}
openAIAssistantChat(): void {
this.aIChatShow.set(true);
this.setAIChatFocus();
}
closeAIAssistantChat(): void {
this.aIChatShow.set(false);
this.giveFocus();
}
clearAIAssistantChat(): void {
let prtn = GlobalModel.submitChatInfoCommand("", "", true);
prtn.then((rtn) => {
if (rtn.success) {
} else {
console.log("submit chat command error: " + rtn.error);
}
}).catch((error) => {
console.log("submit chat command error: ", error);
});
}
hasScrollingInfoMsg(): boolean {
if (!this.infoShow.get()) {
return false;
@ -1773,6 +1946,7 @@ class InputModel {
resetInput(): void {
mobx.action(() => {
this.setHistoryShow(false);
this.closeAIAssistantChat();
this.infoShow.set(false);
this.inputMode.set(null);
this.resetHistory();
@ -2864,7 +3038,7 @@ class RemotesModalModel {
let inputPacket: RemoteInputPacketType = {
type: "remoteinput",
remoteid: remoteId,
inputdata64: btoa(event.key),
inputdata64: stringToBase64(event.key),
};
GlobalModel.sendInputPacket(inputPacket);
}
@ -2922,8 +3096,11 @@ class RemotesModel {
return this.recentConnAddedState.get();
}
seRecentConnAdded(value: boolean) {
@boundMethod
setRecentConnAdded(value: boolean) {
mobx.action(() => {
this.recentConnAddedState.set(value);
})();
}
deSelectRemote(): void {
@ -2935,6 +3112,7 @@ class RemotesModel {
openReadModal(remoteId: string): void {
mobx.action(() => {
this.setRecentConnAdded(false);
this.selectedRemoteId.set(remoteId);
this.remoteEdit.set(null);
GlobalModel.modalsModel.pushModal(appconst.VIEW_REMOTE);
@ -3043,7 +3221,7 @@ class RemotesModel {
let inputPacket: RemoteInputPacketType = {
type: "remoteinput",
remoteid: remoteId,
inputdata64: btoa(event.key),
inputdata64: stringToBase64(event.key),
};
GlobalModel.sendInputPacket(inputPacket);
}
@ -3207,6 +3385,8 @@ class Model {
getApi().onICmd(this.onICmd.bind(this));
getApi().onLCmd(this.onLCmd.bind(this));
getApi().onHCmd(this.onHCmd.bind(this));
getApi().onPCmd(this.onPCmd.bind(this));
getApi().onWCmd(this.onWCmd.bind(this));
getApi().onMenuItemAbout(this.onMenuItemAbout.bind(this));
getApi().onMetaArrowUp(this.onMetaArrowUp.bind(this));
getApi().onMetaArrowDown(this.onMetaArrowDown.bind(this));
@ -3245,6 +3425,16 @@ class Model {
getApi().reloadWindow();
}
/**
* Opens a new default browser window to the given url
* @param {string} url The url to open
*/
openExternalLink(url: string): void {
console.log("opening external link: " + url);
getApi().openExternalLink(url);
console.log("finished opening external link");
}
refocus() {
// givefocus() give back focus to cmd or input
let activeScreen = this.getActiveScreen();
@ -3276,6 +3466,13 @@ class Model {
}
showAlert(alertMessage: AlertMessageType): Promise<boolean> {
if (alertMessage.confirmflag != null) {
let cdata = GlobalModel.clientData.get();
let noConfirm = cdata.clientopts?.confirmflags?.[alertMessage.confirmflag];
if (noConfirm) {
return Promise.resolve(true);
}
}
mobx.action(() => {
this.alertMessage.set(alertMessage);
GlobalModel.modalsModel.pushModal(appconst.ALERT);
@ -3356,7 +3553,7 @@ class Model {
// nothing for now
}
docKeyDownHandler(e: any) {
docKeyDownHandler(e: KeyboardEvent) {
if (isModKeyPress(e)) {
return;
}
@ -3418,6 +3615,54 @@ class Model {
}
}
}
if (e.code == "KeyD" && e.getModifierState("Meta")) {
let ranDelete = this.deleteActiveLine();
if (ranDelete) {
e.preventDefault();
}
}
}
deleteActiveLine(): boolean {
let activeScreen = this.getActiveScreen();
if (activeScreen == null || activeScreen.getFocusType() != "cmd") {
return false;
}
let selectedLine = activeScreen.selectedLine.get();
if (selectedLine == null || selectedLine <= 0) {
return false;
}
let line = activeScreen.getLineByNum(selectedLine);
if (line == null) {
return false;
}
let cmd = activeScreen.getCmd(line);
if (cmd != null) {
if (cmd.isRunning()) {
let info: T.InfoType = { infomsg: "Cannot delete a running command" };
this.inputModel.flashInfoMsg(info, 2000);
return false;
}
}
GlobalCommandRunner.lineDelete(String(selectedLine), true);
return true;
}
onWCmd(e: any, mods: KeyModsType) {
let activeScreen = this.getActiveScreen();
if (activeScreen == null) {
return;
}
let rtnp = this.showAlert({
message: "Are you sure you want to delete this screen?",
confirm: true,
});
rtnp.then((result) => {
if (!result) {
return;
}
GlobalCommandRunner.screenDelete(activeScreen.screenId, true);
});
}
clearModals(): boolean {
@ -3474,6 +3719,10 @@ class Model {
})();
}
getLastLogs(numbOfLines: number, cb: (logs: any) => void): void {
getApi().getLastLogs(numbOfLines, cb);
}
getContentHeight(context: RendererContext): number {
let key = context.screenId + "/" + context.lineId;
return this.termUsedRowsCache[key];
@ -3535,6 +3784,10 @@ class Model {
GlobalModel.historyViewModel.reSearch();
}
onPCmd(e: any, mods: KeyModsType) {
GlobalModel.modalsModel.pushModal(appconst.TAB_SWITCHER);
}
getFocusedLine(): LineFocusType {
if (this.inputModel.hasFocus()) {
return { cmdInputFocus: true };
@ -3711,8 +3964,8 @@ class Model {
this.remotes.clear();
}
this.updateRemotes(update.remotes);
// This code's purpose is to show view remote connection modal when a new connection is added
if (update.remotes && update.remotes.length && this.remotesModel.recentConnAddedState.get()) {
GlobalModel.remotesModel.closeModal();
GlobalModel.remotesModel.openReadModal(update.remotes![0].remoteid);
}
}
@ -3744,6 +3997,10 @@ class Model {
this.remotesModel.openEditModal({ ...rview.remoteedit });
}
}
if (interactive && "alertmessage" in update) {
let alertMessage: AlertMessageType = update.alertmessage;
this.showAlert(alertMessage);
}
if ("cmdline" in update) {
this.inputModel.updateCmdLine(update.cmdline);
}
@ -3756,6 +4013,9 @@ class Model {
this.sessionListLoaded.set(true);
this.remotesLoaded.set(true);
}
if ("openaicmdinfochat" in update) {
this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat);
}
// console.log("run-update>", Date.now(), interactive, update);
}
@ -3990,6 +4250,28 @@ class Model {
return this.submitCommandPacket(pk, interactive);
}
submitChatInfoCommand(chatMsg: string, curLineStr: string, clear: boolean): Promise<CommandRtnType> {
let commandStr = "/chat " + chatMsg;
let interactive = false;
let pk: FeCmdPacketType = {
type: "fecmd",
metacmd: "eval",
args: [commandStr],
kwargs: {},
uicontext: this.getUIContext(),
interactive: interactive,
rawstr: chatMsg,
};
pk.kwargs["nohist"] = "1";
if (clear) {
pk.kwargs["cmdinfoclear"] = "1";
} else {
pk.kwargs["cmdinfo"] = "1";
}
pk.kwargs["curline"] = curLineStr;
return this.submitCommandPacket(pk, interactive);
}
submitRawCommand(cmdStr: string, addToHistory: boolean, interactive: boolean): Promise<CommandRtnType> {
let pk: FeCmdPacketType = {
type: "fecmd",
@ -4190,7 +4472,7 @@ class Model {
return resp.text() as any;
}
contentType = resp.headers.get("Content-Type");
fileInfo = JSON.parse(atob(resp.headers.get("X-FileInfo")));
fileInfo = JSON.parse(base64ToString(resp.headers.get("X-FileInfo")));
return resp.blob();
})
.then((blobOrText: any) => {
@ -4268,11 +4550,15 @@ class CommandRunner {
GlobalModel.submitCommand("session", null, [session], { nohist: "1" }, false);
}
switchScreen(screen: string) {
switchScreen(screen: string, session?: string) {
mobx.action(() => {
GlobalModel.activeMainView.set("session");
})();
GlobalModel.submitCommand("screen", null, [screen], { nohist: "1" }, false);
let kwargs = { nohist: "1" };
if (session != null) {
kwargs["session"] = session;
}
GlobalModel.submitCommand("screen", null, [screen], kwargs, false);
}
lineView(sessionId: string, screenId: string, lineNum?: number) {
@ -4290,6 +4576,10 @@ class CommandRunner {
return GlobalModel.submitCommand("line", "archive", [lineArg, archiveStr], kwargs, false);
}
lineDelete(lineArg: string, interactive: boolean): Promise<CommandRtnType> {
return GlobalModel.submitCommand("line", "delete", [lineArg], { nohist: "1" }, interactive);
}
lineSet(lineArg: string, opts: { renderer?: string }): Promise<CommandRtnType> {
let kwargs = { nohist: "1" };
if ("renderer" in opts) {
@ -4339,8 +4629,8 @@ class CommandRunner {
);
}
screenDelete(screenId: string): Promise<CommandRtnType> {
return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, false);
screenDelete(screenId: string, interactive: boolean): Promise<CommandRtnType> {
return GlobalModel.submitCommand("screen", "delete", [screenId], { nohist: "1" }, interactive);
}
screenWebShare(screenId: string, shouldShare: boolean): Promise<CommandRtnType> {
@ -4407,7 +4697,7 @@ class CommandRunner {
}
importSshConfig() {
GlobalModel.submitCommand("remote", "parse", null, null, false);
GlobalModel.submitCommand("remote", "parse", null, { nohist: "1", visual: "1" }, true);
}
screenSelectLine(lineArg: string, focusVal?: string) {
@ -4573,6 +4863,12 @@ class CommandRunner {
GlobalModel.submitCommand("client", "accepttos", null, { nohist: "1" }, true);
}
clientSetConfirmFlag(flag: string, value: boolean): Promise<CommandRtnType> {
let kwargs = { nohist: "1" };
let valueStr = value ? "1" : "0";
return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false);
}
editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
let kwargs = {
nohist: "1",

View File

@ -3,7 +3,8 @@
import * as React from "react";
import * as T from "../../types/types";
import Editor from "@monaco-editor/react";
import Editor, { Monaco } from "@monaco-editor/react";
import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
import { Markdown } from "../../app/common/common";
import { GlobalModel, GlobalCommandRunner } from "../../model/model";
import Split from "react-split-it";
@ -146,21 +147,24 @@ class SourceCodeRenderer extends React.Component<
}
};
handleEditorDidMount = (editor, monaco) => {
handleEditorDidMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
this.monacoEditor = editor;
this.setInitialLanguage(editor);
this.setEditorHeight();
editor.onKeyDown((e) => {
if (e.code === "KeyS" && (e.ctrlKey || e.metaKey) && this.state.isSave) {
editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
if (e.code === "KeyS" && e.metaKey && this.state.isSave) {
e.preventDefault();
e.stopPropagation();
this.doSave();
}
if (e.code === "KeyD" && (e.ctrlKey || e.metaKey)) {
if (e.code === "KeyD" && e.metaKey) {
e.preventDefault();
e.stopPropagation();
this.doClose();
}
if (e.code === "KeyP" && (e.ctrlKey || e.metaKey)) {
if (e.code === "KeyP" && e.metaKey) {
e.preventDefault();
e.stopPropagation();
this.togglePreview();
}
});

View File

@ -3,10 +3,13 @@
import * as mobx from "mobx";
import { Terminal } from "xterm";
//TODO: replace with `@xterm/addon-web-links` when it's available as stable
import { WebLinksAddon } from "xterm-addon-web-links";
import { sprintf } from "sprintf-js";
import { boundMethod } from "autobind-decorator";
import { windowWidthToCols, windowHeightToRows } from "../../util/textmeasure";
import { boundInt } from "../../util/util";
import { GlobalModel } from "../../model/model"
import type {
TermContextUnion,
TermOptsType,
@ -96,6 +99,21 @@ class TermWrap {
fontFamily: "JetBrains Mono",
theme: { foreground: terminal.foreground, background: terminal.background },
});
this.terminal.loadAddon(new WebLinksAddon((e, uri) => {
e.preventDefault();
switch (GlobalModel.platform) {
case "darwin":
if (e.metaKey) {
GlobalModel.openExternalLink(uri);
}
break;
default:
if (e.ctrlKey) {
GlobalModel.openExternalLink(uri);
}
break;
}
}));
this.terminal._core._inputHandler._parser.setErrorHandler((state) => {
this.numParseErrors++;
return state;

View File

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

View File

@ -6,6 +6,7 @@ import { sprintf } from "sprintf-js";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import type { RemoteType, CommandRtnType } from "../types/types";
import base64 from "base64-js";
type OV<V> = mobx.IObservableValue<V>;
@ -70,6 +71,16 @@ function handleJsonFetchResponse(url: URL, resp: any): Promise<any> {
return rtnData;
}
function base64ToString(b64: string): string {
let stringBytes = base64.toByteArray(b64)
return new TextDecoder().decode(stringBytes)
}
function stringToBase64(input: string): string {
let stringBytes = new TextEncoder().encode(input)
return base64.fromByteArray(stringBytes)
}
function base64ToArray(b64: string): Uint8Array {
let rawStr = atob(b64);
let rtnArr = new Uint8Array(new ArrayBuffer(rawStr.length));
@ -229,26 +240,6 @@ function genMergeDataMap<ObjType extends IObjType<DataType>, DataType extends ID
return rtn;
}
function parseEnv0(envStr64: string): Map<string, string> {
let envStr = atob(envStr64);
let parts = envStr.split("\x00");
let rtn: Map<string, string> = new Map();
for (let i = 0; i < parts.length; i++) {
let part = parts[i];
if (part == "") {
continue;
}
let eqIdx = part.indexOf("=");
if (eqIdx == -1) {
continue;
}
let varName = part.substr(0, eqIdx);
let varVal = part.substr(eqIdx + 1);
rtn.set(varName, varVal);
}
return rtn;
}
function boundInt(ival: number, minVal: number, maxVal: number): number {
if (ival < minVal) {
return minVal;
@ -404,13 +395,22 @@ function commandRtnHandler(prtn: Promise<CommandRtnType>, errorMessage: OV<strin
});
}
function getRemoteName(remote: RemoteType): string {
if (remote == null) {
return "";
}
let { remotealias, remotecanonicalname } = remote;
return remotealias ? `${remotealias} [${remotecanonicalname}]` : remotecanonicalname;
}
export {
handleJsonFetchResponse,
base64ToString,
stringToBase64,
base64ToArray,
genMergeData,
genMergeDataMap,
genMergeSimpleData,
parseEnv0,
boundInt,
isModKeyPress,
incObs,
@ -428,4 +428,5 @@ export {
getColorRGB,
commandRtnHandler,
getRemoteConnVal,
getRemoteName,
};

View File

@ -70,6 +70,8 @@ const PacketEOFStr = "EOF"
var TypeStrToFactory map[string]reflect.Type
const OpenAICmdInfoChatGreetingMessage = "Hello, may I help you with this command? \n(Press ESC to close and Ctrl+L to clear chat buffer)"
func init() {
TypeStrToFactory = make(map[string]reflect.Type)
TypeStrToFactory[RunPacketStr] = reflect.TypeOf(RunPacketType{})
@ -729,6 +731,14 @@ type OpenAIUsageType struct {
TotalTokens int `json:"total_tokens,omitempty"`
}
type OpenAICmdInfoPacketOutputType struct {
Model string `json:"model,omitempty"`
Created int64 `json:"created,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
type OpenAIPacketType struct {
Type string `json:"type"`
Model string `json:"model,omitempty"`
@ -843,6 +853,14 @@ func MakeWriteFileDonePacket(reqId string) *WriteFileDonePacketType {
}
}
type OpenAICmdInfoChatMessage struct {
MessageID int `json:"messageid"`
IsAssistantResponse bool `json:"isassistantresponse,omitempty"`
AssistantResponse *OpenAICmdInfoPacketOutputType `json:"assistantresponse,omitempty"`
UserQuery string `json:"userquery,omitempty"`
UserEngineeredQuery string `json:"userengineeredquery,omitempty"`
}
type OpenAIPromptMessageType struct {
Role string `json:"role"`
Content string `json:"content"`
@ -927,14 +945,6 @@ func ParseJsonPacket(jsonBuf []byte) (PacketType, error) {
return pk, nil
}
func sanitizeBytes(buf []byte) {
for idx, b := range buf {
if b >= 127 || (b < 32 && b != 10 && b != 13) {
buf[idx] = '?'
}
}
}
type SendError struct {
IsWriteError bool // fatal
IsMarshalError bool // not fatal
@ -970,7 +980,6 @@ func MarshalPacket(packet PacketType) ([]byte, error) {
outBuf.Write(jsonBytes)
outBuf.WriteByte('\n')
outBytes := outBuf.Bytes()
sanitizeBytes(outBytes)
return outBytes, nil
}

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/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 RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"}
var ConfirmFlags = []string{"hideshellprompt"}
var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"}
var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
@ -115,8 +116,8 @@ var SetVarScopes = []SetVarScope{
{ScopeName: "remote", VarNames: []string{}},
}
var userHostRe = regexp.MustCompile("^(sudo@)?([a-z][a-z0-9._@-]*)@([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$")
var remoteAliasRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_-]*$")
var userHostRe = regexp.MustCompile(`^(sudo@)?([a-z][a-z0-9._@\\-]*)@([a-z0-9][a-z0-9.-]*)(?::([0-9]+))?$`)
var remoteAliasRe = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
var genericNameRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_ .()<>,/\"'\\[\\]{}=+$@!*-]*$")
var rendererRe = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_.:-]*$")
var positionRe = regexp.MustCompile("^((S?\\+|E?-)?[0-9]+|(\\+|-|S|E))$")
@ -213,6 +214,7 @@ func init() {
registerCmdFn("client:set", ClientSetCommand)
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
registerCmdFn("client:accepttos", ClientAcceptTosCommand)
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
registerCmdFn("sidebar:open", SidebarOpenCommand)
registerCmdFn("sidebar:close", SidebarCloseCommand)
@ -1255,8 +1257,15 @@ func parseRemoteEditArgs(isNew bool, pk *scpacket.FeCommandPacketType, isLocal b
if portVal == 0 && uhPort != 0 {
portVal = uhPort
}
if portVal < 0 || portVal > 65535 {
// 0 is used as a sentinel value for the default in this case
return nil, fmt.Errorf("invalid port argument, \"%d\" is not in the range of 1 to 65535", portVal)
}
sshOpts.SSHPort = portVal
canonicalName = remoteUser + "@" + remoteHost
if portVal != 0 && portVal != 22 {
canonicalName = canonicalName + ":" + strconv.Itoa(portVal)
}
if isSudo {
canonicalName = "sudo@" + canonicalName
}
@ -1511,6 +1520,47 @@ type HostInfoType struct {
Ignore bool
}
func createSshImportSummary(changeList map[string][]string) string {
totalNumChanges := len(changeList["create"]) + len(changeList["delete"]) + len(changeList["update"]) + len(changeList["createErr"]) + len(changeList["deleteErr"]) + len(changeList["updateErr"])
if totalNumChanges == 0 {
return "No changes made from ssh config import"
}
remoteStatusMsgs := map[string]string{
"delete": "Deleted %d connection%s: %s",
"create": "Created %d connection%s: %s",
"update": "Edited %d connection%s: %s",
"deleteErr": "Error deleting %d connection%s: %s",
"createErr": "Error creating %d connection%s: %s",
"updateErr": "Error editing %d connection%s: %s",
}
changeTypeKeys := []string{"delete", "create", "update", "deleteErr", "createErr", "updateErr"}
var outMsgs []string
for _, changeTypeKey := range changeTypeKeys {
changes := changeList[changeTypeKey]
if len(changes) > 0 {
rawStatusMsg := remoteStatusMsgs[changeTypeKey]
var pluralize string
if len(changes) == 1 {
pluralize = ""
} else {
pluralize = "s"
}
newMsg := fmt.Sprintf(rawStatusMsg, len(changes), pluralize, strings.Join(changes, ", "))
outMsgs = append(outMsgs, newMsg)
}
}
var pluralize string
if totalNumChanges == 1 {
pluralize = ""
} else {
pluralize = "s"
}
return fmt.Sprintf("%d connection%s changed:\n\n%s", totalNumChanges, pluralize, strings.Join(outMsgs, "\n\n"))
}
func NewHostInfo(hostName string) (*HostInfoType, error) {
userName, _ := ssh_config.GetStrict(hostName, "User")
if userName == "" {
@ -1528,18 +1578,18 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
portStr, _ := ssh_config.GetStrict(hostName, "Port")
var portVal int
if portStr != "" {
if portStr != "" && portStr != "22" {
canonicalName = canonicalName + ":" + portStr
var err error
portVal, err = strconv.Atoi(portStr)
if err != nil {
// do not make assumptions about port if incorrectly configured
return nil, fmt.Errorf("could not parse \"%s\" (%s) - %s could not be converted to a valid port\n", hostName, canonicalName, portStr)
}
if int(int16(portVal)) != portVal {
if portVal <= 0 || portVal > 65535 {
return nil, fmt.Errorf("could not parse port \"%d\": number is not valid for a port\n", portVal)
}
}
identityFile, _ := ssh_config.GetStrict(hostName, "IdentityFile")
passwordAuth, _ := ssh_config.GetStrict(hostName, "PasswordAuthentication")
@ -1579,6 +1629,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
localConfig := filepath.Join(home, ".ssh", "config")
systemConfig := filepath.Join("/", "ssh", "config")
sshConfigFiles := []string{localConfig, systemConfig}
ssh_config.ReloadConfigs()
hostPatterns, hostPatternsErr := resolveSshConfigPatterns(sshConfigFiles)
if hostPatternsErr != nil {
return nil, hostPatternsErr
@ -1600,6 +1651,8 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
hostInfoInConfig[hostInfo.CanonicalName] = hostInfo
}
remoteChangeList := make(map[string][]string)
// remove all previously imported remotes that
// no longer have a canonical pattern in the config files
for importedRemoteCanonicalName, importedRemote := range previouslyImportedRemotes {
@ -1608,17 +1661,17 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
if !importedRemote.Archived && (hostInfo == nil || hostInfo.Ignore) {
err = remote.ArchiveRemote(ctx, importedRemote.RemoteId)
if err != nil {
remoteChangeList["deleteErr"] = append(remoteChangeList["deleteErr"], importedRemote.RemoteCanonicalName)
log.Printf("sshconfig-import: failed to remove remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName)
} else {
remoteChangeList["delete"] = append(remoteChangeList["delete"], importedRemote.RemoteCanonicalName)
log.Printf("sshconfig-import: archived remote \"%s\" (%s)\n", importedRemote.RemoteAlias, importedRemote.RemoteCanonicalName)
}
}
}
var updatedRemotes []string
for _, hostInfo := range parsedHostData {
previouslyImportedRemote := previouslyImportedRemotes[hostInfo.CanonicalName]
updatedRemotes = append(updatedRemotes, hostInfo.CanonicalName)
if hostInfo.Ignore {
log.Printf("sshconfig-import: ignore remote[%s] as specified in config file\n", hostInfo.CanonicalName)
continue
@ -1626,27 +1679,31 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
if previouslyImportedRemote != nil && !previouslyImportedRemote.Archived {
// this already existed and was created via import
// it needs to be updated instead of created
editMap := make(map[string]interface{})
editMap[sstore.RemoteField_Alias] = hostInfo.Host
editMap[sstore.RemoteField_ConnectMode] = hostInfo.ConnectMode
// changing port is unique to imports because it lets us avoid conflicts
// if the port is changed in the ssh config
editMap[sstore.RemoteField_SSHPort] = hostInfo.Port
if hostInfo.SshKeyFile != "" {
editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile
}
msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId)
if msh == nil {
remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName)
log.Printf("strange, msh for remote %s [%s] not found\n", hostInfo.CanonicalName, previouslyImportedRemote.RemoteId)
continue
} else {
}
if msh.Remote.ConnectMode == hostInfo.ConnectMode && msh.Remote.SSHOpts.SSHIdentity == hostInfo.SshKeyFile && msh.Remote.RemoteAlias == hostInfo.Host {
// silently skip this one. it didn't fail, but no changes were needed
continue
}
err := msh.UpdateRemote(ctx, editMap)
if err != nil {
remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName)
log.Printf("error updating remote[%s]: %v\n", hostInfo.CanonicalName, err)
continue
}
}
remoteChangeList["update"] = append(remoteChangeList["update"], hostInfo.CanonicalName)
log.Printf("sshconfig-import: found previously imported remote with canonical name \"%s\": it has been updated\n", hostInfo.CanonicalName)
} else {
sshOpts := &sstore.SSHOpts{
@ -1675,21 +1732,31 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
}
err := remote.AddRemote(ctx, r, false)
if err != nil {
remoteChangeList["createErr"] = append(remoteChangeList["createErr"], hostInfo.CanonicalName)
log.Printf("sshconfig-import: failed to add remote \"%s\" (%s): it is being skipped\n", hostInfo.Host, hostInfo.CanonicalName)
continue
}
remoteChangeList["create"] = append(remoteChangeList["create"], hostInfo.CanonicalName)
log.Printf("sshconfig-import: created remote \"%s\" (%s)\n", hostInfo.Host, hostInfo.CanonicalName)
}
}
update := &sstore.ModelUpdate{Remotes: remote.GetAllRemoteRuntimeState()}
update.Info = &sstore.InfoMsgType{}
if len(updatedRemotes) == 0 {
update.Info.InfoMsg = "no connections imported from ssh config."
} else {
update.Info.InfoMsg = fmt.Sprintf("imported %d connection(s) from ssh config file: %s\n", len(updatedRemotes), strings.Join(updatedRemotes, ", "))
outMsg := createSshImportSummary(remoteChangeList)
visualEdit := resolveBool(pk.Kwargs["visual"], false)
if visualEdit {
update := &sstore.ModelUpdate{}
update.AlertMessage = &sstore.AlertMessageType{
Title: "SSH Config Import",
Message: outMsg,
Markdown: true,
}
return update, nil
} else {
update := &sstore.ModelUpdate{}
update.Info = &sstore.InfoMsgType{}
update.Info.InfoMsg = outMsg
return update, nil
}
}
func ScreenShowAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
@ -1934,6 +2001,114 @@ func doOpenAICompletion(cmd *sstore.CmdType, opts *sstore.OpenAIOptsType, prompt
return
}
func writePacketToUpdateBus(ctx context.Context, cmd *sstore.CmdType, pk *packet.OpenAICmdInfoChatMessage) {
update, err := sstore.UpdateWithAddNewOpenAICmdInfoPacket(ctx, cmd.ScreenId, pk)
if err != nil {
log.Printf("Open AI Update packet err: %v\n", err)
}
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
}
func updateAsstResponseAndWriteToUpdateBus(ctx context.Context, cmd *sstore.CmdType, pk *packet.OpenAICmdInfoChatMessage, messageID int) {
update, err := sstore.UpdateWithUpdateOpenAICmdInfoPacket(ctx, cmd.ScreenId, messageID, pk)
if err != nil {
log.Printf("Open AI Update packet err: %v\n", err)
}
sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update)
}
func getCmdInfoEngineeredPrompt(userQuery string, curLineStr string) string {
rtn := "You are an expert on the command line terminal. Your task is to help me write a command."
if curLineStr != "" {
rtn += "My current command is: " + curLineStr
}
rtn += ". My question is: " + userQuery + "."
return rtn
}
func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType, curLineStr string) {
var hadError bool
log.Println("had error: ", hadError)
ctx, cancelFn := context.WithTimeout(context.Background(), OpenAIStreamTimeout)
defer cancelFn()
defer func() {
r := recover()
if r != nil {
panicMsg := fmt.Sprintf("panic: %v", r)
log.Printf("panic in doOpenAICompletion: %s\n", panicMsg)
hadError = true
}
}()
var ch chan *packet.OpenAIPacketType
var err error
if opts.APIToken == "" {
var conn *websocket.Conn
ch, conn, err = openai.RunCloudCompletionStream(ctx, clientId, opts, prompt)
if conn != nil {
defer conn.Close()
}
} else {
ch, err = openai.RunCompletionStream(ctx, opts, prompt)
}
asstOutputPk := &packet.OpenAICmdInfoPacketOutputType{
Model: "",
Created: 0,
FinishReason: "",
Message: "",
}
asstOutputMessageID := sstore.ScreenMemGetCmdInfoMessageCount(cmd.ScreenId)
asstMessagePk := &packet.OpenAICmdInfoChatMessage{IsAssistantResponse: true, AssistantResponse: asstOutputPk, MessageID: asstOutputMessageID}
if err != nil {
asstOutputPk.Error = fmt.Sprintf("Error calling OpenAI API: %v", err)
writePacketToUpdateBus(ctx, cmd, asstMessagePk)
return
}
writePacketToUpdateBus(ctx, cmd, asstMessagePk)
doneWaitingForPackets := false
for !doneWaitingForPackets {
select {
case <-time.After(OpenAIPacketTimeout):
// timeout reading from channel
hadError = true
doneWaitingForPackets = true
asstOutputPk.Error = "timeout waiting for server response"
updateAsstResponseAndWriteToUpdateBus(ctx, cmd, asstMessagePk, asstOutputMessageID)
break
case pk, ok := <-ch:
if ok {
// got a packet
if pk.Error != "" {
hadError = true
asstOutputPk.Error = pk.Error
}
if pk.Model != "" && pk.Index == 0 {
asstOutputPk.Model = pk.Model
asstOutputPk.Created = pk.Created
asstOutputPk.FinishReason = pk.FinishReason
if pk.Text != "" {
asstOutputPk.Message += pk.Text
}
}
if pk.Index == 0 {
if pk.FinishReason != "" {
asstOutputPk.FinishReason = pk.FinishReason
}
if pk.Text != "" {
asstOutputPk.Message += pk.Text
}
}
asstMessagePk.AssistantResponse = asstOutputPk
updateAsstResponseAndWriteToUpdateBus(ctx, cmd, asstMessagePk, asstOutputMessageID)
} else {
// channel closed
doneWaitingForPackets = true
break
}
}
}
}
func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType) {
var outputPos int64
var hadError bool
@ -2019,6 +2194,23 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore
return
}
func BuildOpenAIPromptArrayWithContext(messages []*packet.OpenAICmdInfoChatMessage) []packet.OpenAIPromptMessageType {
rtn := make([]packet.OpenAIPromptMessageType, 0)
for _, msg := range messages {
content := msg.UserEngineeredQuery
if msg.UserEngineeredQuery == "" {
content = msg.UserQuery
}
msgRole := sstore.OpenAIRoleUser
if msg.IsAssistantResponse {
msgRole = sstore.OpenAIRoleAssistant
content = msg.AssistantResponse.Message
}
rtn = append(rtn, packet.OpenAIPromptMessageType{Role: msgRole, Content: content})
}
return rtn
}
func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen)
if err != nil {
@ -2044,9 +2236,6 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstor
opts.MaxTokens = openai.DefaultMaxTokens
}
promptStr := firstArg(pk)
if promptStr == "" {
return nil, fmt.Errorf("openai error, prompt string is blank")
}
ptermVal := defaultStr(pk.Kwargs["wterm"], DefaultPTERM)
pkTermOpts, err := GetUITermOpts(pk.UIContext.WinSize, ptermVal)
if err != nil {
@ -2057,11 +2246,40 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstor
if err != nil {
return nil, fmt.Errorf("openai error, cannot make dyn cmd")
}
if resolveBool(pk.Kwargs["cmdinfo"], false) {
if promptStr == "" {
// this is requesting an update without wanting an openai query
update, err := sstore.UpdateWithCurrentOpenAICmdInfoChat(cmd.ScreenId)
if err != nil {
return nil, fmt.Errorf("error getting update for CmdInfoChat %v", err)
}
return update, nil
}
curLineStr := defaultStr(pk.Kwargs["curline"], "")
userQueryPk := &packet.OpenAICmdInfoChatMessage{UserQuery: promptStr, MessageID: sstore.ScreenMemGetCmdInfoMessageCount(cmd.ScreenId)}
engineeredQuery := getCmdInfoEngineeredPrompt(promptStr, curLineStr)
userQueryPk.UserEngineeredQuery = engineeredQuery
writePacketToUpdateBus(ctx, cmd, userQueryPk)
prompt := BuildOpenAIPromptArrayWithContext(sstore.ScreenMemGetCmdInfoChat(cmd.ScreenId).Messages)
go doOpenAICmdInfoCompletion(cmd, clientData.ClientId, opts, prompt, curLineStr)
update := &sstore.ModelUpdate{}
return update, nil
}
prompt := []packet.OpenAIPromptMessageType{{Role: sstore.OpenAIRoleUser, Content: promptStr}}
if resolveBool(pk.Kwargs["cmdinfoclear"], false) {
update, err := sstore.UpdateWithClearOpenAICmdInfo(cmd.ScreenId)
if err != nil {
return nil, fmt.Errorf("error clearing CmdInfoChat: %v", err)
}
return update, nil
}
if promptStr == "" {
return nil, fmt.Errorf("openai error, prompt string is blank")
}
line, err := sstore.AddOpenAILine(ctx, ids.ScreenId, DefaultUserId, cmd)
if err != nil {
return nil, fmt.Errorf("cannot add new line: %v", err)
}
prompt := []packet.OpenAIPromptMessageType{{Role: sstore.OpenAIRoleUser, Content: promptStr}}
if resolveBool(pk.Kwargs["stream"], true) {
go doOpenAIStreamCompletion(cmd, clientData.ClientId, opts, prompt)
} else {
@ -3441,7 +3659,7 @@ func LineDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
}
err = sstore.DeleteLinesByIds(ctx, ids.ScreenId, lineIds)
if err != nil {
return nil, fmt.Errorf("/line:delete error purging lines: %v", err)
return nil, fmt.Errorf("/line:delete error deleting lines: %v", err)
}
update := &sstore.ModelUpdate{}
for _, lineId := range lineIds {
@ -3452,6 +3670,11 @@ func LineDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
}
update.Lines = append(update.Lines, lineObj)
}
screen, err := sstore.FixupScreenSelectedLine(ctx, ids.ScreenId)
if err != nil {
return nil, fmt.Errorf("/line:delete error fixing up screen: %v", err)
}
update.Screens = []*sstore.ScreenType{screen}
return update, nil
}
@ -4019,6 +4242,57 @@ func ClientAcceptTosCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp
return update, nil
}
var confirmKeyRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// confirm flags must be all lowercase and only contain letters, numbers, and underscores (and start with letter)
func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
// Check for valid arguments length
if len(pk.Args) < 2 {
return nil, fmt.Errorf("invalid arguments: expected at least 2, got %d", len(pk.Args))
}
// Extract confirmKey and value from pk.Args
confirmKey := pk.Args[0]
if !confirmKeyRe.MatchString(confirmKey) {
return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey)
}
value := resolveBool(pk.Args[1], true)
validKey := utilfn.ContainsStr(ConfirmFlags, confirmKey)
if !validKey {
return nil, fmt.Errorf("invalid confirm flag key: %s", confirmKey)
}
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
}
// Initialize ConfirmFlags if it's nil
if clientData.ClientOpts.ConfirmFlags == nil {
clientData.ClientOpts.ConfirmFlags = make(map[string]bool)
}
// Set the confirm flag
clientData.ClientOpts.ConfirmFlags[confirmKey] = value
err = sstore.SetClientOpts(ctx, clientData.ClientOpts)
if err != nil {
return nil, fmt.Errorf("error updating client data: %v", err)
}
// Retrieve updated client data
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
}
update := &sstore.ModelUpdate{
ClientData: clientData,
}
return update, nil
}
func validateOpenAIAPIToken(key string) error {
if len(key) > MaxOpenAIAPITokenLen {
return fmt.Errorf("invalid openai token, too long")

View File

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

View File

@ -21,6 +21,7 @@ import (
"syscall"
"time"
"github.com/alessio/shellescape"
"github.com/armon/circbuf"
"github.com/creack/pty"
"github.com/google/uuid"
@ -66,9 +67,9 @@ func MakeLocalMShellCommandStr(isSudo bool) (string, error) {
return "", err
}
if isSudo {
return fmt.Sprintf(`%s; sudo %s --server`, PrintPingPacket, mshellPath), nil
return fmt.Sprintf(`%s; sudo %s --server`, PrintPingPacket, shellescape.Quote(mshellPath)), nil
} else {
return fmt.Sprintf(`%s; %s --server`, PrintPingPacket, mshellPath), nil
return fmt.Sprintf(`%s; %s --server`, PrintPingPacket, shellescape.Quote(mshellPath)), nil
}
}
@ -1125,6 +1126,9 @@ func addScVarsToState(state *packet.ShellState) *packet.ShellState {
envMap := shexec.DeclMapFromState(&rtn)
envMap["PROMPT"] = &shexec.DeclareDeclType{Name: "PROMPT", Value: "1", Args: "x"}
envMap["PROMPT_VERSION"] = &shexec.DeclareDeclType{Name: "PROMPT_VERSION", Value: scbase.WaveVersion, Args: "x"}
if _, exists := envMap["LANG"]; !exists {
envMap["LANG"] = &shexec.DeclareDeclType{Name: "LANG", Value: scbase.DetermineLang(), Args: "x"}
}
rtn.ShellVars = shexec.SerializeDeclMap(envMap)
return &rtn
}

View File

@ -377,3 +377,30 @@ func MacOSRelease() string {
})
return osRelease
}
var osLangOnce = &sync.Once{}
var osLang string
func determineLang() string {
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelFn()
if runtime.GOOS == "darwin" {
out, err := exec.CommandContext(ctx, "defaults", "read", "-g", "AppleLocale").CombinedOutput()
if err != nil {
log.Printf("error executing 'defaults read -g AppleLocale': %v\n", err)
return ""
}
return strings.TrimSpace(string(out)) + ".UTF-8"
} else {
// this is specifically to get the wavesrv LANG so waveshell
// on a remote uses the same LANG
return os.Getenv("LANG")
}
}
func DetermineLang() string {
osLangOnce.Do(func() {
osLang = determineLang()
})
return osLang
}

View File

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

View File

@ -748,6 +748,7 @@ func InsertScreen(ctx context.Context, sessionId string, origScreenName string,
return nil, txErr
}
update.Sessions = []*SessionType{bareSession}
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(newScreenId).Messages
}
return update, nil
}
@ -854,6 +855,29 @@ func GetCmdByScreenId(ctx context.Context, screenId string, lineId string) (*Cmd
})
}
func UpdateWithClearOpenAICmdInfo(screenId string) (*ModelUpdate, error) {
ScreenMemClearCmdInfoChat(screenId)
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
}
func UpdateWithAddNewOpenAICmdInfoPacket(ctx context.Context, screenId string, pk *packet.OpenAICmdInfoChatMessage) (*ModelUpdate, error) {
ScreenMemAddCmdInfoChatMessage(screenId, pk)
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
}
func UpdateWithCurrentOpenAICmdInfoChat(screenId string) (*ModelUpdate, error) {
cmdInfoUpdate := ScreenMemGetCmdInfoChat(screenId).Messages
return &ModelUpdate{OpenAICmdInfoChat: cmdInfoUpdate}, nil
}
func UpdateWithUpdateOpenAICmdInfoPacket(ctx context.Context, screenId string, messageID int, pk *packet.OpenAICmdInfoChatMessage) (*ModelUpdate, error) {
err := ScreenMemUpdateCmdInfoChatMessage(screenId, messageID, pk)
if err != nil {
return nil, err
}
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
}
func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.CmdDonePacketType, status string) (*ModelUpdate, error) {
if donePk == nil {
return nil, fmt.Errorf("invalid cmddone packet")
@ -1039,6 +1063,7 @@ func SwitchScreenById(ctx context.Context, sessionId string, screenId string) (*
memState := GetScreenMemState(screenId)
if memState != nil {
update.CmdLine = &memState.CmdInputText
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(screenId).Messages
}
return update, nil
}
@ -1704,7 +1729,6 @@ const (
RemoteField_ConnectMode = "connectmode" // string
RemoteField_SSHKey = "sshkey" // string
RemoteField_SSHPassword = "sshpassword" // string
RemoteField_SSHPort = "sshport" // string
RemoteField_Color = "color" // string
)
@ -1736,10 +1760,6 @@ func UpdateRemote(ctx context.Context, remoteId string, editMap map[string]inter
query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshpassword', ?) WHERE remoteid = ?`
tx.Exec(query, sshPassword, remoteId)
}
if sshPort, found := editMap[RemoteField_SSHPort]; found {
query = `UPDATE remote SET sshopts = json_set(sshopts, '$.sshport', ?) WHERE remoteid = ?`
tx.Exec(query, sshPort, remoteId)
}
if color, found := editMap[RemoteField_Color]; found {
query = `UPDATE remote SET remoteopts = json_set(remoteopts, '$.color', ?) WHERE remoteid = ?`
tx.Exec(query, color, remoteId)
@ -2044,6 +2064,29 @@ func SetLineArchivedById(ctx context.Context, screenId string, lineId string, ar
return txErr
}
// returns updated screen (only if updated)
func FixupScreenSelectedLine(ctx context.Context, screenId string) (*ScreenType, error) {
return WithTxRtn(ctx, func(tx *TxWrap) (*ScreenType, error) {
query := `SELECT selectedline FROM screen WHERE screenid = ?`
sline := tx.GetInt(query, screenId)
query = `SELECT linenum FROM line WHERE screenid = ? AND linenum = ?`
if tx.Exists(query, screenId, sline) {
// selected line is valid
return nil, nil
}
query = `SELECT min(linenum) FROM line WHERE screenid = ? AND linenum > ?`
newSLine := tx.GetInt(query, screenId, sline)
if newSLine == 0 {
query = `SELECT max(linenum) FROM line WHERE screenid = ? AND linenum < ?`
newSLine = tx.GetInt(query, screenId, sline)
}
// newSLine might be 0, but that's ok (because that means there are no lines)
query = `UPDATE screen SET selectedline = ? WHERE screenid = ?`
tx.Exec(query, newSLine, screenId)
return GetScreenById(tx.Context(), screenId)
})
}
func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) error {
txErr := WithTx(ctx, func(tx *TxWrap) error {
isWS := isWebShare(tx, screenId)
@ -2051,9 +2094,8 @@ func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) er
query := `SELECT status FROM cmd WHERE screenid = ? AND lineid = ?`
cmdStatus := tx.GetString(query, screenId, lineId)
if cmdStatus == CmdStatusRunning {
return fmt.Errorf("cannot delete line[%s:%s], cmd is running", screenId, lineId)
return fmt.Errorf("cannot delete line[%s], cmd is running", lineId)
}
query = `DELETE FROM line WHERE screenid = ? AND lineid = ?`
tx.Exec(query, screenId, lineId)
query = `DELETE FROM cmd WHERE screenid = ? AND lineid = ?`
@ -2061,7 +2103,6 @@ func DeleteLinesByIds(ctx context.Context, screenId string, lineIds []string) er
// don't delete history anymore, just remove lineid reference
query = `UPDATE history SET lineid = '', linenum = 0 WHERE screenid = ? AND lineid = ?`
tx.Exec(query, screenId, lineId)
if isWS {
insertScreenLineUpdate(tx, screenId, lineId, UpdateType_LineDel)
}

View File

@ -5,9 +5,11 @@
package sstore
import (
"fmt"
"log"
"sync"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
)
@ -43,11 +45,109 @@ func isIndicatorGreater(i1 string, i2 string) bool {
return screenIndicatorLevels[i1] > screenIndicatorLevels[i2]
}
type OpenAICmdInfoChatStore struct {
MessageCount int `json:"messagecount"`
Messages []*packet.OpenAICmdInfoChatMessage `json:"messages"`
}
type ScreenMemState struct {
NumRunningCommands int `json:"numrunningcommands,omitempty"`
IndicatorType string `json:"indicatortype,omitempty"`
CmdInputText utilfn.StrWithPos `json:"cmdinputtext,omitempty"`
CmdInputSeqNum int `json:"cmdinputseqnum,omitempty"`
AICmdInfoChat *OpenAICmdInfoChatStore `json:"aicmdinfochat,omitempty"`
}
func ScreenMemDeepCopyCmdInfoChatStore(store *OpenAICmdInfoChatStore) *OpenAICmdInfoChatStore {
rtnMessages := []*packet.OpenAICmdInfoChatMessage{}
for index := 0; index < len(store.Messages); index++ {
messageToCopy := *store.Messages[index]
if messageToCopy.AssistantResponse != nil {
assistantResponseCopy := *messageToCopy.AssistantResponse
messageToCopy.AssistantResponse = &assistantResponseCopy
}
rtnMessages = append(rtnMessages, &messageToCopy)
}
rtn := &OpenAICmdInfoChatStore{MessageCount: store.MessageCount, Messages: rtnMessages}
return rtn
}
func ScreenMemInitCmdInfoChat(screenId string) {
greetingMessagePk := &packet.OpenAICmdInfoChatMessage{
MessageID: 0,
IsAssistantResponse: true,
AssistantResponse: &packet.OpenAICmdInfoPacketOutputType{
Message: packet.OpenAICmdInfoChatGreetingMessage,
},
}
ScreenMemStore[screenId].AICmdInfoChat = &OpenAICmdInfoChatStore{MessageCount: 1, Messages: []*packet.OpenAICmdInfoChatMessage{greetingMessagePk}}
}
func ScreenMemClearCmdInfoChat(screenId string) {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
ScreenMemInitCmdInfoChat(screenId)
}
func ScreenMemAddCmdInfoChatMessage(screenId string, msg *packet.OpenAICmdInfoChatMessage) {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
if ScreenMemStore[screenId].AICmdInfoChat == nil {
log.Printf("AICmdInfoChat is null, creating")
ScreenMemInitCmdInfoChat(screenId)
}
CmdInfoChat := ScreenMemStore[screenId].AICmdInfoChat
CmdInfoChat.Messages = append(CmdInfoChat.Messages, msg)
CmdInfoChat.MessageCount++
}
func ScreenMemGetCmdInfoMessageCount(screenId string) int {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
if ScreenMemStore[screenId].AICmdInfoChat == nil {
ScreenMemInitCmdInfoChat(screenId)
}
return ScreenMemStore[screenId].AICmdInfoChat.MessageCount
}
func ScreenMemGetCmdInfoChat(screenId string) *OpenAICmdInfoChatStore {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
if ScreenMemStore[screenId].AICmdInfoChat == nil {
ScreenMemInitCmdInfoChat(screenId)
}
return ScreenMemDeepCopyCmdInfoChatStore(ScreenMemStore[screenId].AICmdInfoChat)
}
func ScreenMemUpdateCmdInfoChatMessage(screenId string, messageID int, msg *packet.OpenAICmdInfoChatMessage) error {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
if ScreenMemStore[screenId].AICmdInfoChat == nil {
ScreenMemInitCmdInfoChat(screenId)
}
CmdInfoChat := ScreenMemStore[screenId].AICmdInfoChat
if messageID >= 0 && messageID < len(CmdInfoChat.Messages) {
CmdInfoChat.Messages[messageID] = msg
} else {
return fmt.Errorf("ScreenMemUpdateCmdInfoChatMessage: error: Message Id out of range: %d", messageID)
}
return nil
}
func ScreenMemSetCmdInputText(screenId string, sp utilfn.StrWithPos, seqNum int) {

View File

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

View File

@ -270,6 +270,7 @@ type ClientOptsType struct {
NoTelemetry bool `json:"notelemetry,omitempty"`
NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"`
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
}
type FeOptsType struct {

View File

@ -8,6 +8,7 @@ import (
"log"
"sync"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/wavesrv/pkg/utilfn"
)
@ -60,6 +61,8 @@ type ModelUpdate struct {
RemoteView *RemoteViewType `json:"remoteview,omitempty"`
ScreenTombstones []*ScreenTombstoneType `json:"screentombstones,omitempty"`
SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"`
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
}
func (*ModelUpdate) UpdateType() string {
@ -128,6 +131,13 @@ type RemoteEditType struct {
HasPassword bool `json:"haspassword,omitempty"`
}
type AlertMessageType struct {
Title string `json:"title,omitempty"`
Message string `json:"message"`
Confirm bool `json:"confirm,omitempty"`
Markdown bool `json:"markdown,omitempty"`
}
type InfoMsgType struct {
InfoTitle string `json:"infotitle"`
InfoError string `json:"infoerror,omitempty"`

1246
yarn.lock

File diff suppressed because it is too large Load Diff