merge branch 'main' into use-ssh-library

This commit is contained in:
Sylvia Crowe 2024-01-29 13:01:50 -08:00
commit d1bffb5a0d
41 changed files with 1489 additions and 702 deletions

View File

@ -1,9 +1,9 @@
name: "Build Helper"
on: workflow_dispatch
env:
WAVETERM_VERSION: 0.6.0
GO_VERSION: '1.21.5'
NODE_VERSION: '21.5.0'
WAVETERM_VERSION: 0.6.1
GO_VERSION: "1.21.5"
NODE_VERSION: "21.5.0"
jobs:
runbuild-darwin-x64:
name: "Build MacOS x64"
@ -21,7 +21,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{env.NODE_VERSION}}
cache: 'yarn'
cache: "yarn"
- run: yarn --frozen-lockfile
- run: scripthaus run build-package
- uses: actions/upload-artifact@v4
@ -45,7 +45,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{env.NODE_VERSION}}
cache: 'yarn'
cache: "yarn"
- run: yarn --frozen-lockfile
- run: scripthaus run build-package
- uses: actions/upload-artifact@v4
@ -78,7 +78,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: ${{env.NODE_VERSION}}
cache: 'yarn'
cache: "yarn"
- run: yarn --frozen-lockfile
- run: ./scripthaus/scripthaus run build-package-linux
- uses: actions/download-artifact@v4
@ -98,5 +98,3 @@ jobs:
name: waveterm-builds
path: buildtemp
retention-days: 2

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "golang.go", "dbaeumer.vscode-eslint"]
}

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

View File

@ -1,7 +1,7 @@
#!/bin/bash
# assumes we have Wave-darwin-x64-[version].zip and Wave-darwin-arm64-[version].zip in current directory
VERSION=0.6.0
VERSION=0.6.1
rm -rf temp
rm -rf builds
mkdir temp

View File

@ -2,7 +2,7 @@
"name": "waveterm",
"author": "Command Line Inc",
"productName": "Wave",
"version": "0.6.0",
"version": "0.6.1",
"main": "dist/emain.js",
"license": "Apache-2.0",
"dependencies": {

View File

@ -99,9 +99,6 @@ body a {
body code {
font-family: @terminal-font;
}
body code {
background-color: transparent;
}
@ -123,11 +120,19 @@ svg.icon {
}
.hideScrollbarUntillHover {
overflow: hidden;
&:hover,
&:focus,
&:focus-within {
overflow: auto;
overflow: scroll;
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track {
display: none;
}
&::-webkit-scrollbar-corner {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
}
@ -647,7 +652,6 @@ a.a-block {
margin-right: 10px;
border-radius: 8px;
border: 1px solid rgba(241, 246, 243, 0.08);
background: rgba(13, 13, 13, 0.85);
.header {
margin: 24px 18px;

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="440" height="440" version="1.1" viewBox="0 0 116.42 116.42" xmlns="http://www.w3.org/2000/svg">
<circle id="indicator" cx="58.266" cy="58.266" r="28.311" stroke-width=".19016"/>
<path id="spinner" d="m107.2 58.21a48.988 48.988 0 0 1-48.988 48.988" fill="none" stroke="#000" stroke-width="16.856" style="paint-order:normal"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -609,7 +609,6 @@
.wave-dropdown {
position: relative;
background-color: transparent;
height: 44px;
min-width: 150px;
width: 100%;
@ -715,9 +714,7 @@
top: 100%;
left: 0;
right: 0;
z-index: 0;
margin-top: 2px;
padding: 0;
max-height: 200px;
overflow-y: auto;
padding: 6px;
@ -775,7 +772,6 @@
min-width: 412px;
gap: 6px;
border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15));
border-radius: 6px;
background: var(--element-hover-2, rgba(255, 255, 255, 0.06));
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
@ -931,7 +927,6 @@
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
@ -1156,18 +1151,3 @@
}
}
}
.status-indicator {
position: relative;
top: 1px;
&.error {
color: @term-red;
}
&.success {
color: @term-green;
}
&.output {
color: @term-white;
}
}

View File

@ -13,6 +13,7 @@ import { RemoteType, StatusIndicatorLevel } from "../../types/types";
import ReactDOM from "react-dom";
import { GlobalModel } from "../../model/model";
import * as appconst from "../appconst";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.svg";
@ -117,7 +118,7 @@ class Checkbox extends React.Component<
constructor(props) {
super(props);
this.state = {
checkedInternal: this.props.checked !== undefined ? this.props.checked : Boolean(this.props.defaultChecked),
checkedInternal: this.props.checked ?? Boolean(this.props.defaultChecked),
};
this.generatedId = `checkbox-${Checkbox.idCounter++}`;
}
@ -287,15 +288,16 @@ class Button extends React.Component<ButtonProps> {
}
render() {
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style } = this.props;
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } =
this.props;
return (
<button
className={cn("wave-button", theme, variant, color, { disabled: disabled })}
className={cn("wave-button", theme, variant, color, { disabled: disabled }, className)}
onClick={this.handleClick}
disabled={disabled}
style={style}
autoFocus={this.props.autoFocus}
autoFocus={autoFocus}
>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
@ -714,13 +716,14 @@ class InlineSettingsTextEdit extends React.Component<
@boundMethod
handleKeyDown(e: any): void {
if (e.code == "Enter") {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
e.stopPropagation();
this.confirmChange();
return;
}
if (e.code == "Escape") {
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
e.stopPropagation();
this.cancelChange();
@ -868,7 +871,7 @@ class Markdown extends React.Component<
if (codeSelect) {
return <CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex}>{props.children}</CodeBlockMarkdown>;
} else {
let clickHandler = (e: React.MouseEvent<HTMLElement>) => {
const clickHandler = (e: React.MouseEvent<HTMLElement>) => {
let blockText = (e.target as HTMLElement).innerText;
if (blockText) {
blockText = blockText.replace(/\n$/, ""); // remove trailing newline
@ -896,7 +899,9 @@ class Markdown extends React.Component<
};
return (
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>
<ReactMarkdown children={text} remarkPlugins={[remarkGfm]} components={markdownComponents} />
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
@ -1239,38 +1244,6 @@ class Modal extends React.Component<ModalProps> {
}
}
interface StatusIndicatorProps {
level: StatusIndicatorLevel;
className?: string;
}
class StatusIndicator extends React.Component<StatusIndicatorProps> {
render() {
const statusIndicatorLevel = this.props.level;
let statusIndicator = null;
if (statusIndicatorLevel != StatusIndicatorLevel.None) {
let statusIndicatorClass = null;
switch (statusIndicatorLevel) {
case StatusIndicatorLevel.Output:
statusIndicatorClass = "output";
break;
case StatusIndicatorLevel.Success:
statusIndicatorClass = "success";
break;
case StatusIndicatorLevel.Error:
statusIndicatorClass = "error";
break;
}
statusIndicator = (
<div
className={`${this.props.className} fa-sharp fa-solid fa-circle-small status-indicator ${statusIndicatorClass}`}
></div>
);
}
return statusIndicator;
}
}
function ShowWaveShellInstallPrompt(callbackFn: () => void) {
let message: string = `
In order to use Wave's advanced features like unified history and persistent sessions, Wave installs a small, open-source helper program called WaveShell on your remote machine. WaveShell does not open any external ports and only communicates with your *local* Wave terminal instance over ssh. For more information please see [the docs](https://docs.waveterm.dev/reference/waveshell).
@ -1313,6 +1286,5 @@ export {
LinkButton,
Status,
Modal,
StatusIndicator,
ShowWaveShellInstallPrompt,
};

View File

@ -0,0 +1,72 @@
@import "../../../app/common/themes/themes.less";
.front-icon {
margin-right: 5px;
}
.positional-icon-visible {
display: inline-block;
}
.positional-icon-hidden {
display: none;
}
.positional-icon {
display: none;
width: 20px;
height: 20px;
.svg-icon svg {
width: 14px;
height: 14px;
}
font-size: 16px;
}
.positional-icon-inner {
display: flex;
width: inherit;
height: 100%;
line-height: inherit;
align-items: center;
justify-content: center;
& > div,
i,
.svg-icon,
span {
width: 20px;
display: flex;
text-align: center;
align-items: center;
justify-content: center;
}
}
.actions {
.icon {
font-size: 14px;
}
}
.status-indicator {
#spinner,
#indicator {
visibility: hidden;
}
.spin #spinner {
visibility: visible;
stroke: @term-white;
}
&.error #indicator {
visibility: visible;
fill: @term-red;
}
&.success #indicator {
visibility: visible;
fill: @term-green;
}
&.output #indicator {
visibility: visible;
fill: @term-white;
}
}

View File

@ -0,0 +1,77 @@
import React from "react";
import { StatusIndicatorLevel } from "../../../types/types";
import cn from "classnames";
import { ReactComponent as SpinnerIndicator } from "../../assets/icons/spinner-indicator.svg";
interface PositionalIconProps {
children?: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
export class FrontIcon extends React.Component<PositionalIconProps> {
render() {
return (
<div className={cn("front-icon", "positional-icon", this.props.className)}>
<div className="positional-icon-inner">{this.props.children}</div>
</div>
);
}
}
export class CenteredIcon extends React.Component<PositionalIconProps> {
render() {
return (
<div className={cn("centered-icon", "positional-icon", this.props.className)} onClick={this.props.onClick}>
<div className="positional-icon-inner">{this.props.children}</div>
</div>
);
}
}
interface ActionsIconProps {
onClick: React.MouseEventHandler<HTMLDivElement>;
}
export class ActionsIcon extends React.Component<ActionsIconProps> {
render() {
return (
<CenteredIcon className="actions" onClick={this.props.onClick}>
<div className="icon hoverEffect fa-sharp fa-solid fa-1x fa-ellipsis-vertical"></div>
</CenteredIcon>
);
}
}
interface StatusIndicatorProps {
level: StatusIndicatorLevel;
className?: string;
runningCommands?: boolean;
}
export class StatusIndicator extends React.Component<StatusIndicatorProps> {
render() {
const { level, className, runningCommands } = this.props;
let statusIndicator = null;
if (level != StatusIndicatorLevel.None || runningCommands) {
let levelClass = null;
switch (level) {
case StatusIndicatorLevel.Output:
levelClass = "output";
break;
case StatusIndicatorLevel.Success:
levelClass = "success";
break;
case StatusIndicatorLevel.Error:
levelClass = "error";
break;
}
statusIndicator = (
<CenteredIcon className={cn(className, levelClass, "status-indicator")}>
<SpinnerIndicator className={runningCommands ? "spin" : null} />
</CenteredIcon>
);
}
return statusIndicator;
}
}

View File

@ -15,6 +15,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { Line } from "../line/linecomps";
import { CmdStrCode } from "../common/common";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil";
import { ReactComponent as FavoritesIcon } from "../assets/icons/favourites.svg";
import { ReactComponent as XmarkIcon } from "../assets/icons/line/xmark.svg";
@ -95,7 +96,7 @@ function formatSessionName(snames: Record<string, string>, sessionId: string): s
}
@mobxReact.observer
class HistoryCheckbox extends React.Component<{ checked: boolean, partialCheck?: boolean, onClick?: () => void }, {}> {
class HistoryCheckbox extends React.Component<{ checked: boolean; partialCheck?: boolean; onClick?: () => void }, {}> {
@boundMethod
clickHandler(): void {
if (this.props.onClick) {
@ -111,13 +112,17 @@ class HistoryCheckbox extends React.Component<{ checked: boolean, partialCheck?:
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="0.5" y="0.5" width="15" height="15" rx="3.5" fill="#D5FEAF" fill-opacity="0.026" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 8C4 6.89543 4.89543 6 6 6H10C11.1046 6 12 6.89543 12 8C12 9.10457 11.1046 10 10 10H6C4.89543 10 4 9.10457 4 8Z" fill="#58C142"/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4 8C4 6.89543 4.89543 6 6 6H10C11.1046 6 12 6.89543 12 8C12 9.10457 11.1046 10 10 10H6C4.89543 10 4 9.10457 4 8Z"
fill="#58C142"
/>
<rect x="0.5" y="0.5" width="15" height="15" rx="3.5" stroke="#3B3F3A" />
</svg>
);
}
else {
return <div onClick={this.clickHandler} className="history-checkbox state-unchecked"/>
} else {
return <div onClick={this.clickHandler} className="history-checkbox state-unchecked" />;
}
}
}
@ -207,7 +212,8 @@ class HistoryView extends React.Component<{}, {}> {
@boundMethod
searchKeyDown(e: any) {
if (e.code == "Enter") {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
GlobalModel.historyViewModel.submitSearch();
return;
@ -513,9 +519,7 @@ class HistoryView extends React.Component<{}, {}> {
</div>
</div>
<div className="fromts">
<div className="fromts-text">
From:&nbsp;
</div>
<div className="fromts-text">From:&nbsp;</div>
<div className="hoverEffect">
<input
type="date"
@ -550,7 +554,10 @@ class HistoryView extends React.Component<{}, {}> {
</div>
<div className={cn("control-bar", "is-top", { "is-hidden": items.length == 0 })}>
<div className="control-checkbox" onClick={this.handleControlCheckbox} title="Toggle Selection">
<HistoryCheckbox checked={numSelected > 0 && numSelected == items.length} partialCheck={numSelected > 0}/>
<HistoryCheckbox
checked={numSelected > 0 && numSelected == items.length}
partialCheck={numSelected > 0}
/>
</div>
<div
className={cn(
@ -608,13 +615,31 @@ class HistoryView extends React.Component<{}, {}> {
<td className="ts text-standard">{getHistoryViewTs(nowDate, item.ts)}</td>
<td className="downarrow" onClick={() => this.activateItem(item.historyid)}>
<If condition={activeItemId != item.historyid}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12.1297 6.62492C12.3999 6.93881 12.3645 7.41237 12.0506 7.68263L8.48447 10.7531C8.20296 10.9955 7.78645 10.9952 7.50519 10.7526L3.94636 7.68213C3.63274 7.41155 3.59785 6.93796 3.86843 6.62434C4.13901 6.31072 4.6126 6.27583 4.92622 6.54641L7.99562 9.19459L11.0719 6.54591C11.3858 6.27565 11.8594 6.31102 12.1297 6.62492Z" fill="#C3C8C2"/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M12.1297 6.62492C12.3999 6.93881 12.3645 7.41237 12.0506 7.68263L8.48447 10.7531C8.20296 10.9955 7.78645 10.9952 7.50519 10.7526L3.94636 7.68213C3.63274 7.41155 3.59785 6.93796 3.86843 6.62434C4.13901 6.31072 4.6126 6.27583 4.92622 6.54641L7.99562 9.19459L11.0719 6.54591C11.3858 6.27565 11.8594 6.31102 12.1297 6.62492Z"
fill="#C3C8C2"
/>
</svg>
</If>
<If condition={activeItemId == item.historyid}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3.87035 9.37508C3.60009 9.06119 3.63546 8.58763 3.94936 8.31737L7.51553 5.24692C7.79704 5.00455 8.21355 5.00476 8.49481 5.24742L12.0536 8.31787C12.3673 8.58845 12.4022 9.06204 12.1316 9.37566C11.861 9.68928 11.3874 9.72417 11.0738 9.45359L8.00438 6.80541L4.92806 9.45409C4.61416 9.72435 4.14061 9.68898 3.87035 9.37508Z" fill="#C3C8C2"/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M3.87035 9.37508C3.60009 9.06119 3.63546 8.58763 3.94936 8.31737L7.51553 5.24692C7.79704 5.00455 8.21355 5.00476 8.49481 5.24742L12.0536 8.31787C12.3673 8.58845 12.4022 9.06204 12.1316 9.37566C11.861 9.68928 11.3874 9.72417 11.0738 9.45359L8.00438 6.80541L4.92806 9.45409C4.61416 9.72435 4.14061 9.68898 3.87035 9.37508Z"
fill="#C3C8C2"
/>
</svg>
</If>
</td>

View File

@ -360,6 +360,12 @@ class LineCmd extends React.Component<
GlobalCommandRunner.lineDelete(line.lineid, true);
}
@boundMethod
clickRestart() {
let { line } = this.props;
GlobalCommandRunner.lineRestart(line.lineid, true);
}
@boundMethod
clickMinimize() {
mobx.action(() => {
@ -467,12 +473,21 @@ class LineCmd extends React.Component<
renderMeta1(cmd: Cmd) {
let { line } = this.props;
let termOpts = cmd.getTermOpts();
let formattedTime = lineutil.getLineDateTimeStr(line.ts);
let formattedTime: string = "";
let restartTs = cmd.getRestartTs();
let timeTitle: string = null;
if (restartTs != null && restartTs > 0) {
formattedTime = "restarted @ " + lineutil.getLineDateTimeStr(restartTs);
timeTitle = "original start time " + lineutil.getLineDateTimeStr(line.ts);
}
else {
formattedTime = lineutil.getLineDateTimeStr(line.ts);
}
let renderer = line.renderer;
return (
<div key="meta1" className="meta meta-line1">
<SmallLineAvatar line={line} cmd={cmd} />
<div className="ts">{formattedTime}</div>
<div title={timeTitle} className="ts">{formattedTime}</div>
<div>&nbsp;</div>
<If condition={!isBlank(renderer) && renderer != "terminal"}>
<div className="renderer">
@ -665,6 +680,9 @@ class LineCmd extends React.Component<
{this.renderMeta1(cmd)}
<If condition={!hidePrompt}>{this.renderCmdText(cmd)}</If>
</div>
<div key="restart" title="Restart Command" className="line-icon" onClick={this.clickRestart}>
<i className="fa-sharp fa-regular fa-arrows-rotate"/>
</div>
<div key="delete" title="Delete Line (&#x2318;D)" className="line-icon" onClick={this.clickDelete}>
<i className="fa-sharp fa-regular fa-trash" />
</div>

View File

@ -1,4 +1,5 @@
@import "../../app/common/themes/themes.less";
@import "../../app/common/icons/icons.less";
.main-sidebar {
padding: 0;
@ -23,14 +24,20 @@
&.collapsed {
width: 6em;
min-width: 6em;
.arrow-container, .collapse-button {
.arrow-container,
.collapse-button {
transform: rotate(180deg);
margin-top: 20px;
}
.contents {
margin-top: 26px;
.top, .workspaces-item, .middle, .bottom, .separator {
.top,
.workspaces-item,
.middle,
.bottom,
.separator {
pointer-events: none;
opacity: 0;
visibility: hidden;
@ -120,12 +127,9 @@
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
}
.index {
margin: 0 1.5em 0 -0.5rem;
font-size: 0.8em;
vertical-align: middle;
text-align: right;
text-align: center;
width: 2em;
display: inline-block;
}
.hotkey {
float: left !important;
@ -145,58 +149,70 @@
.item {
padding: 5px;
margin: 0 6px;
margin-left: 6px;
border-radius: 4px;
opacity: 1;
visibility: visible;
transition: opacity 0.1s ease-in-out, visibility 0.1s step-end;
.sessionName {
width: 12rem;
display: inline-block;
vertical-align: middle;
width: inherit;
max-width: inherit;
min-width: inherit;
display: flex;
flex-direction: row;
align-items: center;
.front-icon {
.positional-icon-visible;
}
.end-icons {
height: 20px;
}
.item-contents {
flex-grow: 1;
}
.icon {
margin: -2px 8px 0px 4px;
width: 16px;
height: 16px;
display: inline-block;
vertical-align: middle;
border-radius: 50%;
}
.actions.icon {
margin-left: 8px;
}
.hotkey {
float: right;
margin-right: 6px;
visibility: hidden;
letter-spacing: 6px;
}
.disabled .hotkey {
display: none;
}
&:hover .hotkey {
visibility: visible;
&:hover {
:not(.disabled) .hotkey {
.positional-icon-visible;
}
.actions {
visibility: hidden;
.positional-icon-visible;
}
&:hover .actions {
visibility: visible;
}
&:not(:hover) .status-indicator {
.positional-icon-visible;
}
.add_workspace {
float: right;
width: 1.5rem;
height: 1.5rem;
padding: 2px;
margin-right: 6px;
border-radius: 50%;
transition: transform 0.3s ease-in-out;
vertical-align: middle;
svg {
fill: @base-color;
}
}
.front-icon {
font-size: 15px;
}
.end-icons {
line-height: normal;
}
.fa-discord {
font-size: 13px;
}
}
.menu-label {

View File

@ -12,27 +12,54 @@ import { If } from "tsx-control-statements/components";
import { compareLoose } from "semver";
import { ReactComponent as LeftChevronIcon } from "../assets/icons/chevron_left.svg";
import { ReactComponent as HelpIcon } from "../assets/icons/help.svg";
import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg";
import { ReactComponent as DiscordIcon } from "../assets/icons/discord.svg";
import { ReactComponent as HistoryIcon } from "../assets/icons/history.svg";
import { ReactComponent as AppsIcon } from "../assets/icons/apps.svg";
import { ReactComponent as ConnectionsIcon } from "../assets/icons/connections.svg";
import { ReactComponent as WorkspacesIcon } from "../assets/icons/workspaces.svg";
import { ReactComponent as AddIcon } from "../assets/icons/add.svg";
import { ReactComponent as ActionsIcon } from "../assets/icons/tab/actions.svg";
import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model";
import { sortAndFilterRemotes, isBlank, openLink } from "../../util/util";
import { isBlank, openLink } from "../../util/util";
import * as constants from "../appconst";
import "./sidebar.less";
import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "../common/icons/icons";
dayjs.extend(localizedFormat);
type OV<V> = mobx.IObservableValue<V>;
class SideBarItem extends React.Component<{
frontIcon: React.ReactNode;
contents: React.ReactNode | string;
endIcons?: React.ReactNode[];
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}> {
render() {
return (
<div
className={cn("item", "unselectable", "hoverEffect", this.props.className)}
onClick={this.props.onClick}
>
<FrontIcon>{this.props.frontIcon}</FrontIcon>
<div className="item-contents truncate">{this.props.contents}</div>
<div className="end-icons">{this.props.endIcons}</div>
</div>
);
}
}
class HotKeyIcon extends React.Component<{ hotkey: string }> {
render() {
return (
<CenteredIcon className="hotkey">
<span>&#x2318;{this.props.hotkey}</span>
</CenteredIcon>
);
}
}
@mobxReact.observer
class MainSideBar extends React.Component<{}, {}> {
collapsed: mobx.IObservableValue<boolean> = mobx.observable.box(false);
@ -99,7 +126,6 @@ class MainSideBar extends React.Component<{}, {}> {
@boundMethod
handlePlaybookClick(): void {
console.log("playbook click");
return;
}
@boundMethod
@ -159,42 +185,29 @@ class MainSideBar extends React.Component<{}, {}> {
}
return sessionList.map((session, index) => {
const isActive = GlobalModel.activeMainView.get() == "session" && activeSessionId == session.sessionId;
const sessionScreens = GlobalModel.getSessionScreens(session.sessionId);
const sessionIndicator = Math.max(...sessionScreens.map((screen) => screen.statusIndicator.get()));
const sessionRunningCommands = sessionScreens.some((screen) => screen.numRunningCmds.get() > 0);
return (
<div
key={index}
className={`item hoverEffect ${isActive ? "active" : ""}`}
<SideBarItem
className={`${isActive ? "active" : ""}`}
frontIcon={<span className="index">{index + 1}</span>}
contents={session.name.get()}
endIcons={[
<StatusIndicator
key="statusindicator"
level={sessionIndicator}
runningCommands={sessionRunningCommands}
/>,
<ActionsIcon key="actions" onClick={(e) => this.openSessionSettings(e, session)} />,
]}
onClick={() => this.handleSessionClick(session.sessionId)}
>
<span className="index">{index + 1}</span>
<span className="truncate sessionName">{session.name.get()}</span>
<ActionsIcon
className="icon hoverEffect actions"
onClick={(e) => this.openSessionSettings(e, session)}
/>
</div>
);
});
}
render() {
let model = GlobalModel;
let activeSessionId = model.activeSessionId.get();
let activeScreen = model.getActiveScreen();
let activeRemoteId: string = null;
if (activeScreen != null) {
let rptr = activeScreen.curRemote.get();
if (rptr != null && !isBlank(rptr.remoteid)) {
activeRemoteId = rptr.remoteid;
}
}
let remotes = model.remotes ?? [];
remotes = sortAndFilterRemotes(remotes);
let sessionList = [];
for (let session of model.sessionList) {
if (!session.archived.get() || session.sessionId == activeSessionId) {
sessionList.push(session);
}
}
let isCollapsed = this.collapsed.get();
let clientData = GlobalModel.clientData.get();
let needsUpdate = false;
@ -223,65 +236,66 @@ class MainSideBar extends React.Component<{}, {}> {
</div>
<div className="separator" />
<div className="top">
<div className="item hoverEffect unselectable" onClick={this.handleHistoryClick}>
<HistoryIcon className="icon" />
History
<span className="hotkey">&#x2318;H</span>
</div>
{/* <div className="item hoverEffect unselectable" onClick={this.handleBookmarksClick}>
<FavoritesIcon className="icon" />
Favorites
<span className="hotkey">&#x2318;B</span>
</div> */}
<div className="item hoverEffect unselectable" onClick={this.handleConnectionsClick}>
<ConnectionsIcon className="icon" />
Connections
</div>
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-clock-rotate-left icon" />}
contents="History"
endIcons={[<HotKeyIcon key="hotkey" hotkey="H" />]}
onClick={this.handleHistoryClick}
/>
{/* <SideBarItem className="hoverEffect unselectable" frontIcon={<FavoritesIcon className="icon" />} contents="Favorites" endIcon={<span className="hotkey">&#x2318;B</span>} onClick={this.handleBookmarksClick}/> */}
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-globe icon " />}
contents="Connections"
onClick={this.handleConnectionsClick}
/>
</div>
<div className="separator" />
<div className="item workspaces-item unselectable">
<WorkspacesIcon className="icon" />
Workspaces
<div className="add_workspace hoverEffect" onClick={this.handleNewSession}>
<SideBarItem
frontIcon={<WorkspacesIcon className="icon" />}
contents="Workspaces"
endIcons={[
<div
key="add_workspace"
className="add_workspace hoverEffect"
onClick={this.handleNewSession}
>
<AddIcon />
</div>
</div>
</div>,
]}
/>
<div className="middle hideScrollbarUntillHover">{this.getSessions()}</div>
<div className="bottom">
<If condition={needsUpdate}>
<div
className="item hoverEffect unselectable updateBanner"
<SideBarItem
className="updateBanner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Update Available"
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
>
<i className="fa-sharp fa-regular fa-circle-up icon" />
Update Available
</div>
/>
</If>
<If condition={GlobalModel.isDev}>
<div className="item hoverEffect unselectable" onClick={this.handlePluginsClick}>
<AppsIcon className="icon" />
Apps
<span className="hotkey">&#x2318;A</span>
</div>
<SideBarItem
frontIcon={<AppsIcon className="icon" />}
contents="Apps"
onClick={this.handlePluginsClick}
endIcons={[<HotKeyIcon key="hotkey" hotkey="A" />]}
/>
</If>
<div className="item hoverEffect unselectable" onClick={this.handleSettingsClick}>
<SettingsIcon className="icon" />
Settings
</div>
<div
className="item hoverEffect unselectable"
<SideBarItem
frontIcon={<SettingsIcon className="icon" />}
contents="Settings"
onClick={this.handleSettingsClick}
/>
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-circle-question icon" />}
contents="Documentation"
onClick={() => openLink("https://docs.waveterm.dev")}
>
<HelpIcon className="icon" />
Documentation
</div>
<div
className="item hoverEffect unselectable"
/>
<SideBarItem
frontIcon={<i className="fa-brands fa-discord icon" />}
contents="Discord"
onClick={() => openLink("https://discord.gg/XfvZ334gwU")}
>
<DiscordIcon className="icon discord" />
Discord
</div>
/>
</div>
</div>
</div>

View File

@ -13,6 +13,7 @@ import { TextAreaInput } from "./textareainput";
import { If, For } from "tsx-control-statements/components";
import type { OpenAICmdInfoChatMessageType } from "../../../types/types";
import { Markdown } from "../../common/common";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../../util/keyutil";
@mobxReact.observer
class AIChat extends React.Component<{}, {}> {
@ -76,8 +77,8 @@ class AIChat extends React.Component<{}, {}> {
let inputModel = model.inputModel;
let ctrlMod = e.getModifierState("Control") || e.getModifierState("Meta") || e.getModifierState("Shift");
let resetCodeSelect = !ctrlMod;
if (e.code == "Enter") {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
if (!ctrlMod) {
if (inputModel.getCodeSelectSelectedIndex() == -1) {
@ -91,17 +92,18 @@ class AIChat extends React.Component<{}, {}> {
e.target.setRangeText("\n", e.target.selectionStart, e.target.selectionEnd, "end");
}
}
if (e.code == "Escape") {
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
e.stopPropagation();
inputModel.closeAIAssistantChat();
}
if (e.code == "KeyL" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:l")) {
e.preventDefault();
e.stopPropagation();
inputModel.clearAIAssistantChat();
}
if (e.code == "ArrowUp") {
if (checkKeyPressed(waveEvent, "ArrowUp")) {
if (this.getLinePos(e.target).linePos > 1) {
// normal up arrow
return;
@ -110,7 +112,7 @@ class AIChat extends React.Component<{}, {}> {
inputModel.codeSelectSelectNextOldestCodeBlock();
resetCodeSelect = false;
}
if (e.code == "ArrowDown") {
if (checkKeyPressed(waveEvent, "ArrowDown")) {
if (inputModel.getCodeSelectSelectedIndex() == inputModel.codeSelectBottom) {
return;
}

View File

@ -82,7 +82,7 @@
.cmd-input-filter {
opacity: 0.5;
&:hover {
opacity: 1.0;
opacity: 1;
}
.avatar {
@ -198,7 +198,7 @@
opacity: 0.5;
&:hover {
opacity: 1.0;
opacity: 1;
}
}
}
@ -251,7 +251,6 @@
}
.chat-msg-user {
.msg-text {
font-family: @markdown-font;
font-size: 14px;
@ -265,7 +264,6 @@
font-size: 14px;
}
.grow-spacer {
flex: 1 0 10px;
}

View File

@ -13,6 +13,7 @@ import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { getMonoFontSize } from "../../../util/textmeasure";
import { isModKeyPress, hasNoModifiers } from "../../../util/util";
import * as appconst from "../../appconst";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../../util/keyutil";
type OV<T> = mobx.IObservableValue<T>;
@ -177,12 +178,13 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
let ctrlMod = e.getModifierState("Control") || e.getModifierState("Meta") || e.getModifierState("Shift");
let curLine = inputModel.getCurLine();
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
let lastTab = this.lastTab;
this.lastTab = e.code == "Tab";
this.lastTab = checkKeyPressed(waveEvent, "Tab");
let lastHist = this.lastHistoryUpDown;
this.lastHistoryUpDown = false;
if (e.code == "Tab") {
if (checkKeyPressed(waveEvent, "Tab")) {
e.preventDefault();
if (lastTab) {
GlobalModel.submitCommand(
@ -204,7 +206,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
return;
}
}
if (e.code == "Enter") {
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
if (!ctrlMod) {
if (GlobalModel.inputModel.isEmpty()) {
@ -224,7 +226,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
GlobalModel.inputModel.setCurLine(e.target.value);
return;
}
if (e.code == "Escape") {
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
e.stopPropagation();
let inputModel = GlobalModel.inputModel;
@ -235,50 +237,50 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
inputModel.closeAIAssistantChat();
return;
}
if (e.code == "KeyE" && e.getModifierState("Meta")) {
if (checkKeyPressed(waveEvent, "Cmd:e")) {
e.preventDefault();
e.stopPropagation();
let inputModel = GlobalModel.inputModel;
inputModel.toggleExpandInput();
}
if (e.code == "KeyC" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:c")) {
e.preventDefault();
inputModel.resetInput();
return;
}
if (e.code == "KeyU" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:u")) {
e.preventDefault();
this.controlU();
return;
}
if (e.code == "KeyP" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:p")) {
e.preventDefault();
this.controlP();
return;
}
if (e.code == "KeyN" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:n")) {
e.preventDefault();
this.controlN();
return;
}
if (e.code == "KeyW" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:w")) {
e.preventDefault();
this.controlW();
return;
}
if (e.code == "KeyY" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:y")) {
e.preventDefault();
this.controlY();
return;
}
if (e.code == "KeyR" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:r")) {
e.preventDefault();
inputModel.openHistory();
return;
}
if ((e.code == "ArrowUp" || e.code == "ArrowDown") && hasNoModifiers(e)) {
if (checkKeyPressed(waveEvent, "ArrowUp") || checkKeyPressed(waveEvent, "ArrowDown")) {
if (!inputModel.isHistoryLoaded()) {
if (e.code == "ArrowUp") {
if (checkKeyPressed(waveEvent, "ArrowUp")) {
this.lastHistoryUpDown = true;
inputModel.loadHistory(false, 1, "screen");
}
@ -286,7 +288,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
}
// invisible history movement
let linePos = this.getLinePos(e.target);
if (e.code == "ArrowUp") {
if (checkKeyPressed(waveEvent, "ArrowUp")) {
if (!lastHist && linePos.linePos > 1) {
// regular arrow
return;
@ -296,7 +298,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
this.lastHistoryUpDown = true;
return;
}
if (e.code == "ArrowDown") {
if (checkKeyPressed(waveEvent, "ArrowDown")) {
if (!lastHist && linePos.linePos < linePos.numLines) {
// regular arrow
return;
@ -307,16 +309,16 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
return;
}
}
if (e.code == "PageUp" || e.code == "PageDown") {
if (checkKeyPressed(waveEvent, "PageUp") || checkKeyPressed(waveEvent, "PageDown")) {
e.preventDefault();
let infoScroll = inputModel.hasScrollingInfoMsg();
if (infoScroll) {
let div = document.querySelector(".cmd-input-info");
let amt = pageSize(div);
scrollDiv(div, e.code == "PageUp" ? -amt : amt);
scrollDiv(div, checkKeyPressed(waveEvent, "PageUp") ? -amt : amt);
}
}
if (e.code == "Space" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:Space")) {
e.preventDefault();
inputModel.openAIAssistantChat();
}
@ -338,32 +340,29 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
@boundMethod
onHistoryKeyDown(e: any) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
let inputModel = GlobalModel.inputModel;
if (e.code == "Escape") {
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
inputModel.resetHistory();
return;
}
if (e.code == "Enter") {
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
inputModel.grabSelectedHistoryItem();
return;
}
if (e.code == "KeyG" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:g")) {
e.preventDefault();
inputModel.resetInput();
return;
}
if (e.code == "KeyC" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:c")) {
e.preventDefault();
inputModel.resetInput();
return;
}
if (
e.code == "KeyR" &&
(e.getModifierState("Meta") || e.getModifierState("Control")) &&
!e.getModifierState("Shift")
) {
if (checkKeyPressed(waveEvent, "Cmd:r") || checkKeyPressed(waveEvent, "Ctrl:r")) {
e.preventDefault();
let opts = mobx.toJS(inputModel.historyQueryOpts.get());
if (opts.limitRemote) {
@ -376,7 +375,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
inputModel.setHistoryQueryOpts(opts);
return;
}
if (e.code == "KeyS" && (e.getModifierState("Meta") || e.getModifierState("Control"))) {
if (checkKeyPressed(waveEvent, "Cmd:s") || checkKeyPressed(waveEvent, "Ctrl:s")) {
e.preventDefault();
let opts = mobx.toJS(inputModel.historyQueryOpts.get());
let htype = opts.queryType;
@ -390,26 +389,26 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
inputModel.setHistoryType(htype);
return;
}
if (e.code == "Tab") {
if (checkKeyPressed(waveEvent, "Tab")) {
e.preventDefault();
return;
}
if (e.code == "ArrowUp" || e.code == "ArrowDown") {
if (checkKeyPressed(waveEvent, "ArrowUp") || checkKeyPressed(waveEvent, "ArrowDown")) {
e.preventDefault();
inputModel.moveHistorySelection(e.code == "ArrowUp" ? 1 : -1);
inputModel.moveHistorySelection(checkKeyPressed(waveEvent, "ArrowUp") ? 1 : -1);
return;
}
if (e.code == "PageUp" || e.code == "PageDown") {
if (checkKeyPressed(waveEvent, "PageUp") || checkKeyPressed(waveEvent, "PageDown")) {
e.preventDefault();
inputModel.moveHistorySelection(e.code == "PageUp" ? 10 : -10);
inputModel.moveHistorySelection(checkKeyPressed(waveEvent, "PageUp") ? 10 : -10);
return;
}
if (e.code == "KeyP" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:p")) {
e.preventDefault();
inputModel.moveHistorySelection(1);
return;
}
if (e.code == "KeyN" && e.getModifierState("Control")) {
if (checkKeyPressed(waveEvent, "Ctrl:n")) {
e.preventDefault();
inputModel.moveHistorySelection(-1);
return;

View File

@ -7,7 +7,8 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { StatusIndicator, renderCmdText } from "../../common/common";
import { ActionsIcon, StatusIndicator, CenteredIcon } from "../../common/icons/icons";
import { renderCmdText } from "../../common/common";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
import * as constants from "../../appconst";
import { Reorder } from "framer-motion";
@ -79,14 +80,12 @@ class ScreenTab extends React.Component<
let tabIndex = null;
if (index + 1 <= 9) {
tabIndex = <div className="tab-index">{renderCmdText(String(index + 1))}</div>;
}
let settings = (
<div onClick={(e) => this.openScreenSettings(e, screen)} title="Actions" className="tab-gear">
<div className="icon hoverEffect fa-sharp fa-solid fa-ellipsis-vertical"></div>
</div>
tabIndex = (
<CenteredIcon className="tab-index">
<div>{renderCmdText(String(index + 1))}</div>
</CenteredIcon>
);
}
let archived = screen.archived.get() ? (
<i title="archived" className="fa-sharp fa-solid fa-box-archive" />
) : null;
@ -96,6 +95,7 @@ class ScreenTab extends React.Component<
) : null;
const statusIndicatorLevel = screen.statusIndicator.get();
const runningCommands = screen.numRunningCmds.get() > 0;
return (
<Reorder.Item
@ -115,20 +115,16 @@ class ScreenTab extends React.Component<
onContextMenu={(event) => this.openScreenSettings(event, screen)}
onDragEnd={this.handleDragEnd}
>
<div className="front-icon">
{this.renderTabIcon(screen)}
</div>
<CenteredIcon className="front-icon">{this.renderTabIcon(screen)}</CenteredIcon>
<div className="tab-name truncate">
{archived}
{webShared}
{screen.name.get()}
</div>
<div className="end-icon">
<div className="end-icon-inner">
<StatusIndicator level={statusIndicatorLevel}/>
<div className="end-icons">
<StatusIndicator level={statusIndicatorLevel} runningCommands={runningCommands} />
{tabIndex}
{settings}
</div>
<ActionsIcon onClick={(e) => this.openScreenSettings(e, screen)} />
</div>
</Reorder.Item>
);

View File

@ -1,4 +1,5 @@
@import "../../../app/common/themes/themes.less";
@import "../../../app/common/icons/icons.less";
#main .screen-tabs .screen-tab {
border-top: 1px solid transparent;
@ -27,10 +28,6 @@
rgba(88, 193, 66, 0) 86.79%
);
}
.icon i {
color: @tab-green;
}
}
&.color-orange {
@ -242,14 +239,6 @@
.screen-tabs-container-inner {
overflow-x: scroll;
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
}
.screen-tabs {
@ -268,14 +257,7 @@
padding: 0 8px 0 8px;
.front-icon {
margin-right: 5px;
.svg-icon svg {
width: 14px;
height: 14px;
}
.fa-icon {
font-size: 16px;
}
.positional-icon-visible;
}
.tab-name {
@ -294,57 +276,28 @@
}
// Only one of these will be visible at a time
.end-icon {
// This makes the calculations below easier since we don't need to account for the right margin on the parent tab.
.end-icons {
// This adjusts the position of the icon to account for the default 8px margin on the parent. We want the positional calculations for this icon to assume it is flush with the edge of the screen tab.
margin: 0 -8px 0 0;
.end-icon-inner {
& > div {
text-align: center;
align-items: center;
& > * {
margin: auto auto;
}
width: 20px;
}
}
.status-indicator {
display: block;
// The status indicator is a little shorter than the text; this raises it up a bit so it's more centered vertically
padding-bottom: 1px;
margin-top: -1px;
}
.tab-gear {
display: none;
.icon {
border-radius: 50%;
}
}
line-height: normal;
.tab-index {
display: none;
font-size: 0.9em;
font-size: 12.5px;
}
}
}
&:not(:hover) .status-indicator {
.positional-icon-visible;
}
&:hover {
.tab-gear {
display: block;
.screen-tab:not(:hover) .tab-index {
.positional-icon-visible;
}
.screen-tab:hover .actions {
.positional-icon-visible;
}
}
&:hover .screen-tab {
.tab-index {
display: block;
}
.status-indicator {
display: none;
}
}
&:hover .screen-tab:hover .tab-index {
display: none;
}
}
.new-screen {
@ -357,7 +310,6 @@
height: 37px;
.icon {
height: 2rem;
height: 2rem;
border-radius: 50%;
padding: 0.4em;

View File

@ -9,7 +9,6 @@ import { boundMethod } from "autobind-decorator";
import { For } from "tsx-control-statements/components";
import { GlobalModel, GlobalCommandRunner, Session, Screen } from "../../../model/model";
import { ReactComponent as AddIcon } from "../../assets/icons/add.svg";
import * as constants from "../../appconst";
import { Reorder } from "framer-motion";
import { ScreenTab } from "./tab";
@ -181,7 +180,7 @@ class ScreenTabs extends React.Component<
return (
<div className="screen-tabs-container">
{/* Inner container ensures that hovering over the scrollbar doesn't trigger the hover effect on the tabs. This prevents weird flickering of the icons when the mouse is moved over the scrollbar. */}
<div className="screen-tabs-container-inner">
<div className="screen-tabs-container-inner hideScrollbarUntillHover">
<Reorder.Group
className="screen-tabs"
ref={this.tabsRef}

View File

@ -12,6 +12,8 @@ import * as winston from "winston";
import * as util from "util";
import { sprintf } from "sprintf-js";
import { v4 as uuidv4 } from "uuid";
import { checkKeyPressed, adaptFromElectronKeyEvent, setKeyUtilPlatform } from "../util/keyutil";
import { platform } from "os";
const WaveAppPathVarName = "WAVETERM_APP_PATH";
const WaveDevVarName = "WAVETERM_DEV";
@ -34,7 +36,7 @@ ensureDir(waveHome);
// these are either "darwin/amd64" or "darwin/arm64"
// normalize darwin/x64 to darwin/amd64 for GOARCH compatibility
let unamePlatform = process.platform;
let unameArch = process.arch;
let unameArch: string = process.arch;
if (unameArch == "x64") {
unameArch = "amd64";
}
@ -187,15 +189,21 @@ let menuTemplate = [
{ role: "quit" },
],
},
{
label: "File",
submenu: [{ role: "close" },],
},
{
role: "editMenu",
},
{
role: "viewMenu",
submenu: [
{ role: "reload", accelerator: "Option+R" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
role: "windowMenu",
@ -244,6 +252,7 @@ function shFrameNavHandler(event: any, url: any) {
function createMainWindow(clientData) {
let bounds = calcBounds(clientData);
setKeyUtilPlatform(platform());
let win = new electron.BrowserWindow({
x: bounds.x,
y: bounds.y,
@ -261,6 +270,7 @@ function createMainWindow(clientData) {
let indexHtml = isDev ? "index-dev.html" : "index.html";
win.loadFile(path.join(getAppBasePath(), "public", indexHtml));
win.webContents.on("before-input-event", (e, input) => {
let waveEvent = adaptFromElectronKeyEvent(input);
if (win.isFocused()) {
wasActive = true;
}
@ -268,50 +278,47 @@ function createMainWindow(clientData) {
return;
}
let mods = getMods(input);
if (input.code == "KeyT" && input.meta) {
if (checkKeyPressed(waveEvent, "Cmd:t")) {
win.webContents.send("t-cmd", mods);
e.preventDefault();
return;
}
if (input.code == "KeyI" && input.meta) {
if (checkKeyPressed(waveEvent, "Cmd:i")) {
e.preventDefault();
if (!input.alt) {
win.webContents.send("i-cmd", mods);
} else {
win.webContents.toggleDevTools();
}
return;
}
if (input.code == "KeyR" && input.meta) {
if (input.shift) {
if (checkKeyPressed(waveEvent, "Cmd:r")) {
e.preventDefault();
win.reload();
}
win.webContents.send("r-cmd", mods);
return;
}
if (input.code == "KeyL" && input.meta) {
if (checkKeyPressed(waveEvent, "Cmd:l")) {
win.webContents.send("l-cmd", mods);
e.preventDefault();
return;
}
if (input.code == "KeyW" && input.meta) {
if (checkKeyPressed(waveEvent, "Cmd:w")) {
e.preventDefault();
win.webContents.send("w-cmd", mods);
return;
}
if (input.code == "KeyH" && input.meta) {
if (checkKeyPressed(waveEvent, "Cmd:h")) {
win.webContents.send("h-cmd", mods);
e.preventDefault();
return;
}
if (input.code == "KeyP" && input.meta) {
if (checkKeyPressed(waveEvent, "Cmd:p")) {
win.webContents.send("p-cmd", mods);
e.preventDefault();
return;
}
if (input.meta && (input.code == "ArrowUp" || input.code == "ArrowDown")) {
if (input.code == "ArrowUp") {
if (checkKeyPressed(waveEvent, "Cmd:ArrowUp") || checkKeyPressed(waveEvent, "Cmd:ArrowDown")) {
if (checkKeyPressed(waveEvent, "Cmd:ArrowUp")) {
win.webContents.send("meta-arrowup");
} else {
win.webContents.send("meta-arrowdown");
@ -319,8 +326,8 @@ function createMainWindow(clientData) {
e.preventDefault();
return;
}
if (input.meta && (input.code == "PageUp" || input.code == "PageDown")) {
if (input.code == "PageUp") {
if (checkKeyPressed(waveEvent, "Cmd:PageUp") || checkKeyPressed(waveEvent, "Cmd:PageDown")) {
if (checkKeyPressed(waveEvent, "Cmd:PageUp")) {
win.webContents.send("meta-pageup");
} else {
win.webContents.send("meta-pagedown");
@ -336,8 +343,8 @@ function createMainWindow(clientData) {
e.preventDefault();
win.webContents.send("digit-cmd", { digit: digitNum }, mods);
}
if ((input.code == "BracketRight" || input.code == "BracketLeft") && input.meta) {
let rel = input.code == "BracketRight" ? 1 : -1;
if (checkKeyPressed(waveEvent, "Cmd:[") || checkKeyPressed(waveEvent, "Cmd:]")) {
let rel = checkKeyPressed(waveEvent, "Cmd:]") ? 1 : -1;
win.webContents.send("bracket-cmd", { relative: rel }, mods);
e.preventDefault();
return;

View File

@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld("api", {
onHCmd: (callback) => ipcRenderer.on("h-cmd", callback),
onWCmd: (callback) => ipcRenderer.on("w-cmd", callback),
onPCmd: (callback) => ipcRenderer.on("p-cmd", callback),
onRCmd: (callback) => ipcRenderer.on("r-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

@ -84,11 +84,11 @@ import { getRendererContext, cmdStatusIsRunning } from "../app/line/lineutil";
import { MagicLayout } from "../app/magiclayout";
import { modalsRegistry } from "../app/common/modals/registry";
import * as appconst from "../app/appconst";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent, setKeyUtilPlatform } from "../util/keyutil";
dayjs.extend(customParseFormat);
dayjs.extend(localizedFormat);
var GlobalUser = "sawka";
const RemotePtyRows = 8; // also in main.tsx
const RemotePtyCols = 80;
const ProdServerEndpoint = "http://127.0.0.1:1619";
@ -203,6 +203,7 @@ type ElectronApi = {
onLCmd: (callback: (mods: KeyModsType) => void) => void;
onHCmd: (callback: (mods: KeyModsType) => void) => void;
onPCmd: (callback: (mods: KeyModsType) => void) => void;
onRCmd: (callback: (mods: KeyModsType) => void) => void;
onWCmd: (callback: (mods: KeyModsType) => void) => void;
onMenuItemAbout: (callback: () => void) => void;
onMetaArrowUp: (callback: () => void) => void;
@ -252,6 +253,10 @@ class Cmd {
})();
}
getRestartTs(): number {
return this.data.get().restartts;
}
getAsWebCmd(lineid: string): WebCmd {
let cmd = this.data.get();
let remote = GlobalModel.getRemote(this.remote.remoteid);
@ -327,7 +332,6 @@ class Cmd {
}
handleDataFromRenderer(data: string, renderer: RendererModel): void {
// console.log("handle data", {data: data});
if (!this.isRunning()) {
return;
}
@ -372,6 +376,7 @@ class Screen {
webShareOpts: OV<WebShareOpts>;
filterRunning: OV<boolean>;
statusIndicator: OV<StatusIndicatorLevel>;
numRunningCmds: OV<number>;
constructor(sdata: ScreenDataType) {
this.sessionId = sdata.sessionid;
@ -415,6 +420,9 @@ class Screen {
this.statusIndicator = mobx.observable.box(StatusIndicatorLevel.None, {
name: "screen-status-indicator",
});
this.numRunningCmds = mobx.observable.box(0, {
name: "screen-num-running-cmds",
});
}
dispose() {}
@ -553,7 +561,6 @@ class Screen {
mobx.action(() => {
this.anchor.set({ anchorLine: anchorLine, anchorOffset: anchorOffset });
})();
// console.log("set-anchor-fields", anchorLine, anchorOffset, reason);
}
refocusLine(sdata: ScreenDataType, oldFocusType: string, oldSelectedLine: number): void {
@ -566,7 +573,6 @@ class Screen {
if (sdata.selectedline != 0) {
sline = this.getLineByNum(sdata.selectedline);
}
// console.log("refocus", curLineFocus.linenum, "=>", sdata.selectedline, sline.lineid);
if (
curLineFocus.cmdInputFocus ||
(curLineFocus.linenum != null && curLineFocus.linenum != sdata.selectedline)
@ -794,7 +800,6 @@ class Screen {
}
setLineFocus(lineNum: number, focus: boolean): void {
// console.log("SW setLineFocus", lineNum, focus);
mobx.action(() => this.termLineNumFocus.set(focus ? lineNum : 0))();
if (focus && this.selectedLine.get() != lineNum) {
GlobalCommandRunner.screenSelectLine(String(lineNum), "cmd");
@ -813,42 +818,59 @@ class Screen {
})();
}
/**
* Set the number of running commands for the screen.
* @param numRunning The number of running commands.
*/
setNumRunningCmds(numRunning: number): void {
mobx.action(() => {
this.numRunningCmds.set(numRunning);
})();
}
termCustomKeyHandlerInternal(e: any, termWrap: TermWrap): void {
if (e.code == "ArrowUp") {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "ArrowUp")) {
termWrap.terminal.scrollLines(-1);
return;
}
if (e.code == "ArrowDown") {
if (checkKeyPressed(waveEvent, "ArrowDown")) {
termWrap.terminal.scrollLines(1);
return;
}
if (e.code == "PageUp") {
if (checkKeyPressed(waveEvent, "PageUp")) {
termWrap.terminal.scrollPages(-1);
return;
}
if (e.code == "PageDown") {
if (checkKeyPressed(waveEvent, "PageDown")) {
termWrap.terminal.scrollPages(1);
return;
}
}
isTermCapturedKey(e: any): boolean {
let keys = ["ArrowUp", "ArrowDown", "PageUp", "PageDown"];
if (keys.includes(e.code) && keyHasNoMods(e)) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (
checkKeyPressed(waveEvent, "ArrowUp") ||
checkKeyPressed(waveEvent, "ArrowDown") ||
checkKeyPressed(waveEvent, "PageUp") ||
checkKeyPressed(waveEvent, "PageDown")
) {
return true;
}
return false;
}
termCustomKeyHandler(e: any, termWrap: TermWrap): boolean {
if (e.type == "keypress" && e.code == "KeyC" && e.shiftKey && e.ctrlKey) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (e.type == "keypress" && checkKeyPressed(waveEvent, "Ctrl:Shift:c")) {
e.stopPropagation();
e.preventDefault();
let sel = termWrap.terminal.getSelection();
navigator.clipboard.writeText(sel);
return false;
}
if (e.type == "keypress" && e.code == "KeyV" && e.shiftKey && e.ctrlKey) {
if (e.type == "keypress" && checkKeyPressed(waveEvent, "Ctrl:Shift:v")) {
e.stopPropagation();
e.preventDefault();
let p = navigator.clipboard.readText();
@ -880,7 +902,6 @@ class Screen {
console.log("term-wrap already exists for", this.screenId, lineId);
return;
}
let cols = windowWidthToCols(width, GlobalModel.termFontSize.get());
let usedRows = GlobalModel.getContentHeight(getRendererContext(line));
if (line.contentheight != null && line.contentheight != -1) {
usedRows = line.contentheight;
@ -910,7 +931,6 @@ class Screen {
if (this.focusType.get() == "cmd" && this.selectedLine.get() == line.linenum) {
termWrap.giveFocus();
}
return;
}
unloadRenderer(lineId: string) {
@ -936,7 +956,6 @@ class Screen {
}
let termWrap = this.getTermWrap(cmd.lineId);
if (termWrap == null) {
let cols = windowWidthToCols(width, GlobalModel.termFontSize.get());
let usedRows = GlobalModel.getContentHeight(context);
if (usedRows != null) {
return usedRows;
@ -1007,8 +1026,7 @@ class ScreenLines {
getNonArchivedLines(): LineType[] {
let rtn: LineType[] = [];
for (let i = 0; i < this.lines.length; i++) {
let line = this.lines[i];
for (const line of this.lines) {
if (line.archived) {
continue;
}
@ -1029,8 +1047,8 @@ class ScreenLines {
(l: LineType) => sprintf("%013d:%s", l.ts, l.lineid)
);
let cmds = slines.cmds || [];
for (let i = 0; i < cmds.length; i++) {
this.cmds[cmds[i].lineid] = new Cmd(cmds[i]);
for (const cmd of cmds) {
this.cmds[cmd.lineid] = new Cmd(cmd);
}
})();
}
@ -1048,22 +1066,37 @@ class ScreenLines {
return this.cmds[lineId];
}
getRunningCmdLines(): LineType[] {
/**
* Get all running cmds in the screen.
* @param returnFirst If true, return the first running cmd found.
* @returns An array of running cmds, or the first running cmd if returnFirst is true.
*/
getRunningCmdLines(returnFirst?: boolean): LineType[] {
let rtn: LineType[] = [];
for (let i = 0; i < this.lines.length; i++) {
let line = this.lines[i];
let cmd = this.getCmd(line.lineid);
for (const line of this.lines) {
const cmd = this.getCmd(line.lineid);
if (cmd == null) {
continue;
}
let status = cmd.getStatus();
const status = cmd.getStatus();
if (cmdStatusIsRunning(status)) {
if (returnFirst) {
return [line];
}
rtn.push(line);
}
}
return rtn;
}
/**
* Check if there are any running cmds in the screen.
* @returns True if there are any running cmds.
*/
hasRunningCmdLines(): boolean {
return this.getRunningCmdLines(true).length > 0;
}
updateCmd(cmd: CmdDataType): void {
if (cmd.remove) {
throw new Error("cannot remove cmd with updateCmd call [" + cmd.lineid + "]");
@ -1072,7 +1105,6 @@ class ScreenLines {
if (origCmd != null) {
origCmd.setCmd(cmd);
}
return;
}
mergeCmd(cmd: CmdDataType): void {
@ -1086,7 +1118,6 @@ class ScreenLines {
return;
}
origCmd.setCmd(cmd);
return;
}
addLineCmd(line: LineType, cmd: CmdDataType, interactive: boolean) {
@ -1315,11 +1346,9 @@ class InputModel {
if (isFocused) {
this.inputFocused.set(true);
this.lineFocused.set(false);
} else {
if (this.inputFocused.get()) {
} else if (this.inputFocused.get()) {
this.inputFocused.set(false);
}
}
})();
}
@ -1328,11 +1357,9 @@ class InputModel {
if (isFocused) {
this.inputFocused.set(false);
this.lineFocused.set(true);
} else {
if (this.lineFocused.get()) {
} else if (this.lineFocused.get()) {
this.lineFocused.set(false);
}
}
})();
}
@ -1564,14 +1591,12 @@ class InputModel {
curRemote = { ownerid: "", name: "", remoteid: "" };
}
curRemote = mobx.toJS(curRemote);
for (let i = 0; i < hitems.length; i++) {
let hitem = hitems[i];
for (const hitem of hitems) {
if (hitem.ismetacmd) {
if (!opts.includeMeta) {
continue;
}
} else {
if (opts.limitRemoteInstance) {
} else if (opts.limitRemoteInstance) {
if (hitem.remote == null || isBlank(hitem.remote.remoteid)) {
continue;
}
@ -1593,7 +1618,6 @@ class InputModel {
continue;
}
}
}
if (!isBlank(opts.queryStr)) {
if (isBlank(hitem.cmdstr)) {
continue;
@ -1642,7 +1666,6 @@ class InputModel {
return;
}
historyDiv.scrollTop = elemOffset - titleHeight - buffer;
return;
}
}
@ -1728,7 +1751,7 @@ class InputModel {
}
setAIChatFocus() {
if (this.aiChatTextAreaRef != null && this.aiChatTextAreaRef.current != null) {
if (this.aiChatTextAreaRef?.current != null) {
this.aiChatTextAreaRef.current.focus();
}
}
@ -1759,7 +1782,7 @@ class InputModel {
this.codeSelectSelectedIndex.set(blockIndex);
let currentRef = this.codeSelectBlockRefArray[blockIndex].current;
if (currentRef != null) {
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
if (this.aiChatWindowRef?.current != null) {
let chatWindowTop = this.aiChatWindowRef.current.scrollTop;
let chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100;
let elemTop = currentRef.offsetTop;
@ -1789,7 +1812,7 @@ class InputModel {
let incBlockIndex = this.codeSelectSelectedIndex.get() + 1;
if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) {
this.codeSelectDeselectAll();
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
if (this.aiChatWindowRef?.current != null) {
this.aiChatWindowRef.current.scrollTop = this.aiChatWindowRef.current.scrollHeight;
}
}
@ -1813,7 +1836,7 @@ class InputModel {
let decBlockIndex = this.codeSelectSelectedIndex.get() - 1;
if (decBlockIndex < 0) {
this.codeSelectDeselectAll(this.codeSelectTop);
if (this.aiChatWindowRef != null && this.aiChatWindowRef.current != null) {
if (this.aiChatWindowRef?.current != null) {
this.aiChatWindowRef.current.scrollTop = 0;
}
}
@ -1855,8 +1878,7 @@ class InputModel {
clearAIAssistantChat(): void {
let prtn = GlobalModel.submitChatInfoCommand("", "", true);
prtn.then((rtn) => {
if (rtn.success) {
} else {
if (!rtn.success) {
console.log("submit chat command error: " + rtn.error);
}
}).catch((error) => {
@ -1979,7 +2001,6 @@ class InputModel {
}
getCurLine(): string {
let model = GlobalModel;
let hidx = this.historyIndex.get();
if (hidx < this.modHistory.length && this.modHistory[hidx] != null) {
return this.modHistory[hidx];
@ -2190,7 +2211,6 @@ class SpecialLineContainer {
console.log("term-wrap already exists for", line.screenid, lineId);
return;
}
let cols = windowWidthToCols(width, GlobalModel.termFontSize.get());
let usedRows = GlobalModel.getContentHeight(getRendererContext(line));
if (line.contentheight != null && line.contentheight != -1) {
usedRows = line.contentheight;
@ -2214,7 +2234,6 @@ class SpecialLineContainer {
onUpdateContentHeight: null,
});
this.terminal = termWrap;
return;
}
registerRenderer(lineId: string, renderer: RendererModel): void {
@ -2324,8 +2343,6 @@ class HistoryViewModel {
specialLineContainer: SpecialLineContainer;
constructor() {}
closeView(): void {
GlobalModel.showSessionView();
setTimeout(() => GlobalModel.inputModel.giveFocus(), 50);
@ -2335,8 +2352,7 @@ class HistoryViewModel {
if (isBlank(lineId)) {
return null;
}
for (let i = 0; i < this.historyItemLines.length; i++) {
let line = this.historyItemLines[i];
for (const line of this.historyItemLines) {
if (line.lineid == lineId) {
return line;
}
@ -2348,8 +2364,7 @@ class HistoryViewModel {
if (isBlank(lineId)) {
return null;
}
for (let i = 0; i < this.historyItemCmds.length; i++) {
let cmd = this.historyItemCmds[i];
for (const cmd of this.historyItemCmds) {
if (cmd.lineid == lineId) {
return new Cmd(cmd);
}
@ -2361,8 +2376,7 @@ class HistoryViewModel {
if (isBlank(historyId)) {
return null;
}
for (let i = 0; i < this.items.length; i++) {
let hitem = this.items[i];
for (const hitem of this.items) {
if (hitem.historyid == historyId) {
return hitem;
}
@ -2421,7 +2435,6 @@ class HistoryViewModel {
prtn.then((result: CommandRtnType) => {
if (!result.success) {
GlobalModel.showAlert({ message: "Error removing history lines." });
return;
}
});
let params = this._getSearchParams();
@ -2436,8 +2449,8 @@ class HistoryViewModel {
}
_getSearchParams(newOffset?: number, newRawOffset?: number): HistorySearchParams {
let offset = newOffset != null ? newOffset : this.offset.get();
let rawOffset = newRawOffset != null ? newRawOffset : this.curRawOffset;
let offset = newOffset ?? this.offset.get();
let rawOffset = newRawOffset ?? this.curRawOffset;
let opts: HistorySearchParams = {
offset: offset,
rawOffset: rawOffset,
@ -2559,7 +2572,8 @@ class HistoryViewModel {
}
handleDocKeyDown(e: any): void {
if (e.code == "Escape") {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
this.closeView();
return;
@ -2731,8 +2745,7 @@ class BookmarksModel {
if (bookmarkId == null) {
return null;
}
for (let i = 0; i < this.bookmarks.length; i++) {
let bm = this.bookmarks[i];
for (const bm of this.bookmarks) {
if (bm.bookmarkid == bookmarkId) {
return bm;
}
@ -2800,7 +2813,8 @@ class BookmarksModel {
}
handleDocKeyDown(e: any): void {
if (e.code == "Escape") {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
if (this.editingBookmark.get() != null) {
this.cancelEdit();
@ -2812,7 +2826,7 @@ class BookmarksModel {
if (this.editingBookmark.get() != null) {
return;
}
if (e.code == "Backspace" || e.code == "Delete") {
if (checkKeyPressed(waveEvent, "Backspace") || checkKeyPressed(waveEvent, "Delete")) {
if (this.activeBookmark.get() == null) {
return;
}
@ -2820,7 +2834,13 @@ class BookmarksModel {
this.handleDeleteBookmark(this.activeBookmark.get());
return;
}
if (e.code == "ArrowUp" || e.code == "ArrowDown" || e.code == "PageUp" || e.code == "PageDown") {
if (
checkKeyPressed(waveEvent, "ArrowUp") ||
checkKeyPressed(waveEvent, "ArrowDown") ||
checkKeyPressed(waveEvent, "PageUp") ||
checkKeyPressed(waveEvent, "PageDown")
) {
e.preventDefault();
if (this.bookmarks.length == 0) {
return;
@ -2844,14 +2864,14 @@ class BookmarksModel {
})();
return;
}
if (e.code == "Enter") {
if (checkKeyPressed(waveEvent, "Enter")) {
if (this.activeBookmark.get() == null) {
return;
}
this.useBookmark(this.activeBookmark.get());
return;
}
if (e.code == "KeyE") {
if (checkKeyPressed(waveEvent, "e")) {
if (this.activeBookmark.get() == null) {
return;
}
@ -2859,13 +2879,12 @@ class BookmarksModel {
this.handleEditBookmark(this.activeBookmark.get());
return;
}
if (e.code == "KeyC") {
if (checkKeyPressed(waveEvent, "c")) {
if (this.activeBookmark.get() == null) {
return;
}
e.preventDefault();
this.handleCopyBookmark(this.activeBookmark.get());
return;
}
}
}
@ -3401,6 +3420,7 @@ class Model {
this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, {
name: "model-wavesrv-running",
});
this.platform = this.getPlatform();
this.termFontSize = mobx.computed(() => {
let cdata = this.clientData.get();
if (cdata == null || cdata.feopts == null || cdata.feopts.termfontsize == null) {
@ -3421,6 +3441,7 @@ class Model {
getApi().onHCmd(this.onHCmd.bind(this));
getApi().onPCmd(this.onPCmd.bind(this));
getApi().onWCmd(this.onWCmd.bind(this));
getApi().onRCmd(this.onRCmd.bind(this));
getApi().onMenuItemAbout(this.onMenuItemAbout.bind(this));
getApi().onMetaArrowUp(this.onMetaArrowUp.bind(this));
getApi().onMetaArrowDown(this.onMetaArrowDown.bind(this));
@ -3444,9 +3465,14 @@ class Model {
return this.platform;
}
this.platform = getApi().getPlatform();
setKeyUtilPlatform(this.platform);
return this.platform;
}
testGlobalModel() {
return "";
}
needsTos(): boolean {
let cdata = this.clientData.get();
if (cdata == null) {
@ -3588,16 +3614,17 @@ class Model {
}
docKeyDownHandler(e: KeyboardEvent) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (isModKeyPress(e)) {
return;
}
if (this.alertMessage.get() != null) {
if (e.code == "Escape") {
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
this.cancelAlert();
return;
}
if (e.code == "Enter") {
if (checkKeyPressed(waveEvent, "Enter")) {
e.preventDefault();
this.confirmAlert();
return;
@ -3620,7 +3647,7 @@ class Model {
this.historyViewModel.handleDocKeyDown(e);
return;
}
if (e.code == "Escape") {
if (checkKeyPressed(waveEvent, "Escape")) {
e.preventDefault();
if (this.activeMainView.get() == "webshare") {
this.showSessionView();
@ -3636,16 +3663,11 @@ class Model {
}
return;
}
if (e.code == "KeyB" && e.getModifierState("Meta")) {
if (checkKeyPressed(waveEvent, "Cmd:b")) {
e.preventDefault();
GlobalCommandRunner.bookmarksView();
}
if (
this.activeMainView.get() == "session" &&
e.code == "KeyS" &&
e.getModifierState("Meta") &&
e.getModifierState("Control")
) {
if (this.activeMainView.get() == "session" && checkKeyPressed(waveEvent, "Cmd:Ctrl:s")) {
e.preventDefault();
let activeScreen = this.getActiveScreen();
if (activeScreen != null) {
@ -3657,7 +3679,7 @@ class Model {
}
}
}
if (e.code == "KeyD" && e.getModifierState("Meta")) {
if (checkKeyPressed(waveEvent, "Cmd:d")) {
let ranDelete = this.deleteActiveLine();
if (ranDelete) {
e.preventDefault();
@ -3691,6 +3713,9 @@ class Model {
}
onWCmd(e: any, mods: KeyModsType) {
if (this.activeMainView.get() != "session") {
return;
}
let activeScreen = this.getActiveScreen();
if (activeScreen == null) {
return;
@ -3707,6 +3732,27 @@ class Model {
});
}
onRCmd(e: any, mods: KeyModsType) {
if (this.activeMainView.get() != "session") {
return;
}
let activeScreen = this.getActiveScreen();
if (activeScreen == null) {
return;
}
if (mods.shift) {
// restart last line
GlobalCommandRunner.lineRestart("E", true);
} else {
// restart selected line
let selectedLine = activeScreen.selectedLine.get();
if (selectedLine == null || selectedLine == 0) {
return;
}
GlobalCommandRunner.lineRestart(String(selectedLine), true);
}
}
clearModals(): boolean {
let didSomething = false;
mobx.action(() => {
@ -3739,9 +3785,9 @@ class Model {
}
getLocalRemote(): RemoteType {
for (let i = 0; i < this.remotes.length; i++) {
if (this.remotes[i].local) {
return this.remotes[i];
for (const remote of this.remotes) {
if (remote.local) {
return remote;
}
}
return null;
@ -3851,7 +3897,6 @@ class Model {
let wasRunning = cmdStatusIsRunning(origStatus);
let isRunning = cmdStatusIsRunning(newStatus);
if (wasRunning && !isRunning) {
// console.log("cmd status", screenId, lineId, origStatus, "=>", newStatus);
let ptr = this.getActiveLine(screenId, lineId);
if (ptr != null) {
let screen = ptr.screen;
@ -3994,8 +4039,8 @@ class Model {
this.updateCmd(update.cmd);
}
if ("lines" in update) {
for (let i = 0; i < update.lines.length; i++) {
this.addLineCmd(update.lines[i], null, interactive);
for (const line of update.lines) {
this.addLineCmd(line, null, interactive);
}
}
if ("screenlines" in update) {
@ -4007,8 +4052,8 @@ class Model {
}
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.openReadModal(update.remotes![0].remoteid);
if (update.remotes?.length && this.remotesModel.recentConnAddedState.get()) {
GlobalModel.remotesModel.openReadModal(update.remotes[0].remoteid);
}
}
if ("mainview" in update) {
@ -4059,22 +4104,28 @@ class Model {
this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat);
}
if ("screenstatusindicator" in update) {
this.getScreenById_single(update.screenstatusindicator.screenid)?.setStatusIndicator(update.screenstatusindicator.status);
this.getScreenById_single(update.screenstatusindicator.screenid)?.setStatusIndicator(
update.screenstatusindicator.status
);
}
if ("screennumrunningcommands" in update) {
this.getScreenById_single(update.screennumrunningcommands.screenid)?.setNumRunningCmds(
update.screennumrunningcommands.num
);
}
if ("userinputrequest" in update) {
let userInputRequest: UserInputRequest = update.userinputrequest;
let userInputResponse: UserInputResponse = {
type: "text",
text: "what wonderful weather we're having",
}
};
let userInputResponsePacket: UserInputResponsePacket = {
type: "userinputresp",
requestid: userInputRequest.requestid,
response: userInputResponse,
}
};
this.ws.pushMessage(userInputResponsePacket);
}
// console.log("run-update>", Date.now(), interactive, update);
}
updateRemotes(remotes: RemoteType[]): void {
@ -4087,8 +4138,7 @@ class Model {
getSessionNames(): Record<string, string> {
let rtn: Record<string, string> = {};
for (let i = 0; i < this.sessionList.length; i++) {
let session = this.sessionList[i];
for (const session of this.sessionList) {
rtn[session.sessionId] = session.name.get();
}
return rtn;
@ -4106,9 +4156,9 @@ class Model {
if (sessionId == null) {
return null;
}
for (let i = 0; i < this.sessionList.length; i++) {
if (this.sessionList[i].sessionId == sessionId) {
return this.sessionList[i];
for (const session of this.sessionList) {
if (session.sessionId == sessionId) {
return session;
}
}
return null;
@ -4135,7 +4185,6 @@ class Model {
let newWindow = new ScreenLines(slines.screenid);
this.screenLines.set(slines.screenid, newWindow);
newWindow.updateData(slines, load);
return;
} else {
existingWin.updateData(slines, load);
existingWin.loaded.set(true);
@ -4183,12 +4232,28 @@ class Model {
return session.getActiveScreen();
}
handleCmdRestart(cmd: CmdDataType) {
if (cmd == null || !cmd.restarted) {
return;
}
let screen = this.screenMap.get(cmd.screenid);
if (screen == null) {
return;
}
let termWrap = screen.getTermWrap(cmd.lineid);
if (termWrap == null) {
return;
}
termWrap.reload(0);
}
addLineCmd(line: LineType, cmd: CmdDataType, interactive: boolean) {
let slines = this.getScreenLinesById(line.screenid);
if (slines == null) {
return;
}
slines.addLineCmd(line, cmd, interactive);
this.handleCmdRestart(cmd);
}
updateCmd(cmd: CmdDataType) {
@ -4196,6 +4261,7 @@ class Model {
if (slines != null) {
slines.updateCmd(cmd);
}
this.handleCmdRestart(cmd);
}
isInfoUpdate(update: UpdateMessage): boolean {
@ -4292,7 +4358,7 @@ class Model {
metacmd: metaCmd,
metasubcmd: metaSubCmd,
args: args,
kwargs: Object.assign({}, kwargs),
kwargs: { ...kwargs },
uicontext: this.getUIContext(),
interactive: interactive,
};
@ -4367,7 +4433,6 @@ class Model {
}
let slines: ScreenLinesType = data.data;
this.updateScreenLines(slines, true);
return;
})
.catch((err) => {
this.errorHandler(sprintf("getting screen-lines=%s", newWin.screenId), err, false);
@ -4389,8 +4454,7 @@ class Model {
getRemoteNames(): Record<string, string> {
let rtn: Record<string, string> = {};
for (let i = 0; i < this.remotes.length; i++) {
let remote = this.remotes[i];
for (const remote of this.remotes) {
if (!isBlank(remote.remotealias)) {
rtn[remote.remoteid] = remote.remotealias;
} else {
@ -4401,9 +4465,9 @@ class Model {
}
getRemoteByName(name: string): RemoteType {
for (let i = 0; i < this.remotes.length; i++) {
if (this.remotes[i].remotecanonicalname == name || this.remotes[i].remotealias == name) {
return this.remotes[i];
for (const remote of this.remotes) {
if (remote.remotecanonicalname == name || remote.remotealias == name) {
return remote;
}
}
return null;
@ -4434,9 +4498,9 @@ class Model {
return null;
}
let line: LineType = null;
for (let i = 0; i < slines.lines.length; i++) {
if (slines.lines[i].lineid == lineid) {
line = slines.lines[i];
for (const element of slines.lines) {
if (element.lineid == lineid) {
line = element;
break;
}
}
@ -4458,7 +4522,7 @@ class Model {
console.log("[error]", str, err);
if (interactive) {
let errMsg = "error running command";
if (err != null && err.message) {
if (err?.message) {
errMsg = err.message;
}
this.inputModel.flashInfoMsg({ infoerror: errMsg }, null);
@ -4515,13 +4579,10 @@ class Model {
let url = new URL(GlobalModel.getBaseHostPort() + "/api/read-file?" + usp.toString());
let fetchHeaders = this.getFetchHeaders();
let fileInfo: T.FileInfoType = null;
let contentType: string = null;
let isError = false;
let badResponseStr: string = null;
let prtn = fetch(url, { method: "get", headers: fetchHeaders })
.then((resp) => {
if (!resp.ok) {
isError = true;
badResponseStr = sprintf(
"Bad fetch response for /api/read-file: %d %s",
resp.status,
@ -4529,7 +4590,6 @@ class Model {
);
return resp.text() as any;
}
contentType = resp.headers.get("Content-Type");
fileInfo = JSON.parse(base64ToString(resp.headers.get("X-FileInfo")));
return resp.blob();
})
@ -4547,7 +4607,6 @@ class Model {
throw new Error(badResponseStr);
}
throw new Error(textError);
return null;
}
});
return prtn;
@ -4576,15 +4635,13 @@ class Model {
let prtn = fetch(url, { method: "post", headers: fetchHeaders, body: formData });
return prtn
.then((resp) => handleJsonFetchResponse(url, resp))
.then((data) => {
.then((_) => {
return;
});
}
}
class CommandRunner {
constructor() {}
loadHistory(show: boolean, htype: string) {
let kwargs = { nohist: "1" };
if (!show) {
@ -4642,6 +4699,10 @@ class CommandRunner {
return GlobalModel.submitCommand("line", "delete", [lineArg], { nohist: "1" }, interactive);
}
lineRestart(lineArg: string, interactive: boolean): Promise<CommandRtnType> {
return GlobalModel.submitCommand("line", "restart", [lineArg], { nohist: "1" }, interactive);
}
lineSet(lineArg: string, opts: { renderer?: string }): Promise<CommandRtnType> {
let kwargs = { nohist: "1" };
if ("renderer" in opts) {

View File

@ -10,6 +10,7 @@ import { GlobalModel, GlobalCommandRunner } from "../../model/model";
import Split from "react-split-it";
import loader from "@monaco-editor/loader";
loader.config({ paths: { vs: "./node_modules/monaco-editor/min/vs" } });
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil";
import "./code.less";
@ -152,17 +153,18 @@ class SourceCodeRenderer extends React.Component<
this.setInitialLanguage(editor);
this.setEditorHeight();
editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
if (e.code === "KeyS" && e.metaKey && this.state.isSave) {
let waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);
if (checkKeyPressed(waveEvent, "Cmd:s") && this.state.isSave) {
e.preventDefault();
e.stopPropagation();
this.doSave();
}
if (e.code === "KeyD" && e.metaKey) {
if (checkKeyPressed(waveEvent, "Cmd:d")) {
e.preventDefault();
e.stopPropagation();
this.doClose();
}
if (e.code === "KeyP" && e.metaKey) {
if (checkKeyPressed(waveEvent, "Cmd:p")) {
e.preventDefault();
e.stopPropagation();
this.togglePreview();

View File

@ -245,12 +245,14 @@ type CmdDataType = {
status: string;
cmdpid: number;
remotepid: number;
restartts: number;
donets: number;
exitcode: number;
durationms: number;
runout: any[];
rtnstate: boolean;
remove?: boolean;
restarted?: boolean;
};
type PtyDataUpdateType = {
@ -295,7 +297,12 @@ enum StatusIndicatorLevel {
type ScreenStatusIndicatorUpdateType = {
screenid: string;
status: StatusIndicatorLevel;
}
};
type ScreenNumRunningCommandsUpdateType = {
screenid: string;
num: number;
};
type ModelUpdateType = {
interactive: boolean;
@ -320,6 +327,7 @@ type ModelUpdateType = {
openaicmdinfochat?: OpenAICmdInfoChatMessageType[];
alertmessage?: AlertMessageType;
screenstatusindicator?: ScreenStatusIndicatorUpdateType;
screennumrunningcommands?: ScreenNumRunningCommandsUpdateType;
userinputrequest?: UserInputRequest;
};
@ -823,6 +831,4 @@ export type {
ScreenStatusIndicatorUpdateType,
};
export {
StatusIndicatorLevel,
};
export { StatusIndicatorLevel };

171
src/util/keyutil.ts Normal file
View File

@ -0,0 +1,171 @@
import * as React from "react";
import * as electron from "electron";
type KeyPressDecl = {
mods: {
Cmd?: boolean;
Option?: boolean;
Shift?: boolean;
Ctrl?: boolean;
Alt?: boolean;
Meta?: boolean;
};
key: string;
};
var PLATFORM: string;
const PlatformMacOS: string = "darwin";
function setKeyUtilPlatform(platform: string) {
PLATFORM = platform;
}
function parseKeyDescription(keyDescription: string): KeyPressDecl {
let rtn = { key: "", mods: {} } as KeyPressDecl;
let keys = keyDescription.replace(/[()]/g, "").split(":");
for (let key of keys) {
if (key == "Cmd") {
rtn.mods.Cmd = true;
} else if (key == "Shift") {
rtn.mods.Shift = true;
} else if (key == "Ctrl") {
rtn.mods.Ctrl = true;
} else if (key == "Option") {
rtn.mods.Option = true;
} else if (key == "Alt") {
rtn.mods.Alt = true;
} else if (key == "Meta") {
rtn.mods.Meta = true;
} else {
rtn.key = key;
if (key.length == 1) {
// check for if key is upper case
if (/[A-Z]/.test(key.charAt(0))) {
// this key is an upper case A - Z - we should apply the shift key, even if it wasn't specified
rtn.mods.Shift = true;
} else if (key == " ") {
rtn.key = "Space";
// we allow " " and "Space" to be mapped to Space key
}
}
}
}
return rtn;
}
function checkKeyPressed(event: WaveKeyboardEvent, description: string): boolean {
let keyPress = parseKeyDescription(description);
if (keyPress.mods.Option && !event.option) {
return false;
}
if (keyPress.mods.Cmd && !event.cmd) {
return false;
}
if (keyPress.mods.Shift && !event.shift) {
return false;
}
if (keyPress.mods.Ctrl && !event.control) {
return false;
}
if (keyPress.mods.Alt && !event.alt) {
return false;
}
if (keyPress.mods.Meta && !event.meta) {
return false;
}
let eventKey = event.key;
let descKey = keyPress.key;
if (eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) {
// key is upper case A-Z, this means shift is applied, we want to allow
// "Shift:e" as well as "Shift:E" or "E"
eventKey = eventKey.toLocaleLowerCase();
descKey = descKey.toLocaleLowerCase();
} else if (eventKey == " ") {
eventKey = "Space";
// a space key is shown as " ", we want users to be able to set space key as "Space" or " ", whichever they prefer
}
if (descKey != eventKey) {
return false;
}
return true;
}
// Cmd and Option are portable between Mac and Linux/Windows
type ModKeyStrs = "Cmd" | "Option" | "Shift" | "Ctrl" | "Alt" | "Meta";
interface WaveKeyboardEvent {
type: string;
/**
* Equivalent to KeyboardEvent.key.
*/
key: string;
/**
* Equivalent to KeyboardEvent.code.
*/
code: string;
/**
* Equivalent to KeyboardEvent.shiftKey.
*/
shift: boolean;
/**
* Equivalent to KeyboardEvent.controlKey.
*/
control: boolean;
/**
* Equivalent to KeyboardEvent.altKey.
*/
alt: boolean;
/**
* Equivalent to KeyboardEvent.metaKey.
*/
meta: boolean;
/**
* cmd is special, on mac it is meta, on windows it is alt
*/
cmd: boolean;
/**
* option is special, on mac it is alt, on windows it is meta
*/
option: boolean;
repeat: boolean;
/**
* Equivalent to KeyboardEvent.location.
*/
location: number;
}
function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): WaveKeyboardEvent {
let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent;
rtn.control = event.ctrlKey;
rtn.shift = event.shiftKey;
rtn.cmd = (PLATFORM == PlatformMacOS ? event.metaKey : event.altKey);
rtn.option = (PLATFORM == PlatformMacOS ? event.altKey : event.metaKey);
rtn.meta = event.metaKey;
rtn.alt = event.altKey;
rtn.code = event.code;
rtn.key = event.key;
rtn.location = event.location;
rtn.type = event.type;
rtn.repeat = event.repeat;
return rtn;
}
function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent {
let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent;
rtn.type = event.type;
rtn.control = event.control;
rtn.cmd = (PLATFORM == PlatformMacOS ? event.meta : event.alt)
rtn.option = (PLATFORM == PlatformMacOS ? event.alt : event.meta);
rtn.meta = event.meta;
rtn.alt = event.alt;
rtn.shift = event.shift;
rtn.repeat = event.isAutoRepeat;
rtn.location = event.location;
rtn.code = event.code;
rtn.key = event.key;
return rtn;
}
export { adaptFromElectronKeyEvent, adaptFromReactOrNativeKeyEvent, checkKeyPressed, setKeyUtilPlatform };
export type { WaveKeyboardEvent };

View File

@ -1,2 +1,2 @@
const VERSION = "v0.6.0";
const VERSION = "v0.6.1";
module.exports = VERSION;

View File

@ -0,0 +1,84 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package utilfn
import (
"sync"
)
type SyncMap[K comparable, V any] struct {
lock *sync.Mutex
m map[K]V
}
func MakeSyncMap[K comparable, V any]() *SyncMap[K, V] {
return &SyncMap[K, V]{
lock: &sync.Mutex{},
m: make(map[K]V),
}
}
func (sm *SyncMap[K, V]) Set(k K, v V) {
sm.lock.Lock()
defer sm.lock.Unlock()
sm.m[k] = v
}
func (sm *SyncMap[K, V]) Get(k K) V {
sm.lock.Lock()
defer sm.lock.Unlock()
return sm.m[k]
}
func (sm *SyncMap[K, V]) GetEx(k K) (V, bool) {
sm.lock.Lock()
defer sm.lock.Unlock()
v, ok := sm.m[k]
return v, ok
}
func (sm *SyncMap[K, V]) Delete(k K) {
sm.lock.Lock()
defer sm.lock.Unlock()
delete(sm.m, k)
}
func (sm *SyncMap[K, V]) Clear() {
sm.lock.Lock()
defer sm.lock.Unlock()
sm.m = make(map[K]V)
}
func (sm *SyncMap[K, V]) Len() int {
sm.lock.Lock()
defer sm.lock.Unlock()
return len(sm.m)
}
func (sm *SyncMap[K, V]) Keys() []K {
sm.lock.Lock()
defer sm.lock.Unlock()
keys := make([]K, len(sm.m))
i := 0
for k := range sm.m {
keys[i] = k
i++
}
return keys
}
func (sm *SyncMap[K, V]) Replace(newMap map[K]V) {
sm.lock.Lock()
defer sm.lock.Unlock()
sm.m = make(map[K]V, len(newMap))
for k, v := range newMap {
sm.m[k] = v
}
}
func IncSyncMap[K comparable, V int | int64](sm *SyncMap[K, V], key K, incAmt V) {
sm.lock.Lock()
defer sm.lock.Unlock()
sm.m[key] += incAmt
}

View File

@ -0,0 +1 @@
ALTER TABLE cmd DROP COLUMN restartts;

View File

@ -0,0 +1 @@
ALTER TABLE cmd ADD COLUMN restartts bigint NOT NULL DEFAULT 0;

View File

@ -210,6 +210,7 @@ func init() {
registerCmdFn("line:setheight", LineSetHeightCommand)
registerCmdFn("line:view", LineViewCommand)
registerCmdFn("line:set", LineSetCommand)
registerCmdFn("line:restart", LineRestartCommand)
registerCmdFn("client", ClientCommand)
registerCmdFn("client:show", ClientShowCommand)
@ -491,7 +492,12 @@ func SyncCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.
}
runPacket.Command = ":"
runPacket.ReturnState = true
cmd, callback, err := remote.RunCommand(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr, runPacket)
rcOpts := remote.RunCommandOpts{
SessionId: ids.SessionId,
ScreenId: ids.ScreenId,
RemotePtr: ids.Remote.RemotePtr,
}
cmd, callback, err := remote.RunCommand(ctx, rcOpts, runPacket)
if callback != nil {
defer callback()
}
@ -587,7 +593,12 @@ func RunCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.U
}
runPacket.Command = strings.TrimSpace(cmdStr)
runPacket.ReturnState = resolveBool(pk.Kwargs["rtnstate"], isRtnStateCmd)
cmd, callback, err := remote.RunCommand(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr, runPacket)
rcOpts := remote.RunCommandOpts{
SessionId: ids.SessionId,
ScreenId: ids.ScreenId,
RemotePtr: ids.Remote.RemotePtr,
}
cmd, callback, err := remote.RunCommand(ctx, rcOpts, runPacket)
if callback != nil {
defer callback()
}
@ -2974,6 +2985,7 @@ func SessionCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto
if err != nil {
return nil, err
}
update := &sstore.ModelUpdate{
ActiveSessionId: ritem.Id,
Info: &sstore.InfoMsgType{
@ -2981,6 +2993,21 @@ func SessionCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto
TimeoutMs: 2000,
},
}
// Reset the status indicator for the new active screen
session, err := sstore.GetSessionById(ctx, ritem.Id)
if err != nil {
return nil, fmt.Errorf("cannot get session: %w", err)
}
if session == nil {
return nil, fmt.Errorf("session not found")
}
err = sstore.ResetStatusIndicator_Update(update, session.ActiveScreenId)
if err != nil {
// this is not a fatal error, just log it
log.Printf("error resetting status indicator after session command: %v\n", err)
}
return update, nil
}
@ -3320,6 +3347,121 @@ func LineSetHeightCommand(ctx context.Context, pk *scpacket.FeCommandPacketType)
return nil, nil
}
func LineRestartCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen|R_RemoteConnected)
if err != nil {
return nil, err
}
var lineId string
if len(pk.Args) >= 1 {
lineArg := pk.Args[0]
resolvedLineId, err := sstore.FindLineIdByArg(ctx, ids.ScreenId, lineArg)
if err != nil {
return nil, fmt.Errorf("error looking up lineid: %v", err)
}
lineId = resolvedLineId
} else {
selectedLineId, err := sstore.GetScreenSelectedLineId(ctx, ids.ScreenId)
if err != nil {
return nil, fmt.Errorf("error getting selected lineid: %v", err)
}
lineId = selectedLineId
}
if lineId == "" {
return nil, fmt.Errorf("%s requires a lineid to operate on", GetCmdStr(pk))
}
line, cmd, err := sstore.GetLineCmdByLineId(ctx, ids.ScreenId, lineId)
if err != nil {
return nil, fmt.Errorf("error getting line: %v", err)
}
if line == nil {
return nil, fmt.Errorf("line not found")
}
if cmd == nil {
return nil, fmt.Errorf("cannot restart line (no cmd found)")
}
if cmd.Status == sstore.CmdStatusRunning || cmd.Status == sstore.CmdStatusDetached {
killCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
err = ids.Remote.MShell.KillRunningCommandAndWait(killCtx, base.MakeCommandKey(ids.ScreenId, lineId))
if err != nil {
return nil, err
}
}
ids.Remote.MShell.ResetDataPos(base.MakeCommandKey(ids.ScreenId, lineId))
err = sstore.ClearCmdPtyFile(ctx, ids.ScreenId, lineId)
if err != nil {
return nil, fmt.Errorf("error clearing existing pty file: %v", err)
}
runPacket := packet.MakeRunPacket()
runPacket.ReqId = uuid.New().String()
runPacket.CK = base.MakeCommandKey(ids.ScreenId, lineId)
runPacket.UsePty = true
// TODO how can we preseve the original termopts?
runPacket.TermOpts, err = GetUITermOpts(pk.UIContext.WinSize, DefaultPTERM)
if err != nil {
return nil, fmt.Errorf("error getting creating termopts for command: %w", err)
}
runPacket.Command = cmd.CmdStr
runPacket.ReturnState = false
rcOpts := remote.RunCommandOpts{
SessionId: ids.SessionId,
ScreenId: ids.ScreenId,
RemotePtr: ids.Remote.RemotePtr,
StatePtr: &cmd.StatePtr,
NoCreateCmdPtyFile: true,
}
cmd, callback, err := remote.RunCommand(ctx, rcOpts, runPacket)
if callback != nil {
defer callback()
}
if err != nil {
return nil, err
}
newTs := time.Now().UnixMilli()
err = sstore.UpdateCmdForRestart(ctx, runPacket.CK, newTs, cmd.CmdPid, cmd.RemotePid, convertTermOpts(runPacket.TermOpts))
if err != nil {
return nil, fmt.Errorf("error updating cmd for restart: %w", err)
}
line, cmd, err = sstore.GetLineCmdByLineId(ctx, ids.ScreenId, lineId)
if err != nil {
return nil, fmt.Errorf("error getting updated line/cmd: %w", err)
}
cmd.Restarted = true
update := &sstore.ModelUpdate{
Line: line,
Cmd: cmd,
Interactive: pk.Interactive,
}
screen, focusErr := focusScreenLine(ctx, ids.ScreenId, line.LineNum)
if focusErr != nil {
// not a fatal error, so just log
log.Printf("error focusing screen line: %v\n", focusErr)
}
if screen != nil {
update.Screens = []*sstore.ScreenType{screen}
}
return update, nil
}
func focusScreenLine(ctx context.Context, screenId string, lineNum int64) (*sstore.ScreenType, error) {
screen, err := sstore.GetScreenById(ctx, screenId)
if err != nil {
return nil, fmt.Errorf("error getting screen: %v", err)
}
if screen == nil {
return nil, fmt.Errorf("screen not found")
}
updateMap := make(map[string]interface{})
updateMap[sstore.ScreenField_SelectedLine] = lineNum
updateMap[sstore.ScreenField_Focus] = sstore.ScreenFocusCmd
screen, err = sstore.UpdateScreen(ctx, screenId, updateMap)
if err != nil {
return nil, fmt.Errorf("error updating screen: %v", err)
}
return screen, nil
}
func LineSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen)
if err != nil {
@ -3751,6 +3893,10 @@ func LineShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sst
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "file", stat.Location))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "file-data", fileDataStr))
}
if cmd.RestartTs > 0 {
restartTs := time.UnixMilli(cmd.RestartTs)
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "restartts", restartTs.Format(TsFormatStr)))
}
if cmd.DoneTs != 0 {
doneTs := time.UnixMilli(cmd.DoneTs)
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "donets", doneTs.Format(TsFormatStr)))

View File

@ -123,3 +123,12 @@ func convertTermOpts(pkto *packet.TermOpts) *sstore.TermOpts {
MaxPtySize: pkto.MaxPtySize,
}
}
func convertToPacketTermOpts(sto sstore.TermOpts) *packet.TermOpts {
return &packet.TermOpts{
Rows: int(sto.Rows),
Cols: int(sto.Cols),
FlexRows: sto.FlexRows,
MaxPtySize: sto.MaxPtySize,
}
}

View File

@ -161,6 +161,7 @@ type MShellProc struct {
StateMap *server.ShellStateMap
NumTryConnect int
InitPkShellType string
DataPosMap *utilfn.SyncMap[base.CommandKey, int64]
// install
InstallStatus string
@ -169,7 +170,6 @@ type MShellProc struct {
InstallErr error
RunningCmds map[base.CommandKey]RunCmdType
WaitingCmds []RunCmdType
PendingStateCmds map[pendingStateKey]base.CommandKey // key=[remoteinstance name]
launcher Launcher // for conditional launch method based on ssh library in use. remove once ssh library is stabilized
}
@ -209,6 +209,18 @@ func (msh *MShellProc) GetDefaultState(shellType string) *packet.ShellState {
return state
}
func (msh *MShellProc) EnsureShellType(ctx context.Context, shellType string) error {
if msh.StateMap.HasShell(shellType) {
return nil
}
// try to reinit the shell
_, err := msh.ReInit(ctx, shellType)
if err != nil {
return fmt.Errorf("error trying to initialize shell %q: %v", shellType, err)
}
return nil
}
func (msh *MShellProc) GetDefaultStatePtr(shellType string) *sstore.ShellStatePtr {
msh.Lock.Lock()
defer msh.Lock.Unlock()
@ -692,6 +704,7 @@ func MakeMShell(r *sstore.RemoteType) *MShellProc {
PendingStateCmds: make(map[pendingStateKey]base.CommandKey),
StateMap: server.MakeShellStateMap(),
launcher: LegacyLauncher{}, // for conditional launch method based on ssh library in use. remove once ssh library is stabilized
DataPosMap: utilfn.MakeSyncMap[base.CommandKey, int64](),
}
// for conditional launch method based on ssh library in use
// remove once ssh library is stabilized
@ -1615,12 +1628,8 @@ func replaceHomePath(pathStr string, homeDir string) string {
func (msh *MShellProc) IsCmdRunning(ck base.CommandKey) bool {
msh.Lock.Lock()
defer msh.Lock.Unlock()
for runningCk := range msh.RunningCmds {
if runningCk == ck {
return true
}
}
return false
_, ok := msh.RunningCmds[ck]
return ok
}
func (msh *MShellProc) SendInput(dataPk *packet.DataPacketType) error {
@ -1633,6 +1642,30 @@ func (msh *MShellProc) SendInput(dataPk *packet.DataPacketType) error {
return msh.ServerProc.Input.SendPacket(dataPk)
}
func (msh *MShellProc) KillRunningCommandAndWait(ctx context.Context, ck base.CommandKey) error {
if !msh.IsCmdRunning(ck) {
return nil
}
siPk := packet.MakeSpecialInputPacket()
siPk.CK = ck
siPk.SigName = "SIGTERM"
err := msh.SendSpecialInput(siPk)
if err != nil {
return fmt.Errorf("error trying to kill running cmd: %w", err)
}
for {
if ctx.Err() != nil {
return ctx.Err()
}
if !msh.IsCmdRunning(ck) {
return nil
}
// TODO fix busy wait (sync with msh.RunningCmds)
// not a huge deal though since this is not processor intensive and not widely used
time.Sleep(100 * time.Millisecond)
}
}
func (msh *MShellProc) SendSpecialInput(siPk *packet.SpecialInputPacketType) error {
if !msh.IsConnected() {
return fmt.Errorf("remote is not connected, cannot send input")
@ -1682,14 +1715,25 @@ func (msh *MShellProc) removePendingStateCmd(screenId string, rptr sstore.Remote
}
}
// returns (cmdtype, allow-updates-callback, err)
func RunCommand(ctx context.Context, sessionId string, screenId string, remotePtr sstore.RemotePtrType, runPacket *packet.RunPacketType) (rtnCmd *sstore.CmdType, rtnCallback func(), rtnErr error) {
rct := RunCmdType{
SessionId: sessionId,
ScreenId: screenId,
RemotePtr: remotePtr,
RunPacket: runPacket,
type RunCommandOpts struct {
SessionId string
ScreenId string
RemotePtr sstore.RemotePtrType
// optional, if not provided shellstate will look up state from remote instance
// ReturnState cannot be used with StatePtr
// this will also cause this command to bypass the pending state cmd logic
StatePtr *sstore.ShellStatePtr
// set to true to skip creating the pty file (for restarted commands)
NoCreateCmdPtyFile bool
}
// returns (CmdType, allow-updates-callback, err)
// we must persist the CmdType to the DB before calling the callback to allow updates
// otherwise an early CmdDone packet might not get processed (since cmd will not exist in DB)
func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.RunPacketType) (rtnCmd *sstore.CmdType, rtnCallback func(), rtnErr error) {
sessionId, screenId, remotePtr := rcOpts.SessionId, rcOpts.ScreenId, rcOpts.RemotePtr
if remotePtr.OwnerId != "" {
return nil, nil, fmt.Errorf("cannot run command against another user's remote '%s'", remotePtr.MakeFullRemoteRef())
}
@ -1706,6 +1750,13 @@ func RunCommand(ctx context.Context, sessionId string, screenId string, remotePt
if runPacket.State != nil {
return nil, nil, fmt.Errorf("runPacket.State should not be set, it is set in RunCommand")
}
if rcOpts.StatePtr != nil && runPacket.ReturnState {
return nil, nil, fmt.Errorf("RunCommand: cannot use ReturnState with StatePtr")
}
// pending state command logic
// if we are currently running a command that can change the state, we need to wait for it to finish
if rcOpts.StatePtr == nil {
var newPSC *base.CommandKey
if runPacket.ReturnState {
newPSC = &runPacket.CK
@ -1721,41 +1772,63 @@ func RunCommand(ctx context.Context, sessionId string, screenId string, remotePt
}
return nil, nil, fmt.Errorf("cannot run command while a stateful command (linenum=%d) is still running", line.LineNum)
}
startCmdWait(runPacket.CK)
defer func() {
if rtnErr != nil {
removeCmdWait(runPacket.CK)
if newPSC != nil {
defer func() {
// if we get an error, remove the pending state cmd
// if no error, PSC will get removed when we see a CmdDone or CmdFinal packet
if rtnErr != nil {
msh.removePendingStateCmd(screenId, remotePtr, *newPSC)
}
}
}()
}
}
// get current remote-instance state
statePtr, err := sstore.GetRemoteStatePtr(ctx, sessionId, screenId, remotePtr)
var statePtr *sstore.ShellStatePtr
if rcOpts.StatePtr != nil {
statePtr = rcOpts.StatePtr
} else {
var err error
statePtr, err = sstore.GetRemoteStatePtr(ctx, sessionId, screenId, remotePtr)
if err != nil {
return nil, nil, fmt.Errorf("cannot get current connection stateptr: %w", err)
}
if statePtr == nil {
statePtr = msh.GetDefaultStatePtr(msh.GetShellPref())
}
if statePtr == nil { // can be null if there is no remote-instance (screen has unchanged state from default)
err := msh.EnsureShellType(ctx, msh.GetShellPref()) // make sure shellType is initialized
if err != nil {
return nil, nil, err
}
statePtr = msh.GetDefaultStatePtr(msh.GetShellPref())
if statePtr == nil {
return nil, nil, fmt.Errorf("cannot run command, no valid connection stateptr")
}
}
currentState, err := sstore.GetFullState(ctx, *statePtr)
if err != nil || currentState == nil {
return nil, nil, fmt.Errorf("cannot get current remote state: %w", err)
return nil, nil, fmt.Errorf("cannot load current remote state: %w", err)
}
runPacket.State = addScVarsToState(currentState)
runPacket.StateComplete = true
runPacket.ShellType = currentState.GetShellType()
// check to see if shellType is initialized
if !msh.StateMap.HasShell(runPacket.ShellType) {
// try to reinit the shell
_, err := msh.ReInit(ctx, runPacket.ShellType)
err = msh.EnsureShellType(ctx, runPacket.ShellType) // make sure shellType is initialized
if err != nil {
return nil, nil, fmt.Errorf("error trying to initialize shell %q: %v", runPacket.ShellType, err)
return nil, nil, err
}
// start cmdwait. must be started before sending the run packet
// this ensures that we don't process output, or cmddone packets until we set up the line, cmd, and ptyout file
startCmdWait(runPacket.CK)
defer func() {
// if we get an error, remove the cmdwait
// if no error, cmdwait will get removed by the caller w/ the callback fn that's returned on success
if rtnErr != nil {
removeCmdWait(runPacket.CK)
}
}()
// RegisterRpc + WaitForResponse is used to get any waveshell side errors
// waveshell will either return an error (in a ResponsePacketType) or a CmdStartPacketType
msh.ServerProc.Output.RegisterRpc(runPacket.ReqId)
err = shexec.SendRunPacketAndRunData(ctx, msh.ServerProc.Input, runPacket)
if err != nil {
@ -1776,6 +1849,8 @@ func RunCommand(ctx context.Context, sessionId string, screenId string, remotePt
}
return nil, nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk))
}
// command is now successfully runnning
status := sstore.CmdStatusRunning
if runPacket.Detached {
status = sstore.CmdStatusDetached
@ -1797,46 +1872,24 @@ func RunCommand(ctx context.Context, sessionId string, screenId string, remotePt
RunOut: nil,
RtnState: runPacket.ReturnState,
}
if !rcOpts.NoCreateCmdPtyFile {
err = sstore.CreateCmdPtyFile(ctx, cmd.ScreenId, cmd.LineId, cmd.TermOpts.MaxPtySize)
if err != nil {
// TODO the cmd is running, so this is a tricky error to handle
return nil, nil, fmt.Errorf("cannot create local ptyout file for running command: %v", err)
}
msh.AddRunningCmd(rct)
}
msh.AddRunningCmd(RunCmdType{
SessionId: sessionId,
ScreenId: screenId,
RemotePtr: remotePtr,
RunPacket: runPacket,
})
go pushNumRunningCmdsUpdate(&runPacket.CK, 1)
return cmd, func() { removeCmdWait(runPacket.CK) }, nil
}
func (msh *MShellProc) AddWaitingCmd(rct RunCmdType) {
msh.Lock.Lock()
defer msh.Lock.Unlock()
msh.WaitingCmds = append(msh.WaitingCmds, rct)
}
func (msh *MShellProc) reExecSingle(rct RunCmdType) {
// TODO fixme
ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second)
defer cancelFn()
_, callback, _ := RunCommand(ctx, rct.SessionId, rct.ScreenId, rct.RemotePtr, rct.RunPacket)
if callback != nil {
defer callback()
}
}
func (msh *MShellProc) ReExecWaitingCmds() {
msh.Lock.Lock()
defer msh.Lock.Unlock()
for len(msh.WaitingCmds) > 0 {
rct := msh.WaitingCmds[0]
go msh.reExecSingle(rct)
if rct.RunPacket.ReturnState {
break
}
}
if len(msh.WaitingCmds) == 0 {
msh.WaitingCmds = nil
}
}
func (msh *MShellProc) AddRunningCmd(rct RunCmdType) {
msh.Lock.Lock()
defer msh.Lock.Unlock()
@ -1937,10 +1990,10 @@ func (msh *MShellProc) notifyHangups_nolock() {
}
update := &sstore.ModelUpdate{Cmd: cmd}
sstore.MainBus.SendScreenUpdate(ck.GetGroupId(), update)
go pushNumRunningCmdsUpdate(&ck, -1)
}
msh.RunningCmds = make(map[base.CommandKey]RunCmdType)
msh.PendingStateCmds = make(map[pendingStateKey]base.CommandKey)
msh.WaitingCmds = nil
}
func (msh *MShellProc) handleCmdDonePacket(donePk *packet.CmdDonePacketType) {
@ -2011,6 +2064,8 @@ func (msh *MShellProc) handleCmdDonePacket(donePk *packet.CmdDonePacketType) {
// fall-through (nothing to do)
}
}
go pushNumRunningCmdsUpdate(&donePk.CK, -1)
sstore.MainBus.SendUpdate(update)
return
}
@ -2044,6 +2099,7 @@ func (msh *MShellProc) handleCmdFinalPacket(finalPk *packet.CmdFinalPacketType)
if screen != nil {
update.Screens = []*sstore.ScreenType{screen}
}
go pushNumRunningCmdsUpdate(&finalPk.CK, -1)
sstore.MainBus.SendUpdate(update)
}
@ -2057,7 +2113,11 @@ func (msh *MShellProc) handleCmdErrorPacket(errPk *packet.CmdErrorPacketType) {
return
}
func (msh *MShellProc) handleDataPacket(dataPk *packet.DataPacketType, dataPosMap map[base.CommandKey]int64) {
func (msh *MShellProc) ResetDataPos(ck base.CommandKey) {
msh.DataPosMap.Delete(ck)
}
func (msh *MShellProc) handleDataPacket(dataPk *packet.DataPacketType, dataPosMap *utilfn.SyncMap[base.CommandKey, int64]) {
realData, err := base64.StdEncoding.DecodeString(dataPk.Data64)
if err != nil {
ack := makeDataAckPacket(dataPk.CK, dataPk.FdNum, 0, err)
@ -2066,7 +2126,7 @@ func (msh *MShellProc) handleDataPacket(dataPk *packet.DataPacketType, dataPosMa
}
var ack *packet.DataAckPacketType
if len(realData) > 0 {
dataPos := dataPosMap[dataPk.CK]
dataPos := dataPosMap.Get(dataPk.CK)
rcmd := msh.GetRunningCmd(dataPk.CK)
update, err := sstore.AppendToCmdPtyBlob(context.Background(), rcmd.ScreenId, dataPk.CK.GetCmdId(), realData, dataPos)
if err != nil {
@ -2074,7 +2134,7 @@ func (msh *MShellProc) handleDataPacket(dataPk *packet.DataPacketType, dataPosMa
} else {
ack = makeDataAckPacket(dataPk.CK, dataPk.FdNum, len(realData), nil)
}
dataPosMap[dataPk.CK] += int64(len(realData))
utilfn.IncSyncMap(dataPosMap, dataPk.CK, int64(len(realData)))
if update != nil {
sstore.MainBus.SendScreenUpdate(dataPk.CK.GetGroupId(), update)
}
@ -2085,7 +2145,7 @@ func (msh *MShellProc) handleDataPacket(dataPk *packet.DataPacketType, dataPosMa
// log.Printf("data %s fd=%d len=%d eof=%v err=%v\n", dataPk.CK, dataPk.FdNum, len(realData), dataPk.Eof, dataPk.Error)
}
func (msh *MShellProc) makeHandleDataPacketClosure(dataPk *packet.DataPacketType, dataPosMap map[base.CommandKey]int64) func() {
func (msh *MShellProc) makeHandleDataPacketClosure(dataPk *packet.DataPacketType, dataPosMap *utilfn.SyncMap[base.CommandKey, int64]) func() {
return func() {
msh.handleDataPacket(dataPk, dataPosMap)
}
@ -2124,12 +2184,10 @@ func (msh *MShellProc) ProcessPackets() {
go sendScreenUpdates(screens)
}
})
// TODO need to clean dataPosMap
dataPosMap := make(map[base.CommandKey]int64)
for pk := range msh.ServerProc.Output.MainCh {
if pk.GetType() == packet.DataPacketStr {
dataPk := pk.(*packet.DataPacketType)
runCmdUpdateFn(dataPk.CK, msh.makeHandleDataPacketClosure(dataPk, dataPosMap))
runCmdUpdateFn(dataPk.CK, msh.makeHandleDataPacketClosure(dataPk, msh.DataPosMap))
go pushStatusIndicatorUpdate(&dataPk.CK, sstore.StatusIndicatorLevel_Output)
continue
}
@ -2411,5 +2469,13 @@ func (msh *MShellProc) GetDisplayName() string {
// Identify the screen for a given CommandKey and push the given status indicator update for that screen
func pushStatusIndicatorUpdate(ck *base.CommandKey, level sstore.StatusIndicatorLevel) {
screenId := ck.GetGroupId()
sstore.SetStatusIndicatorLevel(context.Background(), screenId, level, false)
err := sstore.SetStatusIndicatorLevel(context.Background(), screenId, level, false)
if err != nil {
log.Printf("error setting status indicator level: %v\n", err)
}
}
func pushNumRunningCmdsUpdate(ck *base.CommandKey, delta int) {
screenId := ck.GetGroupId()
sstore.IncrementNumRunningCmds(screenId, delta)
}

View File

@ -35,7 +35,7 @@ const WaveLockFile = "waveterm.lock"
const WaveDirName = ".waveterm" // must match emain.ts
const WaveDevDirName = ".waveterm-dev" // must match emain.ts
const WaveAppPathVarName = "WAVETERM_APP_PATH"
const WaveVersion = "v0.6.0"
const WaveVersion = "v0.6.1"
const WaveAuthKeyFileName = "waveterm.authkey"
const MShellVersion = "v0.4.0"

View File

@ -763,30 +763,38 @@ func GetScreenById(ctx context.Context, screenId string) (*ScreenType, error) {
})
}
// special "E" returns last unarchived line, "EA" returns last line (even if archived)
func FindLineIdByArg(ctx context.Context, screenId string, lineArg string) (string, error) {
var lineId string
txErr := WithTx(ctx, func(tx *TxWrap) error {
return WithTxRtn(ctx, func(tx *TxWrap) (string, error) {
if lineArg == "E" {
query := `SELECT lineid FROM line WHERE screenid = ? AND NOT archived ORDER BY linenum DESC LIMIT 1`
lineId := tx.GetString(query, screenId)
return lineId, nil
}
if lineArg == "EA" {
query := `SELECT lineid FROM line WHERE screenid = ? ORDER BY linenum DESC LIMIT 1`
lineId := tx.GetString(query, screenId)
return lineId, nil
}
lineNum, err := strconv.Atoi(lineArg)
if err == nil {
// valid linenum
query := `SELECT lineid FROM line WHERE screenid = ? AND linenum = ?`
lineId = tx.GetString(query, screenId, lineNum)
lineId := tx.GetString(query, screenId, lineNum)
return lineId, nil
} else if len(lineArg) == 8 {
// prefix id string match
query := `SELECT lineid FROM line WHERE screenid = ? AND substr(lineid, 1, 8) = ?`
lineId = tx.GetString(query, screenId, lineArg)
lineId := tx.GetString(query, screenId, lineArg)
return lineId, nil
} else {
// id match
query := `SELECT lineid FROM line WHERE screenid = ? AND lineid = ?`
lineId = tx.GetString(query, screenId, lineArg)
}
return nil
})
if txErr != nil {
return "", txErr
}
lineId := tx.GetString(query, screenId, lineArg)
return lineId, nil
}
})
}
func GetLineCmdByLineId(ctx context.Context, screenId string, lineId string) (*LineType, *CmdType, error) {
return WithTxRtn3(ctx, func(tx *TxWrap) (*LineType, *CmdType, error) {
@ -836,8 +844,8 @@ func InsertLine(ctx context.Context, line *LineType, cmd *CmdType) error {
cmd.OrigTermOpts = cmd.TermOpts
cmdMap := cmd.ToMap()
query = `
INSERT INTO cmd ( screenid, lineid, remoteownerid, remoteid, remotename, cmdstr, rawcmdstr, festate, statebasehash, statediffhasharr, termopts, origtermopts, status, cmdpid, remotepid, donets, exitcode, durationms, rtnstate, runout, rtnbasehash, rtndiffhasharr)
VALUES (:screenid,:lineid,:remoteownerid,:remoteid,:remotename,:cmdstr,:rawcmdstr,:festate,:statebasehash,:statediffhasharr,:termopts,:origtermopts,:status,:cmdpid,:remotepid,:donets,:exitcode,:durationms,:rtnstate,:runout,:rtnbasehash,:rtndiffhasharr)
INSERT INTO cmd ( screenid, lineid, remoteownerid, remoteid, remotename, cmdstr, rawcmdstr, festate, statebasehash, statediffhasharr, termopts, origtermopts, status, cmdpid, remotepid, donets, restartts, exitcode, durationms, rtnstate, runout, rtnbasehash, rtndiffhasharr)
VALUES (:screenid,:lineid,:remoteownerid,:remoteid,:remotename,:cmdstr,:rawcmdstr,:festate,:statebasehash,:statediffhasharr,:termopts,:origtermopts,:status,:cmdpid,:remotepid,:donets,:restartts,:exitcode,:durationms,:rtnstate,:runout,:rtnbasehash,:rtndiffhasharr)
`
tx.NamedExec(query, cmdMap)
}
@ -879,6 +887,20 @@ func UpdateWithUpdateOpenAICmdInfoPacket(ctx context.Context, screenId string, m
return UpdateWithCurrentOpenAICmdInfoChat(screenId)
}
func UpdateCmdForRestart(ctx context.Context, ck base.CommandKey, ts int64, cmdPid int, remotePid int, termOpts *TermOpts) error {
return WithTx(ctx, func(tx *TxWrap) error {
query := `UPDATE cmd
SET restartts = ?, status = ?, exitcode = ?, cmdpid = ?, remotepid = ?, durationms = ?, termopts = ?, origtermopts = ?
WHERE screenid = ? AND lineid = ?`
tx.Exec(query, ts, CmdStatusRunning, 0, cmdPid, remotePid, 0, quickJson(termOpts), quickJson(termOpts), ck.GetGroupId(), lineIdFromCK(ck))
query = `UPDATE history
SET ts = ?, status = ?, exitcode = ?, durationms = ?
WHERE screenid = ? AND lineid = ?`
tx.Exec(query, ts, CmdStatusRunning, 0, 0, ck.GetGroupId(), lineIdFromCK(ck))
return nil
})
}
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")
@ -922,7 +944,12 @@ func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.C
} else {
indicator = StatusIndicatorLevel_Error
}
SetStatusIndicatorLevel_Update(ctx, update, screenId, indicator, false)
err := SetStatusIndicatorLevel_Update(ctx, update, screenId, indicator, false)
if err != nil {
// This is not a fatal error, so just log it
log.Printf("error setting status indicator level after done packet: %v\n", err)
}
return update, nil
}
@ -1080,7 +1107,11 @@ func SwitchScreenById(ctx context.Context, sessionId string, screenId string) (*
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(screenId).Messages
// Clear any previous status indicator for this screen
ResetStatusIndicator_Update(update, screenId)
err := ResetStatusIndicator_Update(update, screenId)
if err != nil {
// This is not a fatal error, so just log it
log.Printf("error resetting status indicator when switching screens: %v\n", err)
}
}
return update, nil
}
@ -1490,12 +1521,16 @@ func ArchiveScreenLines(ctx context.Context, screenId string) (*ModelUpdate, err
func DeleteScreenLines(ctx context.Context, screenId string) (*ModelUpdate, error) {
var lineIds []string
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `SELECT lineid FROM line WHERE screenid = ?`
lineIds = tx.SelectStrings(query, screenId)
query = `DELETE FROM line WHERE screenid = ?`
tx.Exec(query, screenId)
query = `UPDATE history SET lineid = '', linenum = 0 WHERE screenid = ?`
tx.Exec(query, screenId)
query := `SELECT lineid FROM line
WHERE screenid = ?
AND NOT EXISTS (SELECT lineid FROM cmd c WHERE c.screenid = ? AND c.lineid = line.lineid AND c.status IN ('running', 'detached'))`
lineIds = tx.SelectStrings(query, screenId, screenId)
query = `DELETE FROM line
WHERE screenid = ? AND lineid IN (SELECT value FROM json_each(?))`
tx.Exec(query, screenId, quickJsonArr(lineIds))
query = `UPDATE history SET lineid = '', linenum = 0
WHERE screenid = ? AND lineid IN (SELECT value FROM json_each(?))`
tx.Exec(query, screenId, quickJsonArr(lineIds))
return nil
})
if txErr != nil {
@ -2091,6 +2126,19 @@ func SetLineArchivedById(ctx context.Context, screenId string, lineId string, ar
return txErr
}
func GetScreenSelectedLineId(ctx context.Context, screenId string) (string, error) {
return WithTxRtn(ctx, func(tx *TxWrap) (string, error) {
query := `SELECT selectedline FROM screen WHERE screenid = ?`
sline := tx.GetInt(query, screenId)
if sline <= 0 {
return "", nil
}
query = `SELECT lineid FROM line WHERE screenid = ? AND linenum = ?`
lineId := tx.GetString(query, screenId, sline)
return lineId, nil
})
}
// returns updated screen (only if updated)
func FixupScreenSelectedLine(ctx context.Context, screenId string) (*ScreenType, error) {
return WithTxRtn(ctx, func(tx *TxWrap) (*ScreenType, error) {

View File

@ -16,6 +16,7 @@ import (
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/waveshell/pkg/cirfile"
"github.com/wavetermdev/waveterm/waveshell/pkg/shexec"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
)
@ -39,6 +40,27 @@ func StatCmdPtyFile(ctx context.Context, screenId string, lineId string) (*cirfi
return cirfile.StatCirFile(ctx, ptyOutFileName)
}
func ClearCmdPtyFile(ctx context.Context, screenId string, lineId string) error {
ptyOutFileName, err := scbase.PtyOutFile(screenId, lineId)
if err != nil {
return err
}
stat, err := cirfile.StatCirFile(ctx, ptyOutFileName)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
os.Remove(ptyOutFileName) // ignore error
var maxSize int64 = shexec.DefaultMaxPtySize
if stat != nil {
maxSize = stat.MaxSize
}
err = CreateCmdPtyFile(ctx, screenId, lineId, maxSize)
if err != nil {
return err
}
return nil
}
func AppendToCmdPtyBlob(ctx context.Context, screenId string, lineId string, data []byte, pos int64) (*PtyDataUpdate, error) {
if screenId == "" {
return nil, fmt.Errorf("cannot append to PtyBlob, screenid is not set")

View File

@ -153,13 +153,15 @@ func ScreenMemSetCmdInputText(screenId string, sp utilfn.StrWithPos, seqNum int)
ScreenMemStore[screenId].CmdInputSeqNum = seqNum
}
func ScreenMemSetNumRunningCommands(screenId string, num int) {
func ScreenMemIncrementNumRunningCommands(screenId string, delta int) int {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
ScreenMemStore[screenId].NumRunningCommands = num
newNum := ScreenMemStore[screenId].NumRunningCommands + delta
ScreenMemStore[screenId].NumRunningCommands = newNum
return newNum
}
// If the new indicator level is higher than the current indicator, update the current indicator. Returns the new indicator level.

View File

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

View File

@ -1118,13 +1118,15 @@ type CmdType struct {
Status string `json:"status"`
CmdPid int `json:"cmdpid"`
RemotePid int `json:"remotepid"`
RestartTs int64 `json:"restartts,omitempty"`
DoneTs int64 `json:"donets"`
ExitCode int `json:"exitcode"`
DurationMs int `json:"durationms"`
RunOut []packet.PacketType `json:"runout,omitempty"`
RtnState bool `json:"rtnstate,omitempty"`
RtnStatePtr ShellStatePtr `json:"rtnstateptr,omitempty"`
Remove bool `json:"remove,omitempty"`
Remove bool `json:"remove,omitempty"` // not persisted to DB
Restarted bool `json:"restarted,omitempty"` // not persisted to DB
}
func (r *RemoteType) ToMap() map[string]interface{} {
@ -1189,6 +1191,7 @@ func (cmd *CmdType) ToMap() map[string]interface{} {
rtn["status"] = cmd.Status
rtn["cmdpid"] = cmd.CmdPid
rtn["remotepid"] = cmd.RemotePid
rtn["restartts"] = cmd.RestartTs
rtn["donets"] = cmd.DoneTs
rtn["exitcode"] = cmd.ExitCode
rtn["durationms"] = cmd.DurationMs
@ -1216,6 +1219,7 @@ func (cmd *CmdType) FromMap(m map[string]interface{}) bool {
quickSetInt(&cmd.CmdPid, m, "cmdpid")
quickSetInt(&cmd.RemotePid, m, "remotepid")
quickSetInt64(&cmd.DoneTs, m, "donets")
quickSetInt64(&cmd.RestartTs, m, "restartts")
quickSetInt(&cmd.ExitCode, m, "exitcode")
quickSetInt(&cmd.DurationMs, m, "durationms")
quickSetJson(&cmd.RunOut, m, "runout")
@ -1474,7 +1478,6 @@ func SetReleaseInfo(ctx context.Context, releaseInfo ReleaseInfoType) error {
// Sets the in-memory status indicator for the given screenId to the given value and adds it to the ModelUpdate. By default, the active screen will be ignored when updating status. To force a status update for the active screen, set force=true.
func SetStatusIndicatorLevel_Update(ctx context.Context, update *ModelUpdate, screenId string, level StatusIndicatorLevel, force bool) error {
var newStatus StatusIndicatorLevel
if force {
// Force the update and set the new status to the given level, regardless of the current status or the active screen
ScreenMemSetIndicatorLevel(screenId, level)
@ -1511,14 +1514,14 @@ func SetStatusIndicatorLevel_Update(ctx context.Context, update *ModelUpdate, sc
}
// Sets the in-memory status indicator for the given screenId to the given value and pushes the new value to the FE
func SetStatusIndicatorLevel(ctx context.Context, screenId string, level StatusIndicatorLevel, force bool) {
func SetStatusIndicatorLevel(ctx context.Context, screenId string, level StatusIndicatorLevel, force bool) error {
update := &ModelUpdate{}
err := SetStatusIndicatorLevel_Update(ctx, update, screenId, level, false)
if err != nil {
log.Printf("error setting status indicator level: %v\n", err)
return
return err
}
MainBus.SendUpdate(update)
return nil
}
// Resets the in-memory status indicator for the given screenId to StatusIndicatorLevel_None and adds it to the ModelUpdate
@ -1528,7 +1531,23 @@ func ResetStatusIndicator_Update(update *ModelUpdate, screenId string) error {
}
// Resets the in-memory status indicator for the given screenId to StatusIndicatorLevel_None and pushes the new value to the FE
func ResetStatusIndicator(screenId string) {
func ResetStatusIndicator(screenId string) error {
// We do not need to set context when resetting the status indicator because we will not need to call the DB
SetStatusIndicatorLevel(context.TODO(), screenId, StatusIndicatorLevel_None, true)
return SetStatusIndicatorLevel(context.TODO(), screenId, StatusIndicatorLevel_None, true)
}
func IncrementNumRunningCmds_Update(update *ModelUpdate, screenId string, delta int) {
newNum := ScreenMemIncrementNumRunningCommands(screenId, delta)
log.Printf("IncrementNumRunningCmds_Update: screenId=%s, newNum=%d\n", screenId, newNum)
update.ScreenNumRunningCommands = &ScreenNumRunningCommandsType{
ScreenId: screenId,
Num: newNum,
}
}
func IncrementNumRunningCmds(screenId string, delta int) {
log.Printf("IncrementNumRunningCmds: screenId=%s, delta=%d\n", screenId, delta)
update := &ModelUpdate{}
IncrementNumRunningCmds_Update(update, screenId, delta)
MainBus.SendUpdate(update)
}

View File

@ -67,6 +67,7 @@ type ModelUpdate struct {
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
ScreenStatusIndicator *ScreenStatusIndicatorType `json:"screenstatusindicator,omitempty"`
ScreenNumRunningCommands *ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
UserInputRequest *UserInputRequestType `json:"userinputrequest,omitempty"`
}
@ -286,6 +287,11 @@ type ScreenStatusIndicatorType struct {
Status StatusIndicatorLevel `json:"status"`
}
type ScreenNumRunningCommandsType struct {
ScreenId string `json:"screenid"`
Num int `json:"num"`
}
func (bus *UpdateBus) registerUserInputChannel() (string, chan *UserInputResponseType) {
bus.Lock.Lock()
defer bus.Lock.Unlock()