merge main

This commit is contained in:
Red Adaya 2024-04-10 09:09:34 +08:00
commit b4d04c3a57
52 changed files with 1598 additions and 1257 deletions

View File

@ -42,16 +42,19 @@
--app-bg-color: black; --app-bg-color: black;
--app-accent-color: rgb(88, 193, 66); --app-accent-color: rgb(88, 193, 66);
--app-accent-bg-color: rgba(88, 193, 66, 0.25); --app-accent-bg-color: rgba(88, 193, 66, 0.25);
--app-accent-bg-darker-color: rgba(88, 193, 66, 0.5);
--app-text-color: rgb(211, 215, 207); --app-text-color: rgb(211, 215, 207);
--app-text-primary-color: rgb(255, 255, 255); --app-text-primary-color: rgb(255, 255, 255);
--app-text-secondary-color: rgb(195, 200, 194); --app-text-secondary-color: rgb(195, 200, 194);
--app-text-disabled-color: rgb(173, 173, 173); --app-text-disabled-color: rgb(173, 173, 173);
--app-text-bg-color: rgb(23, 23, 23);
--app-text-bg-disabled-color: rgb(51, 51, 51);
--app-border-color: rgb(51, 51, 51); --app-border-color: rgb(51, 51, 51);
--app-maincontent-bg-color: #333; --app-maincontent-bg-color: rgb(51, 51, 51);
--app-panel-bg-color: rgba(21, 23, 21, 1); --app-panel-bg-color: rgba(21, 23, 21, 1);
--app-panel-bg-color-dev: rgb(21, 23, 48); --app-panel-bg-color-dev: rgb(21, 23, 48);
--app-icon-color: rgb(139, 145, 138); --app-icon-color: rgb(139, 145, 138);
--app-icon-hover-color: #fff; --app-icon-hover-color: rgb(255, 255, 255);
--app-selected-mask-color: rgba(255, 255, 255, 0.06); --app-selected-mask-color: rgba(255, 255, 255, 0.06);
/* global status colors */ /* global status colors */
@ -122,18 +125,18 @@
/* line colors */ /* line colors */
--line-sidebar-message-color: rgb(196, 160, 0); --line-sidebar-message-color: rgb(196, 160, 0);
--line-background: rgba(21, 23, 21, 1); --line-background: var(--app-panel-bg-color);
--line-avatar-color: #eceeec; --line-avatar-color: rgb(236, 238, 236);
--line-text-color: rgb(211, 215, 207); --line-text-color: rgb(211, 215, 207);
--line-svg-fill-color: rgb(150, 152, 150); --line-svg-fill-color: rgb(150, 152, 150);
--line-svg-hover-fill-color: #eceeec; --line-svg-hover-fill-color: rgb(236, 238, 236);
--line-separator-color: rgb(126, 126, 126); --line-separator-color: rgb(126, 126, 126);
--line-error-color: var(--app-error-color); --line-error-color: var(--app-error-color);
--line-warning-color: var(--app-warning-color); --line-warning-color: var(--app-warning-color);
--line-base-soft-blue-color: #729fcf; --line-base-soft-blue-color: rgb(114, 159, 207);
--line-active-border-color: var(--app-accent-color); --line-active-border-color: var(--app-accent-color);
--line-selected-bg-color: rgba(255, 255, 255, 0.05); --line-selected-bg-color: rgba(255, 255, 255, 0.05);
--line-selected-border-left-color: #777777; --line-selected-border-left-color: rgb(119, 119, 119);
--line-selected-error-border-color: rgba(204, 0, 0, 0.8); --line-selected-error-border-color: rgba(204, 0, 0, 0.8);
--line-selected-error-bg-color: rgb(19, 4, 3); --line-selected-error-bg-color: rgb(19, 4, 3);
--line-error-bg-color: rgba(200, 0, 0, 0.1); --line-error-bg-color: rgba(200, 0, 0, 0.1);
@ -152,22 +155,21 @@
/* table colors */ /* table colors */
--table-border-color: rgba(241, 246, 243, 0.15); --table-border-color: rgba(241, 246, 243, 0.15);
--table-thead-border-top-color: rgba(250, 250, 250, 0.1); --table-thead-border-top-color: rgba(250, 250, 250, 0.1);
--table-thead-bright-border-color: #ccc; --table-thead-bright-border-color: rgb(204, 204, 204);
--table-thead-bg-color: rgba(250, 250, 250, 0.02); --table-thead-bg-color: rgba(250, 250, 250, 0.02);
--table-tr-border-bottom-color: rgba(241, 246, 243, 0.15); --table-tr-border-bottom-color: rgba(241, 246, 243, 0.15);
--table-tr-hover-bg-color: rgba(255, 255, 255, 0.06); --table-tr-hover-bg-color: rgba(255, 255, 255, 0.06);
--table-tr-selected-bg-color: #222; --table-tr-selected-bg-color: rgb(34, 34, 34);
--table-tr-selected-hover-bg-color: #333; --table-tr-selected-hover-bg-color: var(--app-maincontent-bg-color);
/* cmdinput colors */ /* cmdinput colors */
--cmdinput-textarea-bg-color: #171717; --cmdinput-bg-color: var(--app-text-bg-color);
--cmdinput-text-error-color: var(--term-red); --cmdinput-text-error-color: var(--term-red);
--cmdinput-history-item-error-color: var(--term-bright-red); --cmdinput-history-item-error-color: var(--term-bright-red);
--cmdinput-history-item-selected-error-color: var(--term-bright-red); --cmdinput-history-item-selected-error-color: var(--term-bright-red);
--cmdinput-button-bg-color: rgb(88, 193, 66); --cmdinput-button-bg-color: var(--tab-green);
--cmdinput-comment-button-bg-color: rgb(57, 113, 255); --cmdinput-disabled-bg-color: var(--app-text-bg-disabled-color);
--cmdinput-disabled-icon-color: rgb(76, 81, 75, 1); --cmdinput-history-bg-color: var(--app-bg-color);
--cmdinput-history-bg-color: rgb(21, 23, 21, 1);
/* screen view color */ /* screen view color */
--screen-view-text-caption-color: rgb(139, 145, 138); --screen-view-text-caption-color: rgb(139, 145, 138);

View File

@ -5,20 +5,20 @@
@import url("./term-light.css"); @import url("./term-light.css");
:root { :root {
--app-bg-color: #fefefe; --app-bg-color: rgb(254, 254, 254);
--app-accent-color: rgb(75, 166, 57); --app-accent-color: rgb(75, 166, 57);
--app-accent-bg-color: rgba(75, 166, 57, 0.2); --app-accent-bg-color: rgba(75, 166, 57, 0.2);
--app-text-color: #000; --app-text-color: rgb(0, 0, 0);
--app-text-primary-color: rgb(0, 0, 0, 0.9); --app-text-primary-color: rgb(0, 0, 0, 0.9);
--app-text-secondary-color: rgb(0, 0, 0, 0.7); --app-text-secondary-color: rgb(0, 0, 0, 0.7);
--app-border-color: rgb(139 145 138); --app-border-color: rgb(139 145 138);
--app-panel-bg-color: #e0e0e0; --app-panel-bg-color: rgb(224, 224, 224);
--app-panel-bg-color-dev: #e0e0e0; --app-panel-bg-color-dev: rgb(224, 224, 224);
--app-icon-color: rgb(80, 80, 80); --app-icon-color: rgb(110, 110, 110);
--app-icon-hover-color: rgb(100, 100, 100); --app-icon-hover-color: rgb(80, 80, 80);
--app-selected-mask-color: rgba(0, 0, 0, 0.06); --app-selected-mask-color: rgba(0, 0, 0, 0.06);
--input-bg-color: #eeeeee; --input-bg-color: rgb(238, 238, 238);
/* tab color */ /* tab color */
--tab-white: rgb(0, 0, 0, 0.6); --tab-white: rgb(0, 0, 0, 0.6);
@ -30,8 +30,8 @@
--table-thead-bg-color: rgba(250, 250, 250, 0.15); --table-thead-bg-color: rgba(250, 250, 250, 0.15);
--table-tr-border-bottom-color: rgba(0, 0, 0, 0.15); --table-tr-border-bottom-color: rgba(0, 0, 0, 0.15);
--table-tr-hover-bg-color: rgba(0, 0, 0, 0.15); --table-tr-hover-bg-color: rgba(0, 0, 0, 0.15);
--table-tr-selected-bg-color: #dddddd; --table-tr-selected-bg-color: rgb(221, 221, 221);
--table-tr-selected-hover-bg-color: #cccccc; --table-tr-selected-hover-bg-color: rgb(204, 204, 204);
/* form colors */ /* form colors */
--form-element-border-color: rgba(0, 0, 0, 0.3); --form-element-border-color: rgba(0, 0, 0, 0.3);
@ -41,8 +41,8 @@
--form-element-label-color: rgba(0, 0, 0, 0.6); --form-element-label-color: rgba(0, 0, 0, 0.6);
--form-element-secondary-color: rgba(0, 0, 0, 0.09); --form-element-secondary-color: rgba(0, 0, 0, 0.09);
--form-element-icon-color: rgb(0, 0, 0, 0.6); --form-element-icon-color: rgb(0, 0, 0, 0.6);
--form-element-disabled-text-color: #b7b7b7; --form-element-disabled-text-color: rgb(183, 183, 183);
--form-element-placeholder-color: #b7b7b7; --form-element-placeholder-color: rgb(183, 183, 183);
--markdown-bg-color: rgb(0, 0, 0, 0.1); --markdown-bg-color: rgb(0, 0, 0, 0.1);
@ -50,8 +50,6 @@
--modal-header-bottom-border-color: rgba(0, 0, 0, 0.3); --modal-header-bottom-border-color: rgba(0, 0, 0, 0.3);
/* cmd input */ /* cmd input */
--cmdinput-textarea-bg-color: rgba(0, 0, 0, 0.1);
--cmdinput-textarea-border-color: var(--form-element-border-color);
/* scroll colors */ /* scroll colors */
--scrollbar-background-color: var(--app-bg-color); --scrollbar-background-color: var(--app-bg-color);
@ -64,15 +62,15 @@
--line-actions-inactive-color: rgba(0, 0, 0, 0.3); --line-actions-inactive-color: rgba(0, 0, 0, 0.3);
--line-actions-active-color: rgba(0, 0, 0, 1); --line-actions-active-color: rgba(0, 0, 0, 1);
--logo-button-hover-bg-color: #f0f0f0; --logo-button-hover-bg-color: rgb(240, 240, 240);
--xterm-viewport-border-color: rgba(0, 0, 0, 0.3); --xterm-viewport-border-color: rgba(0, 0, 0, 0.3);
--datepicker-cell-hover-bg-color: #f0f0f0; --datepicker-cell-hover-bg-color: rgb(240, 240, 240);
--datepicker-cell-other-text-color: rgba(0, 0, 0, 0.3); --datepicker-cell-other-text-color: rgba(0, 0, 0, 0.3);
--datepicker-header-fade-color: rgba(0, 0, 0, 0.4); --datepicker-header-fade-color: rgba(0, 0, 0, 0.4);
--datepicker-year-header-bg-color: #f5f5f5; --datepicker-year-header-bg-color: rgb(245, 245, 245);
--datepicker-year-header-border-color: #dcdcdc; --datepicker-year-header-border-color: rgb(220, 220, 220);
/* toggle colors */ /* toggle colors */
--toggle-thumb-color: var(--app-bg-color); --toggle-thumb-color: var(--app-bg-color);

View File

@ -64,3 +64,7 @@ export enum StatusIndicatorLevel {
// matches packet.go // matches packet.go
export const ErrorCode_InvalidCwd = "ERRCWD"; export const ErrorCode_InvalidCwd = "ERRCWD";
export const InputAuxView_History = "history";
export const InputAuxView_Info = "info";
export const InputAuxView_AIChat = "aichat";

View File

@ -6,7 +6,7 @@ import "./button.less";
interface ButtonProps { interface ButtonProps {
children: React.ReactNode; children: React.ReactNode;
onClick?: () => void; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean; disabled?: boolean;
leftIcon?: React.ReactNode; leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode; rightIcon?: React.ReactNode;
@ -14,6 +14,7 @@ interface ButtonProps {
autoFocus?: boolean; autoFocus?: boolean;
className?: string; className?: string;
termInline?: boolean; termInline?: boolean;
title?: string;
} }
class Button extends React.Component<ButtonProps> { class Button extends React.Component<ButtonProps> {
@ -23,14 +24,14 @@ class Button extends React.Component<ButtonProps> {
}; };
@boundMethod @boundMethod
handleClick() { handleClick(e) {
if (this.props.onClick && !this.props.disabled) { if (this.props.onClick && !this.props.disabled) {
this.props.onClick(); this.props.onClick(e);
} }
} }
render() { render() {
const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className } = this.props; const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className, title } = this.props;
return ( return (
<button <button
@ -39,6 +40,7 @@ class Button extends React.Component<ButtonProps> {
disabled={disabled} disabled={disabled}
style={style} style={style}
autoFocus={autoFocus} autoFocus={autoFocus}
title={title}
> >
{leftIcon && <span className="icon-left">{leftIcon}</span>} {leftIcon && <span className="icon-left">{leftIcon}</span>}
{children} {children}

View File

@ -0,0 +1,7 @@
.copy-button {
padding: 5px 5px;
.fa-check {
color: var(--app-success-color);
}
}

View File

@ -0,0 +1,51 @@
import * as React from "react";
import { Button } from "./button";
import { boundMethod } from "autobind-decorator";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import "./copybutton.less";
type CopyButtonProps = {
title: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
@mobxReact.observer
class CopyButton extends React.Component<CopyButtonProps, {}> {
isCopied: OV<boolean> = mobx.observable.box(false, { name: "isCopied" });
@boundMethod
handleOnClick(e: React.MouseEvent<HTMLButtonElement>) {
if (this.isCopied.get()) {
return;
}
mobx.action(() => {
this.isCopied.set(true);
})();
setTimeout(() => {
mobx.action(() => {
this.isCopied.set(false);
})();
}, 2000);
if (this.props.onClick) {
this.props.onClick(e);
}
}
render() {
const { title, onClick } = this.props;
const isCopied = this.isCopied.get();
return (
<Button onClick={this.handleOnClick} className="copy-button secondary ghost" title={title}>
{isCopied ? (
<i className="fa-sharp fa-solid fa-check"></i>
) : (
<i className="fa-sharp fa-solid fa-copy"></i>
)}
</Button>
);
}
}
export { CopyButton };

View File

@ -19,3 +19,4 @@ export { Tooltip } from "./tooltip";
export { TabIcon } from "./tabicon"; export { TabIcon } from "./tabicon";
export { DatePicker } from "./datepicker"; export { DatePicker } from "./datepicker";
export { StyleBlock } from "./styleblock"; export { StyleBlock } from "./styleblock";
export { CopyButton } from "./copybutton";

View File

@ -7,8 +7,10 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import cn from "classnames"; import cn from "classnames";
import { GlobalModel } from "@/models"; import { GlobalModel } from "@/models";
import { v4 as uuidv4 } from "uuid";
import "./markdown.less"; import "./markdown.less";
import { boundMethod } from "autobind-decorator";
function LinkRenderer(props: any): any { function LinkRenderer(props: any): any {
let newUrl = "https://extern?" + encodeURIComponent(props.href); let newUrl = "https://extern?" + encodeURIComponent(props.href);
@ -28,14 +30,17 @@ function CodeRenderer(props: any): any {
} }
@mobxReact.observer @mobxReact.observer
class CodeBlockMarkdown extends React.Component<{ children: React.ReactNode; codeSelectSelectedIndex?: number }, {}> { class CodeBlockMarkdown extends React.Component<
{ children: React.ReactNode; codeSelectSelectedIndex?: number; uuid: string },
{}
> {
blockIndex: number; blockIndex: number;
blockRef: React.RefObject<HTMLPreElement>; blockRef: React.RefObject<HTMLPreElement>;
constructor(props) { constructor(props) {
super(props); super(props);
this.blockRef = React.createRef(); this.blockRef = React.createRef();
this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef); this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef, this.props.uuid);
} }
render() { render() {
@ -62,9 +67,21 @@ class Markdown extends React.Component<
{ text: string; style?: any; extraClassName?: string; codeSelect?: boolean }, { text: string; style?: any; extraClassName?: string; codeSelect?: boolean },
{} {}
> { > {
CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number): any { curUuid: string;
constructor(props) {
super(props);
this.curUuid = uuidv4();
}
@boundMethod
CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number, curUuid: string): any {
if (codeSelect) { if (codeSelect) {
return <CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex}>{props.children}</CodeBlockMarkdown>; return (
<CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex} uuid={curUuid}>
{props.children}
</CodeBlockMarkdown>
);
} else { } else {
const clickHandler = (e: React.MouseEvent<HTMLElement>) => { const clickHandler = (e: React.MouseEvent<HTMLElement>) => {
let blockText = (e.target as HTMLElement).innerText; let blockText = (e.target as HTMLElement).innerText;
@ -90,7 +107,7 @@ class Markdown extends React.Component<
h5: (props) => HeaderRenderer(props, 5), h5: (props) => HeaderRenderer(props, 5),
h6: (props) => HeaderRenderer(props, 6), h6: (props) => HeaderRenderer(props, 6),
code: (props) => CodeRenderer(props), code: (props) => CodeRenderer(props),
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex), pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex, this.curUuid),
}; };
return ( return (
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}> <div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>

View File

@ -312,7 +312,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
endDecoration: ( endDecoration: (
<InputDecoration> <InputDecoration>
<Tooltip <Tooltip
message={`(Required) The path to your ssh key file.`} message={`(Required) The path to your ssh private key file.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />} icon={<i className="fa-sharp fa-regular fa-circle-question" />}
> >
<i className="fa-sharp fa-regular fa-circle-question" /> <i className="fa-sharp fa-regular fa-circle-question" />

View File

@ -338,7 +338,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
endDecoration: ( endDecoration: (
<InputDecoration> <InputDecoration>
<Tooltip <Tooltip
message={`(Required) The path to your ssh key file.`} message={`(Required) The path to your ssh private key file.`}
icon={<i className="fa-sharp fa-regular fa-circle-question" />} icon={<i className="fa-sharp fa-regular fa-circle-question" />}
> >
<i className="fa-sharp fa-regular fa-circle-question" /> <i className="fa-sharp fa-regular fa-circle-question" />

View File

@ -357,6 +357,10 @@
cursor: pointer; cursor: pointer;
} }
.wave-button {
padding: 5px 5px;
}
visibility: hidden; visibility: hidden;
} }

View File

@ -14,7 +14,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import { Line } from "@/app/line/linecomps"; import { Line } from "@/app/line/linecomps";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
import { TextField, Dropdown, Button, DatePicker } from "@/elements"; import { TextField, Dropdown, Button, CopyButton } from "@/elements";
import { ReactComponent as ChevronLeftIcon } from "@/assets/icons/history/chevron-left.svg"; import { ReactComponent as ChevronLeftIcon } from "@/assets/icons/history/chevron-left.svg";
import { ReactComponent as ChevronRightIcon } from "@/assets/icons/history/chevron-right.svg"; import { ReactComponent as ChevronRightIcon } from "@/assets/icons/history/chevron-right.svg";
@ -22,8 +22,6 @@ import { ReactComponent as RightIcon } from "@/assets/icons/history/right.svg";
import { ReactComponent as SearchIcon } from "@/assets/icons/history/search.svg"; import { ReactComponent as SearchIcon } from "@/assets/icons/history/search.svg";
import { ReactComponent as TrashIcon } from "@/assets/icons/trash.svg"; import { ReactComponent as TrashIcon } from "@/assets/icons/trash.svg";
import { ReactComponent as CheckedCheckbox } from "@/assets/icons/checked-checkbox.svg"; import { ReactComponent as CheckedCheckbox } from "@/assets/icons/checked-checkbox.svg";
import { ReactComponent as CheckIcon } from "@/assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "@/assets/icons/history/copy.svg";
import "./history.less"; import "./history.less";
import { MainView } from "../common/elements/mainview"; import { MainView } from "../common/elements/mainview";
@ -115,7 +113,6 @@ class HistoryCmdStr extends React.Component<
cmdstr: string; cmdstr: string;
onUse: () => void; onUse: () => void;
onCopy: () => void; onCopy: () => void;
isCopied: boolean;
fontSize: "normal" | "large"; fontSize: "normal" | "large";
limitHeight: boolean; limitHeight: boolean;
}, },
@ -138,24 +135,17 @@ class HistoryCmdStr extends React.Component<
} }
render() { render() {
const { isCopied, cmdstr, fontSize, limitHeight } = this.props; const { cmdstr, fontSize, limitHeight } = this.props;
return ( return (
<div className={cn("cmdstr-code", { "is-large": fontSize == "large" }, { "limit-height": limitHeight })}> <div className={cn("cmdstr-code", { "is-large": fontSize == "large" }, { "limit-height": limitHeight })}>
<If condition={isCopied}>
<div key="copied" className="copied-indicator">
<div>copied</div>
</div>
</If>
<div key="code" className="code-div"> <div key="code" className="code-div">
<code>{cmdstr}</code> <code>{cmdstr}</code>
</div> </div>
<div key="copy" className="actions-block"> <div key="copy" className="actions-block">
<div className="action-item" onClick={this.handleCopy} title="copy"> <CopyButton onClick={this.handleCopy} title="Copy" />
<CopyIcon className="icon" /> <Button className="secondary ghost" title="Use Command" onClick={this.handleUse}>
</div> <i className="fa-sharp fa-solid fa-play"></i>
<div key="use" className="action-item" title="Use Command" onClick={this.handleUse}> </Button>
<CheckIcon className="icon" />
</div>
</div> </div>
</div> </div>
); );
@ -190,7 +180,6 @@ class HistoryView extends React.Component<{}, {}> {
tableRszObs: ResizeObserver; tableRszObs: ResizeObserver;
sessionDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "sessionDropdownActive" }); sessionDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "sessionDropdownActive" });
remoteDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "remoteDropdownActive" }); remoteDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "remoteDropdownActive" });
copiedItemId: OV<string> = mobx.observable.box(null, { name: "copiedItemId" });
@boundMethod @boundMethod
handleNext() { handleNext() {
@ -377,14 +366,6 @@ class HistoryView extends React.Component<{}, {}> {
return; return;
} }
navigator.clipboard.writeText(item.cmdstr); navigator.clipboard.writeText(item.cmdstr);
mobx.action(() => {
this.copiedItemId.set(item.historyid);
})();
setTimeout(() => {
mobx.action(() => {
this.copiedItemId.set(null);
})();
}, 600);
} }
@boundMethod @boundMethod
@ -394,7 +375,7 @@ class HistoryView extends React.Component<{}, {}> {
} }
mobx.action(() => { mobx.action(() => {
GlobalModel.showSessionView(); GlobalModel.showSessionView();
GlobalModel.inputModel.setCurLine(item.cmdstr); GlobalModel.inputModel.updateCmdLine({ str: item.cmdstr, pos: item.cmdstr.length });
setTimeout(() => GlobalModel.inputModel.giveFocus(), 50); setTimeout(() => GlobalModel.inputModel.giveFocus(), 50);
})(); })();
} }
@ -569,7 +550,6 @@ class HistoryView extends React.Component<{}, {}> {
cmdstr={item.cmdstr} cmdstr={item.cmdstr}
onUse={() => this.handleUse(item)} onUse={() => this.handleUse(item)}
onCopy={() => this.handleCopy(item)} onCopy={() => this.handleCopy(item)}
isCopied={this.copiedItemId.get() == item.historyid}
fontSize="normal" fontSize="normal"
limitHeight={true} limitHeight={true}
/> />

View File

@ -106,11 +106,6 @@
} }
} }
&.top-border {
border-top: 1px solid #777;
padding: 10px;
}
&:hover .meta .termopts { &:hover .meta .termopts {
display: block; display: block;
} }

View File

@ -242,7 +242,7 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
} }
render() { render() {
let mainView = GlobalModel.activeMainView.get(); const mainView = GlobalModel.activeMainView.get();
const historyActive = mainView == "history"; const historyActive = mainView == "history";
const connectionsActive = mainView == "connections"; const connectionsActive = mainView == "connections";
const settingsActive = mainView == "clientsettings"; const settingsActive = mainView == "clientsettings";

View File

@ -0,0 +1,70 @@
.cmd-aichat {
padding-bottom: 0 !important;
.auxview-content {
flex-flow: column nowrap;
height: 100%;
.chat-window {
overflow-y: auto;
margin-bottom: 5px;
flex: 1 1 auto;
flex-direction: column-reverse;
}
.chat-input {
padding: 0.5em 0.5em 0.5em 0.5em;
flex: 1 0 auto;
.chat-textarea {
color: var(--app-text-primary-color);
background-color: var(--cmdinput-textarea-bg);
resize: none;
width: 100%;
border: transparent;
outline: none;
overflow: auto;
overflow-wrap: anywhere;
font-family: var(--termfontfamily);
font-weight: normal;
line-height: var(--termlineheight);
}
}
.chat-msg {
margin-top: calc(var(--termpad) * 2);
margin-bottom: calc(var(--termpad) * 2);
.chat-msg-header {
display: flex;
margin-bottom: 2px;
i {
margin-right: 0.5em;
}
.chat-username {
font-weight: bold;
margin-right: 5px;
}
}
}
.chat-msg-assistant {
color: var(--app-text-color);
}
.chat-msg-user {
.msg-text {
font-family: var(--markdown-font);
font-size: 14px;
white-space: pre-wrap;
}
}
.chat-msg-error {
color: var(--cmdinput-text-error);
font-family: var(--markdown-font);
font-size: 14px;
}
}
}

View File

@ -5,18 +5,18 @@ import * as React from "react";
import * as mobxReact from "mobx-react"; import * as mobxReact from "mobx-react";
import * as mobx from "mobx"; import * as mobx from "mobx";
import { GlobalModel } from "@/models"; import { GlobalModel } from "@/models";
import { isBlank } from "@/util/util";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { If, For } from "tsx-control-statements/components"; import { If, For } from "tsx-control-statements/components";
import { Markdown } from "@/elements"; import { Markdown } from "@/elements";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil"; import { AuxiliaryCmdView } from "./auxview";
import "./aichat.less";
class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> { class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> {
componentDidMount(): void { componentDidMount(): void {
let AIChatObject = this.props.AIChatObject; const AIChatObject = this.props.AIChatObject;
let keybindManager = GlobalModel.keybindManager; const keybindManager = GlobalModel.keybindManager;
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
keybindManager.registerKeybinding("pane", "aichat", "generic:confirm", (waveEvent) => { keybindManager.registerKeybinding("pane", "aichat", "generic:confirm", (waveEvent) => {
AIChatObject.onEnterKeyPressed(); AIChatObject.onEnterKeyPressed();
@ -27,7 +27,7 @@ class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> {
return true; return true;
}); });
keybindManager.registerKeybinding("pane", "aichat", "generic:cancel", (waveEvent) => { keybindManager.registerKeybinding("pane", "aichat", "generic:cancel", (waveEvent) => {
inputModel.closeAIAssistantChat(true); inputModel.closeAuxView();
return true; return true;
}); });
keybindManager.registerKeybinding("pane", "aichat", "aichat:clearHistory", (waveEvent) => { keybindManager.registerKeybinding("pane", "aichat", "aichat:clearHistory", (waveEvent) => {
@ -54,10 +54,10 @@ class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> {
@mobxReact.observer @mobxReact.observer
class AIChat extends React.Component<{}, {}> { class AIChat extends React.Component<{}, {}> {
chatListKeyCount: number = 0; chatListKeyCount: number = 0;
textAreaNumLines: mobx.IObservableValue<number> = mobx.observable.box(1, { name: "textAreaNumLines" });
chatWindowScrollRef: React.RefObject<HTMLDivElement>; chatWindowScrollRef: React.RefObject<HTMLDivElement>;
textAreaRef: React.RefObject<HTMLTextAreaElement>; textAreaRef: React.RefObject<HTMLTextAreaElement>;
isFocused: OV<boolean>; isFocused: OV<boolean>;
termFontSize: number = 14;
constructor(props: any) { constructor(props: any) {
super(props); super(props);
@ -69,9 +69,8 @@ class AIChat extends React.Component<{}, {}> {
} }
componentDidMount() { componentDidMount() {
let model = GlobalModel; const inputModel = GlobalModel.inputModel;
let inputModel = model.inputModel; if (this.chatWindowScrollRef?.current != null) {
if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) {
this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight; this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight;
} }
if (this.textAreaRef.current != null) { if (this.textAreaRef.current != null) {
@ -79,10 +78,11 @@ class AIChat extends React.Component<{}, {}> {
inputModel.setCmdInfoChatRefs(this.textAreaRef, this.chatWindowScrollRef); inputModel.setCmdInfoChatRefs(this.textAreaRef, this.chatWindowScrollRef);
} }
this.requestChatUpdate(); this.requestChatUpdate();
this.onTextAreaChange(null);
} }
componentDidUpdate() { componentDidUpdate() {
if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) { if (this.chatWindowScrollRef?.current != null) {
this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight; this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight;
} }
} }
@ -92,20 +92,18 @@ class AIChat extends React.Component<{}, {}> {
} }
submitChatMessage(messageStr: string) { submitChatMessage(messageStr: string) {
let model = GlobalModel; const curLine = GlobalModel.inputModel.getCurLine();
let inputModel = model.inputModel; const prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false);
let curLine = inputModel.getCurLine();
let prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false);
prtn.then((rtn) => { prtn.then((rtn) => {
if (!rtn.success) { if (!rtn.success) {
console.log("submit chat command error: " + rtn.error); console.log("submit chat command error: " + rtn.error);
} }
}).catch((error) => {}); }).catch((_) => {});
} }
getLinePos(elem: any): { numLines: number; linePos: number } { getLinePos(elem: any): { numLines: number; linePos: number } {
let numLines = elem.value.split("\n").length; const numLines = elem.value.split("\n").length;
let linePos = elem.value.substr(0, elem.selectionStart).split("\n").length; const linePos = elem.value.substr(0, elem.selectionStart).split("\n").length;
return { numLines, linePos }; return { numLines, linePos };
} }
@ -121,22 +119,32 @@ class AIChat extends React.Component<{}, {}> {
})(); })();
} }
// Adjust the height of the textarea to fit the text
onTextAreaChange(e: any) { onTextAreaChange(e: any) {
// set height of textarea based on number of newlines // Calculate the bounding height of the text area
mobx.action(() => { const textAreaMaxLines = 4;
this.textAreaNumLines.set(e.target.value.split(/\n/).length); const textAreaLineHeight = this.termFontSize * 1.5;
GlobalModel.inputModel.codeSelectDeselectAll(); const textAreaMinHeight = textAreaLineHeight;
})(); const textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines;
// Get the height of the wrapped text area content. Courtesy of https://stackoverflow.com/questions/995168/textarea-to-resize-based-on-content-length
this.textAreaRef.current.style.height = "1px";
const scrollHeight: number = this.textAreaRef.current.scrollHeight;
// Set the new height of the text area, bounded by the min and max height.
const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight);
this.textAreaRef.current.style.height = newHeight + "px";
GlobalModel.inputModel.codeSelectDeselectAll();
} }
onEnterKeyPressed() { onEnterKeyPressed() {
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
let currentRef = this.textAreaRef.current; const currentRef = this.textAreaRef.current;
if (currentRef == null) { if (currentRef == null) {
return; return;
} }
if (inputModel.getCodeSelectSelectedIndex() == -1) { if (inputModel.getCodeSelectSelectedIndex() == -1) {
let messageStr = currentRef.value; const messageStr = currentRef.value;
this.submitChatMessage(messageStr); this.submitChatMessage(messageStr);
currentRef.value = ""; currentRef.value = "";
} else { } else {
@ -145,7 +153,7 @@ class AIChat extends React.Component<{}, {}> {
} }
onExpandInputPressed() { onExpandInputPressed() {
let currentRef = this.textAreaRef.current; const currentRef = this.textAreaRef.current;
if (currentRef == null) { if (currentRef == null) {
return; return;
} }
@ -154,7 +162,7 @@ class AIChat extends React.Component<{}, {}> {
} }
onArrowUpPressed(): boolean { onArrowUpPressed(): boolean {
let currentRef = this.textAreaRef.current; const currentRef = this.textAreaRef.current;
if (currentRef == null) { if (currentRef == null) {
return false; return false;
} }
@ -168,8 +176,8 @@ class AIChat extends React.Component<{}, {}> {
} }
onArrowDownPressed(): boolean { onArrowDownPressed(): boolean {
let currentRef = this.textAreaRef.current; const currentRef = this.textAreaRef.current;
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
if (currentRef == null) { if (currentRef == null) {
return false; return false;
} }
@ -190,10 +198,10 @@ class AIChat extends React.Component<{}, {}> {
} }
renderChatMessage(chatItem: OpenAICmdInfoChatMessageType): any { renderChatMessage(chatItem: OpenAICmdInfoChatMessageType): any {
let curKey = "chatmsg-" + this.chatListKeyCount; const curKey = "chatmsg-" + this.chatListKeyCount;
this.chatListKeyCount++; this.chatListKeyCount++;
let senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user"; const senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user";
let msgClassName = "chat-msg " + senderClassName; const msgClassName = "chat-msg " + senderClassName;
let innerHTML: React.JSX.Element = ( let innerHTML: React.JSX.Element = (
<span> <span>
<div className="chat-msg-header"> <div className="chat-msg-header">
@ -226,68 +234,52 @@ class AIChat extends React.Component<{}, {}> {
); );
} }
renderChatWindow(): any {
let model = GlobalModel;
let inputModel = model.inputModel;
let chatMessageItems = inputModel.AICmdInfoChatItems.slice();
let chitem: OpenAICmdInfoChatMessageType = null;
return (
<div className="chat-window" ref={this.chatWindowScrollRef}>
<For each="chitem" index="idx" of={chatMessageItems}>
{this.renderChatMessage(chitem)}
</For>
</div>
);
}
render() { render() {
let model = GlobalModel; const chatMessageItems = GlobalModel.inputModel.AICmdInfoChatItems.slice();
let inputModel = model.inputModel; const chitem: OpenAICmdInfoChatMessageType = null;
const renderKeybindings = mobx
const termFontSize = 14; .computed(() => {
const textAreaMaxLines = 4; return (
const textAreaLineHeight = termFontSize * 1.5; this.isFocused.get() ||
const textAreaPadding = 2 * 0.5 * termFontSize; GlobalModel.inputModel.hasFocus() ||
let textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines + textAreaPadding; (GlobalModel.getActiveScreen().getFocusType() == "input" &&
let textAreaInnerHeight = this.textAreaNumLines.get() * textAreaLineHeight + textAreaPadding; GlobalModel.activeMainView.get() == "session")
let isFocused = this.isFocused.get(); );
})
.get();
return ( return (
<div className="cmd-aichat"> <AuxiliaryCmdView
<If condition={isFocused}> title="Wave AI"
className="cmd-aichat"
onClose={() => GlobalModel.inputModel.closeAuxView()}
iconClass="fa-sharp fa-solid fa-sparkles"
>
<If condition={renderKeybindings}>
<AIChatKeybindings AIChatObject={this}></AIChatKeybindings> <AIChatKeybindings AIChatObject={this}></AIChatKeybindings>
</If> </If>
<div className="cmdinput-titlebar"> <div className="chat-window" ref={this.chatWindowScrollRef}>
<div className="title-icon"> <For each="chitem" index="idx" of={chatMessageItems}>
<i className="fa-sharp fa-solid fa-sparkles" /> {this.renderChatMessage(chitem)}
</div> </For>
<div className="title-string">Wave AI</div>
<div className="flex-spacer"></div>
<div
className="close-button"
title="Close (ESC)"
onClick={() => inputModel.closeAIAssistantChat(true)}
>
<i className="fa-sharp fa-solid fa-xmark-large" />
</div>
</div> </div>
<div className="titlebar-spacer" /> <div className="chat-input">
{this.renderChatWindow()} <textarea
<textarea key="main"
key="main" ref={this.textAreaRef}
ref={this.textAreaRef} autoComplete="off"
autoComplete="off" autoCorrect="off"
autoCorrect="off" id="chat-cmd-input"
id="chat-cmd-input" onFocus={this.onTextAreaFocused.bind(this)}
onFocus={this.onTextAreaFocused.bind(this)} onBlur={this.onTextAreaBlur.bind(this)}
onBlur={this.onTextAreaBlur.bind(this)} onChange={this.onTextAreaChange.bind(this)}
onChange={this.onTextAreaChange.bind(this)} onKeyDown={this.onKeyDown}
onKeyDown={this.onKeyDown} style={{ fontSize: this.termFontSize }}
style={{ height: textAreaInnerHeight, maxHeight: textAreaMaxHeight, fontSize: termFontSize }} className="chat-textarea"
className={cn("chat-textarea")} placeholder="Send a Message..."
placeholder="Send a Message..." ></textarea>
></textarea> </div>
</div> </AuxiliaryCmdView>
); );
} }
} }

View File

@ -0,0 +1,51 @@
// For the additonal views, we want less padding on the top and bottom than we want for the base-cmdinput div
.auxview {
padding: var(--termpad) calc(var(--termpad) * 2) var(--termpad) calc(var(--termpad) * 3 - 1px);
overflow: auto;
flex-shrink: 1;
width: 100%;
--auxview-titlebar-height: 18px;
.auxview-titlebar {
position: absolute;
z-index: 22;
top: 0;
left: 0;
background-color: var(--app-panel-bg-color);
color: var(--term-blue);
padding: 6px 10px 6px 10px;
display: flex;
flex-direction: row;
width: 100%;
border-bottom: 1px solid var(--app-border-color);
font: var(--base-font);
user-select: none;
cursor: default;
line-height: var(--auxview-titlebar-height);
.title-string {
font-weight: bold;
}
.close-button {
cursor: pointer;
i {
color: var(--app-icon-color);
&:hover {
color: var(--app-icon-hover-color);
}
}
}
div:not(.close-button, .flex-spacer) {
margin-right: 10px;
}
}
.auxview-content {
display: flex;
padding-top: calc(var(--auxview-titlebar-height) + 6px);
}
}

View File

@ -0,0 +1,50 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import cn from "classnames";
import { If } from "tsx-control-statements/components";
import "./auxview.less";
export class AuxiliaryCmdView extends React.Component<
{
title: string;
className?: string;
iconClass?: string;
titleBarContents?: React.ReactElement[];
children?: React.ReactNode;
onClose?: React.MouseEventHandler<HTMLDivElement>;
},
{}
> {
render() {
const { title, className, iconClass, titleBarContents, children, onClose } = this.props;
return (
<div className={cn("auxview", className)}>
<div className="auxview-titlebar">
<If condition={iconClass != null}>
<div className="title-icon">
<i className={iconClass} />
</div>
</If>
<div className="title-string">{title}</div>
<If condition={titleBarContents != null}>{titleBarContents}</If>
<div className="flex-spacer"></div>
<If condition={onClose != null}>
<div className="close-button" title="Close (ESC)" onClick={onClose}>
<i className="fa-sharp fa-solid fa-xmark-large" />
</div>
</If>
</div>
<If condition={children != null}>
<div className="auxview-content">{children}</div>
</If>
</div>
);
}
}

View File

@ -4,130 +4,19 @@
max-height: max(300px, 40%); max-height: max(300px, 40%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute;
bottom: 0;
width: 100%; width: 100%;
padding: calc(var(--termpad) * 2) calc(var(--termpad) * 2) calc(var(--termpad) * 2) calc(var(--termpad) * 3 - 1px);
z-index: 100; z-index: 100;
border-top: 2px solid var(--app-border-color); border-top: 2px solid var(--app-border-color);
background-color: var(--app-bg-color); background-color: var(--app-bg-color);
position: relative;
&.has-info, // Apply a border between the base cmdinput and any views shown above it
// TODO: use a generic selector for this
&.has-aichat, &.has-aichat,
&.has-history { &.has-history,
.cmdinput-actions { &.has-info {
display: none; .base-cmdinput {
} border-top: 1px solid var(--app-border-color);
}
.titlebar-spacer {
height: 31px;
}
.cmdinput-conn {
position: absolute;
top: 0;
left: 0;
border-radius: 0 0 4px 0;
background-color: rgba(88, 193, 66, 0.3);
padding: 2px 10px 4px 10px;
font-size: calc(var(--termfontsize));
cursor: pointer;
i {
margin-left: 5px;
}
&:hover {
background-color: rgba(88, 193, 66, 0.5);
}
}
.cmdinput-actions {
position: absolute;
border-radius: 4px;
padding-left: 4px;
padding-right: 4px;
font-size: calc(var(--termfontsize) + 2px);
line-height: 1.2;
// we want to align to 2nd line of meta. that's 2xPad + 1xLineHightSm
// height of actions is 1xLineHeight + 8px (2x2px padding on icons)
top: calc(var(--termpad));
right: calc(var(--termpad) * 2);
display: flex;
flex-direction: row;
align-items: center;
.cmdinput-icon {
display: inline-flex;
color: var(--app-icon-hover-color);
opacity: 0.5;
.centered-icon {
.positional-icon-visible;
}
&.running-cmds {
.rotate {
fill: var(--app-warning-color);
}
}
&.active {
opacity: 1;
}
&:hover {
opacity: 1;
}
padding: 4px 6px;
cursor: pointer;
}
.line-icon + .line-icon:not(.line-icon-shrink-left) {
margin-left: 3px;
}
}
.cmdinput-titlebar {
position: absolute;
z-index: 22;
top: 0;
left: 0;
background-color: var(--app-panel-bg-color);
color: var(--term-blue);
padding: 6px 10px 6px 10px;
display: flex;
flex-direction: row;
width: 100%;
overflow-x: auto;
border-bottom: 1px solid var(--app-border-color);
font: var(--base-font);
.title-icon {
margin-right: 10px;
}
.title-string {
font-weight: bold;
}
.close-button {
cursor: pointer;
i {
color: var(--app-icon-color);
&:hover {
color: var(--app-icon-hover-color);
}
}
}
.spacer {
flex: 0 0 10px;
} }
} }
@ -135,22 +24,12 @@
padding-top: var(--termpad); padding-top: var(--termpad);
} }
.focus-indicator {
height: 90%;
top: 5%;
left: 4px;
}
&.has-history, &.has-history,
&.has-aichat { &.has-aichat {
padding-top: var(--termpad); padding-top: var(--termpad);
height: max(300px, 70%); height: max(300px, 70%);
} }
&.has-remote {
max-height: max(300px, 70%);
}
.remote-status-warning { .remote-status-warning {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -164,394 +43,153 @@
} }
} }
.input-minmax-control {
position: absolute;
top: 5px;
right: 5px;
color: var(--term-foreground);
padding: 5px;
cursor: pointer;
}
.cmd-input-grow-spacer { .cmd-input-grow-spacer {
flex-grow: 1; flex-grow: 1;
} }
.base-cmdinput:not(:first-child) { .base-cmdinput {
margin-top: var(--termpad);
}
.cmd-input-context {
color: var(--term-bright-white);
white-space: nowrap;
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
line-height: var(--termlineheight);
margin-left: 2px;
}
.cmd-input-filter {
opacity: 0.5;
&:hover {
opacity: 1;
}
.avatar {
display: inline-block;
width: 1em;
height: 1em;
margin: 0 0.5em;
vertical-align: text-top;
fill: var(--term-foreground);
}
.warning {
fill: var(--term-yellow);
}
}
.cmd-input-field {
position: relative; position: relative;
padding-right: var(--termpad); // Rather than apply the padding to the whole container, we will apply it to the inner contents directly.
font-family: var(--termfontfamily); // This is more fragile, but allows us to capture a larger target area for the individual components.
font-weight: normal; --padding-top: var(--termpad);
line-height: var(--termlineheight); --padding-sides: calc(var(--termpad) * 2);
font-size: var(--termfontsize);
.cmd-hints { .cmd-input-context {
position: absolute; color: var(--term-bright-white);
bottom: -14px; white-space: nowrap;
right: 0px; display: flex;
} justify-content: space-between;
.control { align-items: center;
padding: 1em 2px;
}
.textareainput-div {
position: relative;
&.control {
padding: var(--termpad) 0;
}
.shelltag {
position: absolute;
// 13px = 10px height + 3px padding. subtract termpad to account for textareainput-div padding (2px not sure?)
bottom: calc(-13px + var(--termpad));
right: 0px;
font-size: 10px;
color: var(--app-text-secondary-color);
line-height: 1;
padding: 1px 8px 3px 8px;
background-color: var(--cmdinput-textarea-bg-color);
border-radius: 0 0 5px 5px;
}
}
textarea {
color: var(--app-text-primary-color);
background-color: var(--cmdinput-textarea-bg-color);
padding: var(--termpad);
resize: none;
overflow: auto;
overflow-wrap: anywhere;
border-color: transparent;
border: none;
font-family: var(--termfontfamily); font-family: var(--termfontfamily);
font-size: var(--termfontsize);
line-height: var(--termlineheight);
// We don't want to pad the bottom or it will push the input field down.
padding: var(--padding-top) var(--padding-sides) 0 var(--padding-sides);
margin-left: 2px;
}
.cmd-input-field {
position: relative;
font-family: var(--termfontfamily);
font-weight: normal;
line-height: var(--termlineheight); line-height: var(--termlineheight);
font-size: var(--termfontsize); font-size: var(--termfontsize);
border-radius: 4px 4px 0 4px; // 0 is for shelltag border: none;
cursor: text;
&.display-disabled { // We don't want to pad the top or it will push the input field down.
background-color: #444; padding: 0 var(--padding-sides) var(--padding-top) var(--padding-sides);
.cmd-hints {
position: absolute;
bottom: -14px;
right: 0px;
}
.control {
padding: 1em 2px;
} }
&:focus { .textareainput-div {
position: relative;
&.control {
padding: var(--termpad) 0;
}
.shelltag {
position: absolute;
// 13px = 10px height + 3px padding. subtract termpad to account for textareainput-div padding (2px not sure?)
bottom: calc(-13px + var(--termpad));
right: 0;
font-size: 10px;
color: var(--app-text-secondary-color);
line-height: 1;
user-select: none;
}
}
textarea {
color: var(--app-text-primary-color);
background-color: var(--app-bg-color);
padding: var(--termpad) 0;
resize: none;
overflow: auto;
overflow-wrap: anywhere;
font-family: var(--termfontfamily);
line-height: var(--termlineheight);
font-size: var(--termfontsize);
border: none; border: none;
box-shadow: none;
}
input.history-input {
border: 0;
padding: 0;
height: 0;
}
.cmd-quick-context .button {
background-color: var(--app-bg-color) !important;
color: var(--app-text-color);
}
&.inputmode-global .cmd-quick-context .button {
color: var(--app-bg-color);
background-color: var(--cmdinput-button-bg-color) !important;
}
&.inputmode-comment .cmd-quick-context .button {
color: var(--app-bg-color);
background-color: var(--cmdinput-comment-button-bg-color) !important;
} }
} }
input.history-input { .cmdinput-actions {
border: 0;
padding: 0;
height: 0;
}
.cmd-quick-context .button {
background-color: #000 !important;
color: var(--app-text-color);
}
&.inputmode-global .cmd-quick-context .button {
color: var(--app-bg-color);
background-color: var(--cmdinput-button-bg-color) !important;
}
&.inputmode-comment .cmd-quick-context .button {
color: var(--app-bg-color);
background-color: var(--cmdinput-comment-button-bg-color) !important;
}
.cmd-exec {
position: absolute; position: absolute;
right: 0; font-size: calc(var(--termfontsize) + 2px);
.icon { line-height: 1.2;
vertical-align: bottom;
margin-right: 1em;
width: 2.5em;
height: 2.5em;
cursor: pointer;
border-radius: 50%;
fill: var(--app-accent-color);
padding: 0.25em;
}
.icon.disabled {
fill: var(--cmdinput-disabled-icon-color);
cursor: default;
}
.cmd-btn {
display: inline-block;
margin-right: 0;
padding: 0.2em 0.7rem;
border-radius: 4px;
vertical-align: super;
.hint-elem { // Align to the same bounds as the input field
cursor: pointer; top: var(--padding-top);
opacity: 0.5; right: var(--padding-sides);
&:hover { display: flex;
opacity: 1; flex-direction: row;
align-items: center;
.cmdinput-icon {
display: inline-flex;
color: var(--app-icon-hover-color);
opacity: 0.5;
.centered-icon {
.positional-icon-visible;
}
&.running-cmds {
.rotate {
fill: var(--app-warning-color);
} }
} }
}
}
}
.cmd-aichat { &.active {
display: flex; opacity: 1;
justify-content: flex-end;
flex-flow: column nowrap;
margin-bottom: 10px;
flex-shrink: 1;
overflow-y: auto;
.chat-window {
overflow-y: auto;
margin-bottom: 5px;
flex-shrink: 1;
flex-direction: column-reverse;
}
.chat-textarea {
color: var(--app-text-primary-color);
background-color: var(--cmdinput-textarea-bg);
padding: 0.5em;
resize: none;
overflow: auto;
overflow-wrap: anywhere;
border-color: transparent;
border: none;
font-family: var(--termfontfamily);
font-weight: normal;
flex-shrink: 0;
flex-grow: 1;
border-radius: 4px;
&:focus {
border: none;
outline: none;
}
}
.chat-msg {
margin-top: calc(var(--termpad) * 2);
margin-bottom: calc(var(--termpad) * 2);
.chat-msg-header {
display: flex;
margin-bottom: 2px;
i {
margin-right: 0.5em;
} }
.chat-username {
font-weight: bold;
margin-right: 5px;
}
}
}
.chat-msg-assistant {
color: var(--app-text-color);
}
.chat-msg-user {
.msg-text {
font-family: var(--markdown-font);
font-size: 14px;
white-space: pre-wrap;
}
}
.chat-msg-error {
color: var(--cmdinput-text-error);
font-family: var(--markdown-font);
font-size: 14px;
}
.grow-spacer {
flex: 1 0 10px;
}
}
.cmd-history {
color: var(--app-text-color);
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
overflow: auto;
flex-shrink: 1;
.history-title {
div:first-child {
margin-left: var(--termpad);
}
.history-opt {
white-space: nowrap;
}
.history-clickable-opt {
cursor: pointer;
&:hover { &:hover {
color: var(--app-text-primary-color); opacity: 1;
} }
}
.history-clickable-opt { // This aligns the icons with the prompt field.
white-space: nowrap; // We don't need right padding because the whole input field is already padded.
padding: 2px 0 0 12px;
cursor: pointer; cursor: pointer;
} }
}
.history-items { .line-icon + .line-icon:not(.line-icon-shrink-left) {
color: var(--app-text-color); margin-left: 3px;
padding-bottom: 6px;
.history-line {
white-space: pre;
} }
.history-item.history-haderror {
color: var(--cmdinput-history-item-error-color);
}
.history-line:first-child {
margin-left: 0 !important;
}
.history-item {
padding-left: 5px;
cursor: pointer;
&:hover {
background-color: #222;
}
}
.history-item.is-selected {
font-weight: bold;
color: var(--app-text-primary-color);
background-color: #444;
}
.history-item.is-selected.history-haderror {
color: var(--cmdinput-history-item-selected-error-color);
}
}
}
.cmd-input-info {
flex-shrink: 1;
overflow-y: auto;
margin-bottom: var(--termpad);
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
line-height: var(--termlineheight);
.info-title {
position: absolute;
z-index: 102;
top: 5px;
left: 0;
font-size: calc(var(--termfontsize) + 2px);
background-color: var(--app-bg-color);
color: var(--term-blue);
padding-bottom: 4px;
padding-left: calc(var(--termpad) * 2);
display: flex;
flex-direction: row;
width: 100%;
overflow-x: auto;
border-bottom: 1px solid var(--app-border-color);
}
.info-title + .info-msg,
.info-title + .info-lines,
.info-title + .info-comps,
.info-title + .info-error {
margin-top: 26px;
}
.info-msg {
color: var(--term-blue);
padding-bottom: 2px;
a {
color: var(--term-blue);
}
}
.info-lines {
color: var(--app-text-color);
white-space: pre;
padding-bottom: 6px;
}
.info-comps {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding-bottom: 5px;
font-weight: normal;
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
.info-comp {
min-width: 200px;
color: var(--term-foreground);
margin-right: 10px;
&.has-space {
text-decoration: underline dotted #777;
}
}
.metacmd-comp {
color: var(--term-bright-green);
}
}
.info-error {
color: var(--cmdinput-text-error-color);
padding-bottom: 2px;
} }
} }
} }

View File

@ -5,7 +5,7 @@ import * as React from "react";
import * as mobxReact from "mobx-react"; import * as mobxReact from "mobx-react";
import * as mobx from "mobx"; import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components"; import { Choose, If, When } from "tsx-control-statements/components";
import cn from "classnames"; import cn from "classnames";
import dayjs from "dayjs"; import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
@ -18,6 +18,7 @@ import { Prompt } from "@/common/prompt/prompt";
import { CenteredIcon, RotateIcon } from "@/common/icons/icons"; import { CenteredIcon, RotateIcon } from "@/common/icons/icons";
import { AIChat } from "./aichat"; import { AIChat } from "./aichat";
import * as util from "@/util/util"; import * as util from "@/util/util";
import * as appconst from "@/app/appconst";
import "./cmdinput.less"; import "./cmdinput.less";
@ -61,12 +62,17 @@ class CmdInput extends React.Component<{}, {}> {
} }
@boundMethod @boundMethod
cmdInputClick(e: any): void { baseCmdInputClick(e: React.SyntheticEvent): void {
if (this.promptRef.current != null) { if (this.promptRef.current != null) {
if (this.promptRef.current.contains(e.target)) { if (this.promptRef.current.contains(e.target)) {
return; return;
} }
} }
if ((e.target as HTMLDivElement).classList.contains("cmd-input-context")) {
e.stopPropagation();
return;
}
GlobalModel.inputModel.setAuxViewFocus(false);
GlobalModel.inputModel.giveFocus(); GlobalModel.inputModel.giveFocus();
} }
@ -74,8 +80,12 @@ class CmdInput extends React.Component<{}, {}> {
clickAIAction(e: any): void { clickAIAction(e: any): void {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
inputModel.openAIAssistantChat(); if (inputModel.getActiveAuxView() === appconst.InputAuxView_AIChat) {
inputModel.closeAuxView();
} else {
inputModel.openAIAssistantChat();
}
} }
@boundMethod @boundMethod
@ -84,7 +94,7 @@ class CmdInput extends React.Component<{}, {}> {
e.stopPropagation(); e.stopPropagation();
const inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
if (inputModel.historyShow.get()) { if (inputModel.getActiveAuxView() === appconst.InputAuxView_History) {
inputModel.resetHistory(); inputModel.resetHistory();
} else { } else {
inputModel.openHistory(); inputModel.openHistory();
@ -146,9 +156,6 @@ class CmdInput extends React.Component<{}, {}> {
remote = GlobalModel.getRemote(rptr.remoteid); remote = GlobalModel.getRemote(rptr.remoteid);
} }
feState = feState || {}; feState = feState || {};
const infoShow = inputModel.infoShow.get();
const historyShow = !infoShow && inputModel.historyShow.get();
const aiChatShow = inputModel.aIChatShow.get();
const focusVal = inputModel.physicalInputFocused.get(); const focusVal = inputModel.physicalInputFocused.get();
const inputMode: string = inputModel.inputMode.get(); const inputMode: string = inputModel.inputMode.get();
const textAreaInputKey = screen == null ? "null" : screen.screenId; const textAreaInputKey = screen == null ? "null" : screen.screenId;
@ -160,6 +167,9 @@ class CmdInput extends React.Component<{}, {}> {
} }
let shellInitMsg: string = null; let shellInitMsg: string = null;
let hidePrompt = false; let hidePrompt = false;
const openView = inputModel.getActiveAuxView();
const hasOpenView = openView ? `has-${openView}` : null;
if (ri == null) { if (ri == null) {
let shellStr = "shell"; let shellStr = "shell";
if (!util.isBlank(remote?.defaultshelltype)) { if (!util.isBlank(remote?.defaultshelltype)) {
@ -172,51 +182,20 @@ class CmdInput extends React.Component<{}, {}> {
} }
} }
return ( return (
<div <div ref={this.cmdInputRef} className={cn("cmd-input", hasOpenView, { active: focusVal })}>
ref={this.cmdInputRef} <Choose>
className={cn( <When condition={openView === appconst.InputAuxView_History}>
"cmd-input", <div className="cmd-input-grow-spacer"></div>
{ "has-info": infoShow }, <HistoryInfo />
{ "has-aichat": aiChatShow }, </When>
{ "has-history": historyShow }, <When condition={openView === appconst.InputAuxView_AIChat}>
{ active: focusVal } <div className="cmd-input-grow-spacer"></div>
)} <AIChat />
> </When>
<div className="cmdinput-actions"> <When condition={openView === appconst.InputAuxView_Info}>
<If condition={numRunningLines > 0}> <InfoMsg key="infomsg" />
<div </When>
key="running" </Choose>
className={cn("cmdinput-icon", "running-cmds", { active: filterRunning })}
title="Filter for Running Commands"
onClick={() => this.toggleFilter(screen)}
>
<CenteredIcon>{numRunningLines}</CenteredIcon>{" "}
<CenteredIcon>
<RotateIcon className="rotate warning spin" />
</CenteredIcon>
</div>
</If>
<div key="ai" title="Wave AI (Ctrl-Space)" className="cmdinput-icon" onClick={this.clickAIAction}>
<i className="fa-sharp fa-regular fa-sparkles fa-fw" />
</div>
<div
key="history"
title="Tab History (Ctrl-R)"
className="cmdinput-icon"
onClick={this.clickHistoryAction}
>
<i className="fa-sharp fa-regular fa-clock-rotate-left fa-fw" />
</div>
</div>
<If condition={historyShow}>
<div className="cmd-input-grow-spacer"></div>
<HistoryInfo />
</If>
<If condition={aiChatShow}>
<div className="cmd-input-grow-spacer"></div>
<AIChat />
</If>
<InfoMsg key="infomsg" />
<If condition={remote && remote.status != "connected"}> <If condition={remote && remote.status != "connected"}>
<div className="remote-status-warning"> <div className="remote-status-warning">
WARNING:&nbsp; WARNING:&nbsp;
@ -252,7 +231,43 @@ class CmdInput extends React.Component<{}, {}> {
</Button> </Button>
</div> </div>
</If> </If>
<div key="base-cmdinput" className="base-cmdinput"> <div
key="base-cmdinput"
className="base-cmdinput"
onClick={this.baseCmdInputClick}
onSelect={this.baseCmdInputClick}
>
<div className="cmdinput-actions">
<If condition={numRunningLines > 0}>
<div
key="running"
className={cn("cmdinput-icon", "running-cmds", { active: filterRunning })}
title="Filter for Running Commands"
onClick={() => this.toggleFilter(screen)}
>
<CenteredIcon>{numRunningLines}</CenteredIcon>{" "}
<CenteredIcon>
<RotateIcon className="rotate warning spin" />
</CenteredIcon>
</div>
</If>
<div
key="ai"
title="Wave AI (Ctrl-Space)"
className="cmdinput-icon"
onClick={this.clickAIAction}
>
<i className="fa-sharp fa-regular fa-sparkles fa-fw" />
</div>
<div
key="history"
title="Tab History (Ctrl-R)"
className="cmdinput-icon"
onClick={this.clickHistoryAction}
>
<i className="fa-sharp fa-regular fa-clock-rotate-left fa-fw" />
</div>
</div>
<If condition={!hidePrompt}> <If condition={!hidePrompt}>
<div key="prompt" className="cmd-input-context"> <div key="prompt" className="cmd-input-context">
<div className="has-text-white"> <div className="has-text-white">

View File

@ -0,0 +1,60 @@
.cmd-history {
color: var(--app-text-color);
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
.auxview-titlebar {
.history-opt {
white-space: nowrap;
}
.history-clickable-opt {
cursor: pointer;
white-space: nowrap;
cursor: pointer;
&:hover {
color: var(--app-text-primary-color);
}
}
}
.history-items {
color: var(--app-text-color);
width: 100%;
.history-item {
cursor: pointer;
border-radius: 5px;
.history-line {
white-space: pre;
&:first-child {
margin-left: 0 !important;
}
}
&:hover {
background-color: var(--table-tr-hover-bg-color);
}
&.history-haderror {
color: var(--cmdinput-history-item-error-color);
}
&.is-selected {
font-weight: bold;
color: var(--app-text-primary-color);
background-color: var(--table-tr-selected-bg-color);
&:hover {
background-color: var(--table-tr-selected-hover-bg-color);
}
}
&.is-selected.history-haderror {
color: var(--cmdinput-history-item-selected-error-color);
}
}
}
}

View File

@ -13,6 +13,9 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel } from "@/models"; import { GlobalModel } from "@/models";
import { isBlank } from "@/util/util"; import { isBlank } from "@/util/util";
import "./historyinfo.less";
import { AuxiliaryCmdView } from "./auxview";
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
const TDots = "⋮"; const TDots = "⋮";
@ -43,7 +46,7 @@ class HItem extends React.Component<
if (hitem.remote == null || isBlank(hitem.remote.remoteid)) { if (hitem.remote == null || isBlank(hitem.remote.remoteid)) {
return sprintf("%-15s ", ""); return sprintf("%-15s ", "");
} }
let r = GlobalModel.getRemote(hitem.remote.remoteid); const r = GlobalModel.getRemote(hitem.remote.remoteid);
if (r == null) { if (r == null) {
return sprintf("%-15s ", "???"); return sprintf("%-15s ", "???");
} }
@ -71,15 +74,15 @@ class HItem extends React.Component<
if (!opts.limitRemote) { if (!opts.limitRemote) {
remoteStr = this.renderRemote(hitem); remoteStr = this.renderRemote(hitem);
} }
let selectedStr = isSelected ? "*" : " "; const selectedStr = isSelected ? "*" : " ";
let lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : ""; const lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : "";
if (isBlank(opts.queryType) || opts.queryType == "screen") { if (isBlank(opts.queryType) || opts.queryType == "screen") {
return selectedStr + sprintf("%7s", lineNumStr) + " " + remoteStr; return selectedStr + sprintf("%7s", lineNumStr) + " " + remoteStr;
} }
if (opts.queryType == "session") { if (opts.queryType == "session") {
let screenStr = ""; let screenStr = "";
if (!isBlank(hitem.screenid)) { if (!isBlank(hitem.screenid)) {
let scrName = scrNames[hitem.screenid]; const scrName = scrNames[hitem.screenid];
if (scrName != null) { if (scrName != null) {
screenStr = "[" + truncateWithTDots(scrName, 15) + "]"; screenStr = "[" + truncateWithTDots(scrName, 15) + "]";
} }
@ -89,19 +92,18 @@ class HItem extends React.Component<
if (opts.queryType == "global") { if (opts.queryType == "global") {
let sessionStr = ""; let sessionStr = "";
if (!isBlank(hitem.sessionid)) { if (!isBlank(hitem.sessionid)) {
let sessionName = snames[hitem.sessionid]; const sessionName = snames[hitem.sessionid];
if (sessionName != null) { if (sessionName != null) {
sessionStr = "#" + truncateWithTDots(sessionName, 15); sessionStr = "#" + truncateWithTDots(sessionName, 15);
} }
} }
let screenStr = ""; let screenStr = "";
if (!isBlank(hitem.screenid)) { if (!isBlank(hitem.screenid)) {
let scrName = scrNames[hitem.screenid]; const scrName = scrNames[hitem.screenid];
if (scrName != null) { if (scrName != null) {
screenStr = "[" + truncateWithTDots(scrName, 13) + "]"; screenStr = "[" + truncateWithTDots(scrName, 13) + "]";
} }
} }
let ssStr = sessionStr + screenStr;
return ( return (
selectedStr + selectedStr +
sprintf("%15s ", sessionStr) + sprintf("%15s ", sessionStr) +
@ -116,12 +118,12 @@ class HItem extends React.Component<
} }
render() { render() {
let { hitem, isSelected, opts, snames, scrNames } = this.props; const { hitem, isSelected, opts, snames, scrNames } = this.props;
let lines = hitem.cmdstr.split("\n"); const lines = hitem.cmdstr.split("\n");
let line: string = ""; let line: string = "";
let idx = 0; let idx = 0;
let infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames); const infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames);
let infoTextSpacer = sprintf("%" + infoText.length + "s", ""); const infoTextSpacer = sprintf("%" + infoText.length + "s", "");
return ( return (
<div <div
key={hitem.historynum} key={hitem.historynum}
@ -153,7 +155,7 @@ class HistoryInfo extends React.Component<{}, {}> {
containingText: mobx.IObservableValue<string> = mobx.observable.box(""); containingText: mobx.IObservableValue<string> = mobx.observable.box("");
componentDidMount() { componentDidMount() {
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
let hitem = inputModel.getHistorySelectedItem(); let hitem = inputModel.getHistorySelectedItem();
if (hitem == null) { if (hitem == null) {
hitem = inputModel.getFirstHistoryItem(); hitem = inputModel.getFirstHistoryItem();
@ -165,20 +167,20 @@ class HistoryInfo extends React.Component<{}, {}> {
@boundMethod @boundMethod
handleClose() { handleClose() {
GlobalModel.inputModel.toggleInfoMsg(); GlobalModel.inputModel.closeAuxView();
} }
@boundMethod @boundMethod
handleItemClick(hitem: HistoryItem) { handleItemClick(hitem: HistoryItem) {
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
let selItem = inputModel.getHistorySelectedItem(); const selItem = inputModel.getHistorySelectedItem();
inputModel.setAuxViewFocus(false);
if (this.lastClickHNum == hitem.historynum && selItem != null && selItem.historynum == hitem.historynum) { if (this.lastClickHNum == hitem.historynum && selItem != null && selItem.historynum == hitem.historynum) {
inputModel.grabSelectedHistoryItem(); inputModel.grabSelectedHistoryItem();
return; return;
} }
inputModel.giveFocus();
inputModel.setHistorySelectionNum(hitem.historynum); inputModel.setHistorySelectionNum(hitem.historynum);
let now = Date.now(); const now = Date.now();
this.lastClickHNum = hitem.historynum; this.lastClickHNum = hitem.historynum;
this.lastClickTs = now; this.lastClickTs = now;
setTimeout(() => { setTimeout(() => {
@ -191,24 +193,41 @@ class HistoryInfo extends React.Component<{}, {}> {
@boundMethod @boundMethod
handleClickType() { handleClickType() {
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
inputModel.setAuxViewFocus(true);
inputModel.toggleHistoryType(); inputModel.toggleHistoryType();
} }
@boundMethod @boundMethod
handleClickRemote() { handleClickRemote() {
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
inputModel.setAuxViewFocus(true);
inputModel.toggleRemoteType(); inputModel.toggleRemoteType();
} }
@boundMethod
getTitleBarContents(): React.ReactElement[] {
const opts = GlobalModel.inputModel.historyQueryOpts.get();
return [
<div className="history-opt history-clickable-opt" key="screen" onClick={this.handleClickType}>
[for {opts.queryType} &#x2318;S]
</div>,
<div className="history-opt" key="query-str" title="type to search">
[containing '{opts.queryStr}']
</div>,
<div className="history-opt history-clickable-opt" key="remote" onClick={this.handleClickRemote}>
[{opts.limitRemote ? "this" : "any"} remote &#x2318;R]
</div>,
];
}
render() { render() {
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
let idx: number = 0; const selItem = inputModel.getHistorySelectedItem();
let selItem = inputModel.getHistorySelectedItem(); const hitems = inputModel.getFilteredHistoryItems().slice().reverse();
let hitems = inputModel.getFilteredHistoryItems(); const opts = inputModel.historyQueryOpts.get();
hitems = hitems.slice().reverse();
let hitem: HistoryItem = null; let hitem: HistoryItem = null;
let opts = inputModel.historyQueryOpts.get();
let snames: Record<string, string> = {}; let snames: Record<string, string> = {};
let scrNames: Record<string, string> = {}; let scrNames: Record<string, string> = {};
if (opts.queryType == "global") { if (opts.queryType == "global") {
@ -218,29 +237,13 @@ class HistoryInfo extends React.Component<{}, {}> {
scrNames = GlobalModel.getScreenNames(); scrNames = GlobalModel.getScreenNames();
} }
return ( return (
<div className="cmd-history hide-scrollbar"> <AuxiliaryCmdView
<div className="cmdinput-titlebar history-title"> title="History"
<div className="title-icon"> className="cmd-history hide-scrollbar"
<i className="fa-sharp fa-solid fa-clock-rotate-left" /> onClose={this.handleClose}
</div> titleBarContents={this.getTitleBarContents()}
<div className="title-string">History</div> iconClass="fa-sharp fa-solid fa-clock-rotate-left"
<div className="spacer"></div> >
<div className="history-opt history-clickable-opt" onClick={this.handleClickType}>
[for {opts.queryType} &#x2318;S]
</div>
<div className="spacer"></div>
<div className="history-opt" title="type to search">
[containing '{opts.queryStr}']
</div>
<div className="spacer"></div>
<div className="history-opt history-clickable-opt" onClick={this.handleClickRemote}>
[{opts.limitRemote ? "this" : "any"} remote &#x2318;R]
</div>
<div className="flex-spacer"></div>
<div className="close-button" title="Close (ESC)">
<i className="fa-sharp fa-solid fa-xmark-large" onClick={this.handleClose}></i>
</div>
</div>
<div <div
className={cn( className={cn(
"history-items", "history-items",
@ -248,7 +251,6 @@ class HistoryInfo extends React.Component<{}, {}> {
{ "show-sessions": opts.queryType == "global" } { "show-sessions": opts.queryType == "global" }
)} )}
> >
<div className="titlebar-spacer" />
<If condition={hitems.length == 0}>[no history]</If> <If condition={hitems.length == 0}>[no history]</If>
<If condition={hitems.length > 0}> <If condition={hitems.length > 0}>
<For each="hitem" index="idx" of={hitems}> <For each="hitem" index="idx" of={hitems}>
@ -264,7 +266,7 @@ class HistoryInfo extends React.Component<{}, {}> {
</For> </For>
</If> </If>
</div> </div>
</div> </AuxiliaryCmdView>
); );
} }
} }

View File

@ -0,0 +1,49 @@
.cmd-input-info {
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
line-height: var(--termlineheight);
.info-msg {
color: var(--term-blue);
padding-bottom: 2px;
a {
color: var(--term-blue);
}
}
.info-lines {
color: var(--app-text-color);
white-space: pre;
padding-bottom: 6px;
}
.info-comps {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding-bottom: 5px;
font-weight: normal;
font-family: var(--termfontfamily);
font-size: var(--termfontsize);
.info-comp {
min-width: 200px;
color: var(--term-foreground);
margin-right: 10px;
&.has-space {
text-decoration: underline dotted #777;
}
}
.metacmd-comp {
color: var(--term-bright-green);
}
}
.info-error {
color: var(--cmdinput-text-error-color);
padding-bottom: 2px;
}
}

View File

@ -9,6 +9,9 @@ import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel } from "@/models"; import { GlobalModel } from "@/models";
import * as appconst from "@/app/appconst"; import * as appconst from "@/app/appconst";
import { AuxiliaryCmdView } from "./auxview";
import "./infomsg.less";
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -40,29 +43,22 @@ class InfoMsg extends React.Component<{}, {}> {
} }
render() { render() {
let model = GlobalModel; const inputModel = GlobalModel.inputModel;
let inputModel = model.inputModel; const infoMsg: InfoType = inputModel.infoMsg.get();
let infoMsg = inputModel.infoMsg.get(); const infoShow = inputModel.getActiveAuxView() == appconst.InputAuxView_Info;
let infoShow = inputModel.infoShow.get();
let line: string = null; let line: string = null;
let istr: string = null; let istr: string = null;
let idx: number = 0; let idx: number = 0;
let titleStr = null; let titleStr = null;
let remoteEditKey = "inforemoteedit";
if (infoMsg != null) { if (infoMsg != null) {
titleStr = infoMsg.infotitle; titleStr = infoMsg.infotitle;
} }
let activeScreen = model.getActiveScreen();
if (!infoShow) { if (!infoShow) {
return null; return null;
} }
return ( return (
<div className="cmd-input-info" style={{ display: infoShow ? "block" : "none" }}> <AuxiliaryCmdView title={titleStr} className="cmd-input-info">
<If condition={infoMsg?.infotitle}>
<div key="infotitle" className="info-title">
{titleStr}
</div>
</If>
<If condition={infoMsg?.infomsg}> <If condition={infoMsg?.infomsg}>
<div key="infomsg" className="info-msg"> <div key="infomsg" className="info-msg">
<If condition={infoMsg.infomsghtml}> <If condition={infoMsg.infomsghtml}>
@ -108,7 +104,7 @@ class InfoMsg extends React.Component<{}, {}> {
<div className="info-error">to reset, run: /reset:cwd</div> <div className="info-error">to reset, run: /reset:cwd</div>
</If> </If>
</If> </If>
</div> </AuxiliaryCmdView>
); );
} }
} }

View File

@ -152,11 +152,7 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
}); });
keybindManager.registerKeybinding("pane", "cmdinput", "generic:cancel", (waveEvent) => { keybindManager.registerKeybinding("pane", "cmdinput", "generic:cancel", (waveEvent) => {
GlobalModel.closeTabSettings(); GlobalModel.closeTabSettings();
inputModel.toggleInfoMsg(); inputModel.closeAuxView();
if (inputModel.inputMode.get() != null) {
inputModel.resetInputMode();
}
inputModel.closeAIAssistantChat(true);
return true; return true;
}); });
keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:expandInput", (waveEvent) => { keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:expandInput", (waveEvent) => {
@ -253,9 +249,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
controlRef: React.RefObject<HTMLDivElement> = React.createRef(); controlRef: React.RefObject<HTMLDivElement> = React.createRef();
lastHeight: number = 0; lastHeight: number = 0;
lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos }; lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos };
version: OV<number> = mobx.observable.box(0); // forces render updates version: OV<number> = mobx.observable.box(0, { name: "textAreaInput-version" }); // forces render updates
mainInputFocused: OV<boolean> = mobx.observable.box(true);
historyFocused: OV<boolean> = mobx.observable.box(false);
incVersion(): void { incVersion(): void {
const v = this.version.get(); const v = this.version.get();
@ -286,12 +280,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
} }
setFocus(): void { setFocus(): void {
const inputModel = GlobalModel.inputModel; GlobalModel.inputModel.giveFocus();
if (inputModel.historyShow.get()) {
this.historyInputRef.current.focus();
} else {
this.mainInputRef.current.focus();
}
} }
getTextAreaMaxCols(): number { getTextAreaMaxCols(): number {
@ -532,7 +521,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
if (selStart > value.length || selEnd > value.length) { if (selStart > value.length || selEnd > value.length) {
return; return;
} }
const newValue = value.substr(0, selStart) + clipText + value.substr(selEnd); const newValue = value.substring(0, selStart) + clipText + value.substring(selEnd);
const cmdLineUpdate = { str: newValue, pos: selStart + clipText.length }; const cmdLineUpdate = { str: newValue, pos: selStart + clipText.length };
GlobalModel.inputModel.updateCmdLine(cmdLineUpdate); GlobalModel.inputModel.updateCmdLine(cmdLineUpdate);
}); });
@ -549,19 +538,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
} }
@boundMethod @boundMethod
handleMainFocus(e: any) { handleFocus(e: any) {
const inputModel = GlobalModel.inputModel; e.preventDefault();
if (inputModel.historyShow.get()) { GlobalModel.inputModel.giveFocus();
e.preventDefault();
if (this.historyInputRef.current != null) {
this.historyInputRef.current.focus();
}
return;
}
inputModel.setPhysicalInputFocused(true);
mobx.action(() => {
this.mainInputFocused.set(true);
})();
} }
@boundMethod @boundMethod
@ -570,25 +549,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
return; return;
} }
GlobalModel.inputModel.setPhysicalInputFocused(false); GlobalModel.inputModel.setPhysicalInputFocused(false);
mobx.action(() => {
this.mainInputFocused.set(false);
})();
}
@boundMethod
handleHistoryFocus(e: any) {
const inputModel = GlobalModel.inputModel;
if (!inputModel.historyShow.get()) {
e.preventDefault();
if (this.mainInputRef.current != null) {
this.mainInputRef.current.focus();
}
return;
}
inputModel.setPhysicalInputFocused(true);
mobx.action(() => {
this.historyFocused.set(true);
})();
} }
@boundMethod @boundMethod
@ -597,9 +557,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
return; return;
} }
GlobalModel.inputModel.setPhysicalInputFocused(false); GlobalModel.inputModel.setPhysicalInputFocused(false);
mobx.action(() => {
this.historyFocused.set(false);
})();
} }
render() { render() {
@ -616,8 +573,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
if (numLines > 1 || longLine || inputModel.inputExpanded.get()) { if (numLines > 1 || longLine || inputModel.inputExpanded.get()) {
displayLines = 5; displayLines = 5;
} }
const disabled = inputModel.historyShow.get();
if (disabled) { const auxViewFocused = inputModel.getAuxViewFocus();
if (auxViewFocused) {
displayLines = 1; displayLines = 1;
} }
const activeScreen = GlobalModel.getActiveScreen(); const activeScreen = GlobalModel.getActiveScreen();
@ -633,7 +591,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
const screen = GlobalModel.getActiveScreen(); const screen = GlobalModel.getActiveScreen();
if (screen != null) { if (screen != null) {
const ri = screen.getCurRemoteInstance(); const ri = screen.getCurRemoteInstance();
if (ri != null && ri.shelltype != null) { if (ri?.shelltype != null) {
shellType = ri.shelltype; shellType = ri.shelltype;
} }
if (shellType == "") { if (shellType == "") {
@ -646,22 +604,21 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
} }
} }
} }
const isMainInputFocused = this.mainInputFocused.get(); const isHistoryFocused = auxViewFocused && inputModel.getActiveAuxView() == appconst.InputAuxView_History;
const isHistoryFocused = this.historyFocused.get();
return ( return (
<div <div
className="textareainput-div control is-expanded" className="textareainput-div control is-expanded"
ref={this.controlRef} ref={this.controlRef}
style={{ height: computedOuterHeight }} style={{ height: computedOuterHeight }}
> >
<If condition={isMainInputFocused}> <If condition={!auxViewFocused}>
<CmdInputKeybindings inputObject={this}></CmdInputKeybindings> <CmdInputKeybindings inputObject={this}></CmdInputKeybindings>
</If> </If>
<If condition={isHistoryFocused}> <If condition={isHistoryFocused}>
<HistoryKeybindings inputObject={this}></HistoryKeybindings> <HistoryKeybindings inputObject={this}></HistoryKeybindings>
</If> </If>
<If condition={!disabled && !util.isBlank(shellType)}> <If condition={!util.isBlank(shellType)}>
<div className="shelltag">{shellType}</div> <div className="shelltag">{shellType}</div>
</If> </If>
<textarea <textarea
@ -671,14 +628,15 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
id="main-cmd-input" id="main-cmd-input"
onFocus={this.handleMainFocus} onFocus={this.handleFocus}
onBlur={this.handleMainBlur} onBlur={this.handleMainBlur}
style={{ height: computedInnerHeight, minHeight: computedInnerHeight, fontSize: termFontSize }} style={{ height: computedInnerHeight, minHeight: computedInnerHeight, fontSize: termFontSize }}
value={curLine} value={curLine}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onChange={this.onChange} onChange={this.onChange}
onSelect={this.onSelect} onSelect={this.onSelect}
className={cn("textarea", { "display-disabled": disabled })} placeholder="Type here..."
className={cn("textarea", { "display-disabled": auxViewFocused })}
></textarea> ></textarea>
<input <input
key="history" key="history"
@ -688,7 +646,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
autoCorrect="off" autoCorrect="off"
className="history-input" className="history-input"
type="text" type="text"
onFocus={this.handleHistoryFocus} onFocus={this.handleFocus}
onBlur={this.handleHistoryBlur} onBlur={this.handleHistoryBlur}
onKeyDown={this.onHistoryKeyDown} onKeyDown={this.onHistoryKeyDown}
onChange={this.handleHistoryInput} onChange={this.handleHistoryInput}

View File

@ -159,134 +159,3 @@
} }
} }
} }
.newtab-container {
margin: 8px 16px 0 16px;
.newtab-section {
display: flex;
padding: 10px 16px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
&.conn-section {
gap: 8px;
}
}
.cr-help-text {
color: var(--screen-view-text-caption-color);
margin-left: 5px;
}
.newtab-spacer {
height: 1px;
background: var(--app-border-color);
}
.control-iconlist {
display: flex;
margin-left: -2px;
padding: 8px 0 8px 2px;
align-items: flex-start;
gap: 14px;
&.tabicon-list {
gap: 12px;
}
.icondiv {
width: 20px;
height: 20px;
cursor: pointer;
position: relative;
font-size: 14px;
&.tabicon {
display: flex;
align-items: center;
width: 22px;
}
.icon {
width: 20px;
height: 20px;
}
i {
padding-left: 3px;
padding-right: 3px;
}
.icon.square-icon {
position: relative;
top: 3px;
width: 16px;
height: 16px;
}
.check-icon {
width: 12px;
height: 12px;
position: absolute;
top: 4px;
left: 4px;
path {
fill: black;
}
}
.status-div {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 3px;
svg.status-icon {
width: 10px;
height: 10px;
}
}
.add-div {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
svg.add-icon {
width: 16px;
height: 16px;
path {
fill: var(--app-text-primary-color);
}
}
}
.text-standard {
color: var(--app-text-secondary-color);
}
.ellipsis {
text-overflow: ellipsis;
}
&:hover {
background-color: rgba(241, 246, 243, 0.08);
}
.icon.color-white + .check-icon {
path {
fill: black;
}
}
}
}
}

View File

@ -53,3 +53,138 @@
} }
} }
} }
.newtab-container {
margin: 8px 16px 0 16px;
.newtab-section {
display: flex;
padding: 10px 16px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
.truncate {
max-width: 100%;
}
&.conn-section {
gap: 8px;
}
}
.cr-help-text {
color: var(--screen-view-text-caption-color);
margin-left: 5px;
}
.newtab-spacer {
height: 1px;
background: var(--app-border-color);
}
.control-iconlist {
display: flex;
margin-left: -2px;
padding: 8px 0 8px 2px;
align-items: flex-start;
gap: 14px;
&.tabicon-list {
gap: 12px;
}
.icondiv {
width: 20px;
height: 20px;
cursor: pointer;
position: relative;
font-size: 14px;
&.tabicon {
display: flex;
align-items: center;
width: 22px;
}
.icon {
width: 20px;
height: 20px;
}
i {
padding-left: 3px;
padding-right: 3px;
}
.icon.square-icon {
position: relative;
top: 3px;
width: 16px;
height: 16px;
}
.check-icon {
width: 12px;
height: 12px;
position: absolute;
top: 4px;
left: 4px;
path {
fill: black;
}
}
.status-div {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 3px;
svg.status-icon {
width: 10px;
height: 10px;
}
}
.add-div {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
svg.add-icon {
width: 16px;
height: 16px;
path {
fill: var(--app-text-primary-color);
}
}
}
.text-standard {
color: var(--app-text-secondary-color);
}
.ellipsis {
text-overflow: ellipsis;
}
&:hover {
background-color: rgba(241, 246, 243, 0.08);
}
.icon.color-white + .check-icon {
path {
fill: black;
}
}
}
}
}

View File

@ -13,7 +13,6 @@ import { CmdInput } from "./cmdinput/cmdinput";
import { ScreenView } from "./screen/screenview"; import { ScreenView } from "./screen/screenview";
import { ScreenTabs } from "./screen/tabs"; import { ScreenTabs } from "./screen/tabs";
import { ErrorBoundary } from "@/common/error/errorboundary"; import { ErrorBoundary } from "@/common/error/errorboundary";
import * as textmeasure from "@/util/textmeasure";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import type { Screen } from "@/models"; import type { Screen } from "@/models";
import { Button, StyleBlock } from "@/elements"; import { Button, StyleBlock } from "@/elements";
@ -34,7 +33,7 @@ Are you sure you want to delete this tab?
class SessionKeybindings extends React.Component<{}, {}> { class SessionKeybindings extends React.Component<{}, {}> {
componentDidMount() { componentDidMount() {
let keybindManager = GlobalModel.keybindManager; const keybindManager = GlobalModel.keybindManager;
keybindManager.registerKeybinding("mainview", "session", "app:toggleSidebar", (waveEvent) => { keybindManager.registerKeybinding("mainview", "session", "app:toggleSidebar", (waveEvent) => {
GlobalModel.handleToggleSidebar(); GlobalModel.handleToggleSidebar();
return true; return true;
@ -96,7 +95,7 @@ class SessionKeybindings extends React.Component<{}, {}> {
@mobxReact.observer @mobxReact.observer
class TabSettingsPulldownKeybindings extends React.Component<{}, {}> { class TabSettingsPulldownKeybindings extends React.Component<{}, {}> {
componentDidMount() { componentDidMount() {
let keybindManager = GlobalModel.keybindManager; const keybindManager = GlobalModel.keybindManager;
keybindManager.registerKeybinding("pane", "tabsettings", "generic:cancel", (waveEvent) => { keybindManager.registerKeybinding("pane", "tabsettings", "generic:cancel", (waveEvent) => {
GlobalModel.closeTabSettings(); GlobalModel.closeTabSettings();
return true; return true;
@ -127,13 +126,13 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
GlobalModel.modalsModel.popModal(); GlobalModel.modalsModel.popModal();
return; return;
} }
let message = ScreenDeleteMessage; const message = ScreenDeleteMessage;
let alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true }); const alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true });
alertRtn.then((result) => { alertRtn.then((result) => {
if (!result) { if (!result) {
return; return;
} }
let prtn = GlobalCommandRunner.screenDelete(screen.screenId, false); const prtn = GlobalCommandRunner.screenDelete(screen.screenId, false);
util.commandRtnHandler(prtn, this.errorMessage); util.commandRtnHandler(prtn, this.errorMessage);
GlobalModel.modalsModel.popModal(); GlobalModel.modalsModel.popModal();
}); });
@ -151,8 +150,8 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
} }
render() { render() {
let { screen } = this.props; const { screen } = this.props;
let rptr = screen.curRemote.get(); const rptr = screen.curRemote.get();
const termThemes = getTermThemes(GlobalModel.termThemes); const termThemes = getTermThemes(GlobalModel.termThemes);
const currTermTheme = GlobalModel.getTermTheme()[screen.screenId] ?? termThemes[0].label; const currTermTheme = GlobalModel.getTermTheme()[screen.screenId] ?? termThemes[0].label;
return ( return (
@ -162,13 +161,13 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
</div> </div>
<div className="newtab-spacer" /> <div className="newtab-spacer" />
<div className="newtab-section conn-section"> <div className="newtab-section conn-section">
<div className="unselectable"> <div className="unselectable truncate">
You're connected to "{getRemoteStrWithAlias(rptr)}". Do you want to change it? You're connected to "{getRemoteStrWithAlias(rptr)}". Do you want to change it?
</div> </div>
<div> <div>
<TabRemoteSelector screen={screen} errorMessage={this.errorMessage} /> <TabRemoteSelector screen={screen} errorMessage={this.errorMessage} />
</div> </div>
<div className="text-caption cr-help-text"> <div className="text-caption cr-help-text truncate">
To change connection from the command line use `cr [alias|user@host]` To change connection from the command line use `cr [alias|user@host]`
</div> </div>
</div> </div>
@ -218,18 +217,13 @@ class WorkspaceView extends React.Component<{}, {}> {
} }
render() { render() {
const model = GlobalModel; const session = GlobalModel.getActiveSession();
const session = model.getActiveSession();
let activeScreen: Screen = null; let activeScreen: Screen = null;
let sessionId: string = "none"; let sessionId: string = "none";
if (session != null) { if (session != null) {
sessionId = session.sessionId; sessionId = session.sessionId;
activeScreen = session.getActiveScreen(); activeScreen = session.getActiveScreen();
} }
let cmdInputHeight = model.inputModel.cmdInputHeight.get();
if (cmdInputHeight == 0) {
cmdInputHeight = textmeasure.baseCmdInputHeight(GlobalModel.lineHeightEnv); // this is the base size of cmdInput (measured using devtools)
}
const isHidden = GlobalModel.activeMainView.get() != "session"; const isHidden = GlobalModel.activeMainView.get() != "session";
const mainSidebarModel = GlobalModel.mainSidebarModel; const mainSidebarModel = GlobalModel.mainSidebarModel;
const showTabSettings = GlobalModel.tabSettingsOpen.get(); const showTabSettings = GlobalModel.tabSettingsOpen.get();
@ -258,9 +252,9 @@ class WorkspaceView extends React.Component<{}, {}> {
<ScreenTabs key={"tabs-" + sessionId} session={session} /> <ScreenTabs key={"tabs-" + sessionId} session={session} />
<If condition={activeScreen != null}> <If condition={activeScreen != null}>
<div key="pulldown" className={cn("tab-settings-pulldown", { closed: !showTabSettings })}> <div key="pulldown" className={cn("tab-settings-pulldown", { closed: !showTabSettings })}>
<div className="close-icon" onClick={this.toggleTabSettings}> <button className="close-icon" onClick={this.toggleTabSettings}>
<i className="fa-solid fa-sharp fa-xmark-large" /> <i className="fa-solid fa-sharp fa-xmark-large" />
</div> </button>
<TabSettings key={activeScreen.screenId} screen={activeScreen} /> <TabSettings key={activeScreen.screenId} screen={activeScreen} />
<If condition={showTabSettings && !isHidden}> <If condition={showTabSettings && !isHidden}>
<TabSettingsPulldownKeybindings /> <TabSettingsPulldownKeybindings />
@ -269,7 +263,6 @@ class WorkspaceView extends React.Component<{}, {}> {
</If> </If>
<ErrorBoundary key="eb"> <ErrorBoundary key="eb">
<ScreenView key={`screenview-${sessionId}`} session={session} screen={activeScreen} /> <ScreenView key={`screenview-${sessionId}`} session={session} screen={activeScreen} />
<div className="cmdinput-height-placeholder" style={{ height: cmdInputHeight }}></div>
<If condition={activeScreen != null}> <If condition={activeScreen != null}>
<CmdInput key={"cmdinput-" + sessionId} /> <CmdInput key={"cmdinput-" + sessionId} />
</If> </If>

View File

@ -8,6 +8,7 @@ import { isBlank } from "@/util/util";
import * as appconst from "@/app/appconst"; import * as appconst from "@/app/appconst";
import { Model } from "./model"; import { Model } from "./model";
import { GlobalCommandRunner } from "./global"; import { GlobalCommandRunner } from "./global";
import { app } from "electron";
function getDefaultHistoryQueryOpts(): HistoryQueryOpts { function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
return { return {
@ -24,14 +25,15 @@ function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
class InputModel { class InputModel {
globalModel: Model; globalModel: Model;
historyShow: OV<boolean> = mobx.observable.box(false); activeAuxView: OV<InputAuxViewType> = mobx.observable.box(null);
infoShow: OV<boolean> = mobx.observable.box(false); auxViewFocus: OV<boolean> = mobx.observable.box(false);
aIChatShow: OV<boolean> = mobx.observable.box(false);
cmdInputHeight: OV<number> = mobx.observable.box(0); cmdInputHeight: OV<number> = mobx.observable.box(0);
aiChatTextAreaRef: React.RefObject<HTMLTextAreaElement>; aiChatTextAreaRef: React.RefObject<HTMLTextAreaElement>;
aiChatWindowRef: React.RefObject<HTMLDivElement>; aiChatWindowRef: React.RefObject<HTMLDivElement>;
codeSelectBlockRefArray: Array<React.RefObject<HTMLElement>>; codeSelectBlockRefArray: Array<React.RefObject<HTMLElement>>;
codeSelectSelectedIndex: OV<number> = mobx.observable.box(-1); codeSelectSelectedIndex: OV<number> = mobx.observable.box(-1);
codeSelectUuid: string;
inputPopUpType: OV<string> = mobx.observable.box("none");
AICmdInfoChatItems: mobx.IObservableArray<OpenAICmdInfoChatMessageType> = mobx.observable.array([], { AICmdInfoChatItems: mobx.IObservableArray<OpenAICmdInfoChatMessageType> = mobx.observable.array([], {
name: "aicmdinfo-chat", name: "aicmdinfo-chat",
@ -80,6 +82,7 @@ class InputModel {
this.codeSelectSelectedIndex.set(-1); this.codeSelectSelectedIndex.set(-1);
this.codeSelectBlockRefArray = []; this.codeSelectBlockRefArray = [];
})(); })();
this.codeSelectUuid = "";
} }
setInputMode(inputMode: null | "comment" | "global"): void { setInputMode(inputMode: null | "comment" | "global"): void {
@ -89,7 +92,7 @@ class InputModel {
} }
toggleHistoryType(): void { toggleHistoryType(): void {
let opts = mobx.toJS(this.historyQueryOpts.get()); const opts = mobx.toJS(this.historyQueryOpts.get());
let htype = opts.queryType; let htype = opts.queryType;
if (htype == "screen") { if (htype == "screen") {
htype = "session"; htype = "session";
@ -102,7 +105,7 @@ class InputModel {
} }
toggleRemoteType(): void { toggleRemoteType(): void {
let opts = mobx.toJS(this.historyQueryOpts.get()); const opts = mobx.toJS(this.historyQueryOpts.get());
if (opts.limitRemote) { if (opts.limitRemote) {
opts.limitRemote = false; opts.limitRemote = false;
opts.limitRemoteInstance = false; opts.limitRemoteInstance = false;
@ -135,26 +138,39 @@ class InputModel {
})(); })();
} }
_focusCmdInput(): void { // Focuses the main input or the auxiliary view, depending on the active auxiliary view
let elem = document.getElementById("main-cmd-input");
if (elem != null) {
elem.focus();
}
}
_focusHistoryInput(): void {
let elem: HTMLElement = document.querySelector(".cmd-input input.history-input");
if (elem != null) {
elem.focus();
}
}
giveFocus(): void { giveFocus(): void {
if (this.historyShow.get()) { // Override active view to the main input if aux view does not have focus
this._focusHistoryInput(); const activeAuxView = this.getAuxViewFocus() ? this.getActiveAuxView() : null;
} else { mobx.action(() => {
this._focusCmdInput(); switch (activeAuxView) {
} case appconst.InputAuxView_History: {
const elem: HTMLElement = document.querySelector(".cmd-input input.history-input");
if (elem != null) {
elem.focus();
}
break;
}
case "aichat":
this.setAIChatFocus();
break;
case null: {
const elem = document.getElementById("main-cmd-input");
if (elem != null) {
elem.focus();
}
this.setPhysicalInputFocused(true);
break;
}
default: {
const elem: HTMLElement = document.querySelector(".cmd-input .auxview");
if (elem != null) {
elem.focus();
}
break;
}
}
})();
} }
setPhysicalInputFocused(isFocused: boolean): void { setPhysicalInputFocused(isFocused: boolean): void {
@ -162,7 +178,7 @@ class InputModel {
this.physicalInputFocused.set(isFocused); this.physicalInputFocused.set(isFocused);
})(); })();
if (isFocused) { if (isFocused) {
let screen = this.globalModel.getActiveScreen(); const screen = this.globalModel.getActiveScreen();
if (screen != null) { if (screen != null) {
if (screen.focusType.get() != "input") { if (screen.focusType.get() != "input") {
GlobalCommandRunner.screenSetFocus("input"); GlobalCommandRunner.screenSetFocus("input");
@ -172,14 +188,18 @@ class InputModel {
} }
hasFocus(): boolean { hasFocus(): boolean {
let mainInputElem = document.getElementById("main-cmd-input"); const mainInputElem = document.getElementById("main-cmd-input");
if (document.activeElement == mainInputElem) { if (document.activeElement == mainInputElem) {
return true; return true;
} }
let historyInputElem = document.querySelector(".cmd-input input.history-input"); const historyInputElem = document.querySelector(".cmd-input input.history-input");
if (document.activeElement == historyInputElem) { if (document.activeElement == historyInputElem) {
return true; return true;
} }
let aiChatInputElem = document.querySelector(".cmd-input chat-cmd-input");
if (document.activeElement == aiChatInputElem) {
return true;
}
return false; return false;
} }
@ -194,20 +214,19 @@ class InputModel {
if (oldItem == null) { if (oldItem == null) {
return 0; return 0;
} }
let newItems = this.getFilteredHistoryItems(); const newItems = this.getFilteredHistoryItems();
if (newItems.length == 0) { if (newItems.length == 0) {
return 0; return 0;
} }
let bestIdx = 0; let bestIdx = 0;
for (let i = 0; i < newItems.length; i++) { for (const [i, item] of newItems.entries()) {
// still start at i=0 to catch the historynum equality case // still start at i=0 to catch the historynum equality case
let item = newItems[i];
if (item.historynum == oldItem.historynum) { if (item.historynum == oldItem.historynum) {
bestIdx = i; bestIdx = i;
break; break;
} }
let bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts); const bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts);
let curTsDiff = Math.abs(item.ts - oldItem.ts); const curTsDiff = Math.abs(item.ts - oldItem.ts);
if (curTsDiff < bestTsDiff) { if (curTsDiff < bestTsDiff) {
bestIdx = i; bestIdx = i;
} }
@ -217,9 +236,9 @@ class InputModel {
setHistoryQueryOpts(opts: HistoryQueryOpts): void { setHistoryQueryOpts(opts: HistoryQueryOpts): void {
mobx.action(() => { mobx.action(() => {
let oldItem = this.getHistorySelectedItem(); const oldItem = this.getHistorySelectedItem();
this.historyQueryOpts.set(opts); this.historyQueryOpts.set(opts);
let bestIndex = this.findBestNewIndex(oldItem); const bestIndex = this.findBestNewIndex(oldItem);
setTimeout(() => this.setHistoryIndex(bestIndex, true), 10); setTimeout(() => this.setHistoryIndex(bestIndex, true), 10);
})(); })();
} }
@ -229,23 +248,11 @@ class InputModel {
this.codeSelectBlockRefArray = []; this.codeSelectBlockRefArray = [];
} }
setHistoryShow(show: boolean): void {
if (this.historyShow.get() == show) {
return;
}
mobx.action(() => {
this.historyShow.set(show);
if (this.hasFocus()) {
this.giveFocus();
}
})();
}
isHistoryLoaded(): boolean { isHistoryLoaded(): boolean {
if (this.historyLoading.get()) { if (this.historyLoading.get()) {
return false; return false;
} }
let hitems = this.historyItems.get(); const hitems = this.historyItems.get();
return hitems != null; return hitems != null;
} }
@ -273,13 +280,9 @@ class InputModel {
this.loadHistory(true, 0, "screen"); this.loadHistory(true, 0, "screen");
return; return;
} }
if (!this.historyShow.get()) { if (this.getActiveAuxView() != appconst.InputAuxView_History) {
mobx.action(() => { this.dropModHistory(true);
this.setHistoryShow(true); this.setActiveAuxView(appconst.InputAuxView_History);
this.infoShow.set(false);
this.dropModHistory(true);
this.giveFocus();
})();
} }
} }
@ -293,11 +296,11 @@ class InputModel {
} }
getHistorySelectedItem(): HistoryItem { getHistorySelectedItem(): HistoryItem {
let hidx = this.historyIndex.get(); const hidx = this.historyIndex.get();
if (hidx == 0) { if (hidx == 0) {
return null; return null;
} }
let hitems = this.getFilteredHistoryItems(); const hitems = this.getFilteredHistoryItems();
if (hidx > hitems.length) { if (hidx > hitems.length) {
return null; return null;
} }
@ -305,7 +308,7 @@ class InputModel {
} }
getFirstHistoryItem(): HistoryItem { getFirstHistoryItem(): HistoryItem {
let hitems = this.getFilteredHistoryItems(); const hitems = this.getFilteredHistoryItems();
if (hitems.length == 0) { if (hitems.length == 0) {
return null; return null;
} }
@ -313,9 +316,9 @@ class InputModel {
} }
setHistorySelectionNum(hnum: string): void { setHistorySelectionNum(hnum: string): void {
let hitems = this.getFilteredHistoryItems(); const hitems = this.getFilteredHistoryItems();
for (let i = 0; i < hitems.length; i++) { for (const [i, hitem] of hitems.entries()) {
if (hitems[i].historynum == hnum) { if (hitem.historynum == hnum) {
this.setHistoryIndex(i + 1); this.setHistoryIndex(i + 1);
return; return;
} }
@ -324,8 +327,8 @@ class InputModel {
setHistoryInfo(hinfo: HistoryInfoType): void { setHistoryInfo(hinfo: HistoryInfoType): void {
mobx.action(() => { mobx.action(() => {
let oldItem = this.getHistorySelectedItem(); const oldItem = this.getHistorySelectedItem();
let hitems: HistoryItem[] = hinfo.items ?? []; const hitems: HistoryItem[] = hinfo.items ?? [];
this.historyItems.set(hitems); this.historyItems.set(hitems);
this.historyLoading.set(false); this.historyLoading.set(false);
this.historyQueryOpts.get().queryType = hinfo.historytype; this.historyQueryOpts.get().queryType = hinfo.historytype;
@ -334,7 +337,7 @@ class InputModel {
this.historyQueryOpts.get().limitRemoteInstance = false; this.historyQueryOpts.get().limitRemoteInstance = false;
} }
if (this.historyAfterLoadIndex == -1) { if (this.historyAfterLoadIndex == -1) {
let bestIndex = this.findBestNewIndex(oldItem); const bestIndex = this.findBestNewIndex(oldItem);
setTimeout(() => this.setHistoryIndex(bestIndex, true), 100); setTimeout(() => this.setHistoryIndex(bestIndex, true), 100);
} else if (this.historyAfterLoadIndex) { } else if (this.historyAfterLoadIndex) {
if (hitems.length >= this.historyAfterLoadIndex) { if (hitems.length >= this.historyAfterLoadIndex) {
@ -353,10 +356,10 @@ class InputModel {
} }
_getFilteredHistoryItems(): HistoryItem[] { _getFilteredHistoryItems(): HistoryItem[] {
let hitems: HistoryItem[] = this.historyItems.get() ?? []; const hitems: HistoryItem[] = this.historyItems.get() ?? [];
let rtn: HistoryItem[] = []; const rtn: HistoryItem[] = [];
let opts = mobx.toJS(this.historyQueryOpts.get()); const opts: HistoryQueryOpts = mobx.toJS(this.historyQueryOpts.get());
let ctx = this.globalModel.getUIContext(); const ctx = this.globalModel.getUIContext();
let curRemote: RemotePtrType = ctx.remote; let curRemote: RemotePtrType = ctx.remote;
if (curRemote == null) { if (curRemote == null) {
curRemote = { ownerid: "", name: "", remoteid: "" }; curRemote = { ownerid: "", name: "", remoteid: "" };
@ -393,7 +396,7 @@ class InputModel {
if (isBlank(hitem.cmdstr)) { if (isBlank(hitem.cmdstr)) {
continue; continue;
} }
let idx = hitem.cmdstr.indexOf(opts.queryStr); const idx = hitem.cmdstr.indexOf(opts.queryStr);
if (idx == -1) { if (idx == -1) {
continue; continue;
} }
@ -405,24 +408,24 @@ class InputModel {
} }
scrollHistoryItemIntoView(hnum: string): void { scrollHistoryItemIntoView(hnum: string): void {
let elem: HTMLElement = document.querySelector(".cmd-history .hnum-" + hnum); const elem: HTMLElement = document.querySelector(".cmd-history .hnum-" + hnum);
if (elem == null) { if (elem == null) {
return; return;
} }
let historyDiv = elem.closest(".cmd-history"); const historyDiv = elem.closest(".cmd-history");
if (historyDiv == null) { if (historyDiv == null) {
return; return;
} }
let buffer = 15; const buffer = 15;
let titleHeight = 24; let titleHeight = 24;
let titleDiv: HTMLElement = document.querySelector(".cmd-history .history-title"); const titleDiv: HTMLElement = document.querySelector(".cmd-history .history-title");
if (titleDiv != null) { if (titleDiv != null) {
titleHeight = titleDiv.offsetHeight + 2; titleHeight = titleDiv.offsetHeight + 2;
} }
let elemOffset = elem.offsetTop; const elemOffset = elem.offsetTop;
let elemHeight = elem.clientHeight; const elemHeight = elem.clientHeight;
let topPos = historyDiv.scrollTop; const topPos = historyDiv.scrollTop;
let endPos = topPos + historyDiv.clientHeight; const endPos = topPos + historyDiv.clientHeight;
if (elemOffset + elemHeight + buffer > endPos) { if (elemOffset + elemHeight + buffer > endPos) {
if (elemHeight + buffer > historyDiv.clientHeight - titleHeight) { if (elemHeight + buffer > historyDiv.clientHeight - titleHeight) {
historyDiv.scrollTop = elemOffset - titleHeight; historyDiv.scrollTop = elemOffset - titleHeight;
@ -441,7 +444,7 @@ class InputModel {
} }
grabSelectedHistoryItem(): void { grabSelectedHistoryItem(): void {
let hitem = this.getHistorySelectedItem(); const hitem = this.getHistorySelectedItem();
if (hitem == null) { if (hitem == null) {
this.resetHistory(); this.resetHistory();
return; return;
@ -452,6 +455,51 @@ class InputModel {
})(); })();
} }
// Closes the auxiliary view if it is open, focuses the main input
closeAuxView(): void {
if (this.activeAuxView.get() == null) {
return;
}
this.setActiveAuxView(null);
}
// Gets the active auxiliary view, or null if none
getActiveAuxView(): InputAuxViewType {
return this.activeAuxView.get();
}
// Sets the active auxiliary view
setActiveAuxView(view: InputAuxViewType): void {
if (view == this.activeAuxView.get()) {
return;
}
mobx.action(() => {
this.auxViewFocus.set(view != null);
this.activeAuxView.set(view);
})();
this.giveFocus();
}
// Gets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus.
// If the auxiliary view is not open, this will return false.
getAuxViewFocus(): boolean {
if (this.getActiveAuxView() == null) {
return false;
}
return this.auxViewFocus.get();
}
// Sets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus.
setAuxViewFocus(focus: boolean): void {
if (this.getAuxViewFocus() == focus) {
return;
}
mobx.action(() => {
this.auxViewFocus.set(focus);
})();
this.giveFocus();
}
setHistoryIndex(hidx: number, force?: boolean): void { setHistoryIndex(hidx: number, force?: boolean): void {
if (hidx < 0) { if (hidx < 0) {
return; return;
@ -461,7 +509,7 @@ class InputModel {
} }
mobx.action(() => { mobx.action(() => {
this.historyIndex.set(hidx); this.historyIndex.set(hidx);
if (this.historyShow.get()) { if (this.getActiveAuxView() == appconst.InputAuxView_History) {
let hitem = this.getHistorySelectedItem(); let hitem = this.getHistorySelectedItem();
if (hitem == null) { if (hitem == null) {
hitem = this.getFirstHistoryItem(); hitem = this.getFirstHistoryItem();
@ -480,9 +528,8 @@ class InputModel {
if (!this.isHistoryLoaded()) { if (!this.isHistoryLoaded()) {
return; return;
} }
let hitems = this.getFilteredHistoryItems(); const hitems = this.getFilteredHistoryItems();
let idx = this.historyIndex.get(); let idx = this.historyIndex.get() + amt;
idx += amt;
if (idx < 0) { if (idx < 0) {
idx = 0; idx = 0;
} }
@ -496,16 +543,18 @@ class InputModel {
this._clearInfoTimeout(); this._clearInfoTimeout();
mobx.action(() => { mobx.action(() => {
this.infoMsg.set(info); this.infoMsg.set(info);
if (info == null) {
this.infoShow.set(false);
} else {
this.infoShow.set(true);
this.setHistoryShow(false);
}
})(); })();
if (info == null && this.getActiveAuxView() == appconst.InputAuxView_Info) {
this.setActiveAuxView(null);
} else {
this.setActiveAuxView(appconst.InputAuxView_Info);
}
if (info != null && timeoutMs) { if (info != null && timeoutMs) {
this.infoTimeoutId = setTimeout(() => { this.infoTimeoutId = setTimeout(() => {
if (this.historyShow.get()) { console.log("clearing info msg");
if (this.activeAuxView.get() != appconst.InputAuxView_Info) {
return; return;
} }
this.clearInfoMsg(false); this.clearInfoMsg(false);
@ -532,16 +581,19 @@ class InputModel {
this.codeSelectSelectedIndex.get() >= 0 && this.codeSelectSelectedIndex.get() >= 0 &&
this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length
) { ) {
let curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()]; const curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()];
let codeText = curBlockRef.current.innerText; const codeText = curBlockRef.current.innerText.replace(/\n$/, ""); // remove trailing newline
codeText = codeText.replace(/\n$/, ""); // remove trailing newline
this.setCurLine(codeText); this.setCurLine(codeText);
this.giveFocus(); this.giveFocus();
} }
} }
addCodeBlockToCodeSelect(blockRef: React.RefObject<HTMLElement>): number { addCodeBlockToCodeSelect(blockRef: React.RefObject<HTMLElement>, uuid: string): number {
let rtn = -1; let rtn = -1;
if (uuid != this.codeSelectUuid) {
this.codeSelectUuid = uuid;
this.codeSelectBlockRefArray = [];
}
rtn = this.codeSelectBlockRefArray.length; rtn = this.codeSelectBlockRefArray.length;
this.codeSelectBlockRefArray.push(blockRef); this.codeSelectBlockRefArray.push(blockRef);
return rtn; return rtn;
@ -551,23 +603,21 @@ class InputModel {
mobx.action(() => { mobx.action(() => {
if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) { if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) {
this.codeSelectSelectedIndex.set(blockIndex); this.codeSelectSelectedIndex.set(blockIndex);
let currentRef = this.codeSelectBlockRefArray[blockIndex].current; const currentRef = this.codeSelectBlockRefArray[blockIndex].current;
if (currentRef != null) { if (currentRef != null && this.aiChatWindowRef?.current != null) {
if (this.aiChatWindowRef?.current != null) { const chatWindowTop = this.aiChatWindowRef.current.scrollTop;
let chatWindowTop = this.aiChatWindowRef.current.scrollTop; const chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100;
let chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100; const elemTop = currentRef.offsetTop;
let elemTop = currentRef.offsetTop; let elemBottom = elemTop - currentRef.offsetHeight;
let elemBottom = elemTop - currentRef.offsetHeight; const elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop;
let elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop; if (!elementIsInView) {
if (!elementIsInView) { this.aiChatWindowRef.current.scrollTop =
this.aiChatWindowRef.current.scrollTop = elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
}
} }
} }
this.codeSelectBlockRefArray = [];
this.setAIChatFocus();
} }
this.codeSelectBlockRefArray = [];
this.setAIChatFocus();
})(); })();
} }
@ -580,7 +630,7 @@ class InputModel {
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) { } else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
return; return;
} }
let incBlockIndex = this.codeSelectSelectedIndex.get() + 1; const incBlockIndex = this.codeSelectSelectedIndex.get() + 1;
if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) { if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) {
this.codeSelectDeselectAll(); this.codeSelectDeselectAll();
if (this.aiChatWindowRef?.current != null) { if (this.aiChatWindowRef?.current != null) {
@ -604,7 +654,7 @@ class InputModel {
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) { } else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
return; return;
} }
let decBlockIndex = this.codeSelectSelectedIndex.get() - 1; const decBlockIndex = this.codeSelectSelectedIndex.get() - 1;
if (decBlockIndex < 0) { if (decBlockIndex < 0) {
this.codeSelectDeselectAll(this.codeSelectTop); this.codeSelectDeselectAll(this.codeSelectTop);
if (this.aiChatWindowRef?.current != null) { if (this.aiChatWindowRef?.current != null) {
@ -640,28 +690,11 @@ class InputModel {
} }
openAIAssistantChat(): void { openAIAssistantChat(): void {
mobx.action(() => { this.setActiveAuxView(appconst.InputAuxView_AIChat);
this.aIChatShow.set(true);
this.setAIChatFocus();
})();
}
// pass true to give focus to the input (e.g. if this is an 'active' close of the chat)
// when resetting the input (when switching screens, don't give focus)
closeAIAssistantChat(giveFocus: boolean): void {
if (!this.aIChatShow.get()) {
return;
}
mobx.action(() => {
this.aIChatShow.set(false);
if (giveFocus) {
this.giveFocus();
}
})();
} }
clearAIAssistantChat(): void { clearAIAssistantChat(): void {
let prtn = this.globalModel.submitChatInfoCommand("", "", true); const prtn = this.globalModel.submitChatInfoCommand("", "", true);
prtn.then((rtn) => { prtn.then((rtn) => {
if (!rtn.success) { if (!rtn.success) {
console.log("submit chat command error: " + rtn.error); console.log("submit chat command error: " + rtn.error);
@ -672,14 +705,14 @@ class InputModel {
} }
hasScrollingInfoMsg(): boolean { hasScrollingInfoMsg(): boolean {
if (!this.infoShow.get()) { if (this.activeAuxView.get() !== appconst.InputAuxView_Info) {
return false; return false;
} }
let info = this.infoMsg.get(); const info = this.infoMsg.get();
if (info == null) { if (info == null) {
return false; return false;
} }
let div = document.querySelector(".cmd-input-info"); const div = document.querySelector(".cmd-input-info");
if (div == null) { if (div == null) {
return false; return false;
} }
@ -695,9 +728,11 @@ class InputModel {
clearInfoMsg(setNull: boolean): void { clearInfoMsg(setNull: boolean): void {
this._clearInfoTimeout(); this._clearInfoTimeout();
if (this.getActiveAuxView() == appconst.InputAuxView_Info) {
this.setActiveAuxView(null);
}
mobx.action(() => { mobx.action(() => {
this.setHistoryShow(false);
this.infoShow.set(false);
if (setNull) { if (setNull) {
this.infoMsg.set(null); this.infoMsg.set(null);
} }
@ -706,26 +741,17 @@ class InputModel {
toggleInfoMsg(): void { toggleInfoMsg(): void {
this._clearInfoTimeout(); this._clearInfoTimeout();
mobx.action(() => { if (this.activeAuxView.get() == appconst.InputAuxView_Info) {
if (this.historyShow.get()) { this.setActiveAuxView(null);
this.setHistoryShow(false); } else if (this.infoMsg.get() != null) {
return; this.setActiveAuxView(appconst.InputAuxView_Info);
} }
let isShowing = this.infoShow.get();
if (isShowing) {
this.infoShow.set(false);
} else {
if (this.infoMsg.get() != null) {
this.infoShow.set(true);
}
}
})();
} }
@boundMethod @boundMethod
uiSubmitCommand(): void { uiSubmitCommand(): void {
mobx.action(() => { mobx.action(() => {
let commandStr = this.getCurLine(); const commandStr = this.getCurLine();
if (commandStr.trim() == "") { if (commandStr.trim() == "") {
return; return;
} }
@ -746,7 +772,7 @@ class InputModel {
} }
setCurLine(val: string): void { setCurLine(val: string): void {
let hidx = this.historyIndex.get(); const hidx = this.historyIndex.get();
mobx.action(() => { mobx.action(() => {
if (this.modHistory.length <= hidx) { if (this.modHistory.length <= hidx) {
this.modHistory.length = hidx + 1; this.modHistory.length = hidx + 1;
@ -757,9 +783,7 @@ class InputModel {
resetInput(): void { resetInput(): void {
mobx.action(() => { mobx.action(() => {
this.setHistoryShow(false); this.setActiveAuxView(null);
this.closeAIAssistantChat(false);
this.infoShow.set(false);
this.inputMode.set(null); this.inputMode.set(null);
this.resetHistory(); this.resetHistory();
this.dropModHistory(false); this.dropModHistory(false);
@ -778,15 +802,15 @@ class InputModel {
} }
getCurLine(): string { getCurLine(): string {
let hidx = this.historyIndex.get(); const hidx = this.historyIndex.get();
if (hidx < this.modHistory.length && this.modHistory[hidx] != null) { if (hidx < this.modHistory.length && this.modHistory[hidx] != null) {
return this.modHistory[hidx]; return this.modHistory[hidx];
} }
let hitems = this.getFilteredHistoryItems(); const hitems = this.getFilteredHistoryItems();
if (hidx == 0 || hitems == null || hidx > hitems.length) { if (hidx == 0 || hitems == null || hidx > hitems.length) {
return ""; return "";
} }
let hitem = hitems[hidx - 1]; const hitem = hitems[hidx - 1];
if (hitem == null) { if (hitem == null) {
return ""; return "";
} }
@ -807,7 +831,9 @@ class InputModel {
resetHistory(): void { resetHistory(): void {
mobx.action(() => { mobx.action(() => {
this.setHistoryShow(false); if (this.getActiveAuxView() == appconst.InputAuxView_History) {
this.setActiveAuxView(null);
}
this.historyLoading.set(false); this.historyLoading.set(false);
this.historyType.set("screen"); this.historyType.set("screen");
this.historyItems.set(null); this.historyItems.set(null);

View File

@ -24,6 +24,10 @@ class ModalsModel {
})(); })();
callback && callback(); callback && callback();
} }
hasOpenModals(): boolean {
return this.store.length > 0;
}
} }
export { ModalsModel }; export { ModalsModel };

View File

@ -205,6 +205,7 @@ class Model {
getApi().onNativeThemeUpdated(this.onNativeThemeUpdated.bind(this)); getApi().onNativeThemeUpdated(this.onNativeThemeUpdated.bind(this));
document.addEventListener("keydown", this.docKeyDownHandler.bind(this)); document.addEventListener("keydown", this.docKeyDownHandler.bind(this));
document.addEventListener("selectionchange", this.docSelectionChangeHandler.bind(this)); document.addEventListener("selectionchange", this.docSelectionChangeHandler.bind(this));
window.addEventListener("focus", this.windowFocus.bind(this));
setTimeout(() => this.getClientDataLoop(1), 10); setTimeout(() => this.getClientDataLoop(1), 10);
this.lineHeightEnv = { this.lineHeightEnv = {
// defaults // defaults
@ -241,6 +242,12 @@ class Model {
}); });
} }
windowFocus(): void {
if (this.activeMainView.get() == "session" && !this.modalsModel.hasOpenModals()) {
this.refocus();
}
}
fetchTerminalThemes() { fetchTerminalThemes() {
const url = new URL(this.getBaseHostPort() + "/config/terminal-themes"); const url = new URL(this.getBaseHostPort() + "/config/terminal-themes");
fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() }) fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })
@ -828,7 +835,6 @@ class Model {
} }
onMetaArrowDown(): void { onMetaArrowDown(): void {
console.log("meta arrow down?");
GlobalCommandRunner.screenSelectLine("+1"); GlobalCommandRunner.screenSelectLine("+1");
} }
@ -841,7 +847,6 @@ class Model {
} }
onSwitchSessionCmd(digit: number) { onSwitchSessionCmd(digit: number) {
console.log("switching to ", digit);
GlobalCommandRunner.switchSession(String(digit)); GlobalCommandRunner.switchSession(String(digit));
} }
@ -1089,7 +1094,7 @@ class Model {
this.ws.watchScreen(newActiveSessionId, newActiveScreenId); this.ws.watchScreen(newActiveSessionId, newActiveScreenId);
this.closeTabSettings(); this.closeTabSettings();
const activeScreen = this.getActiveScreen(); const activeScreen = this.getActiveScreen();
if (activeScreen != null && activeScreen.getCurRemoteInstance() != null) { if (activeScreen?.getCurRemoteInstance() != null) {
setTimeout(() => { setTimeout(() => {
GlobalCommandRunner.syncShellState(); GlobalCommandRunner.syncShellState();
}, 100); }, 100);

View File

@ -15,6 +15,7 @@ declare global {
type LineContainerStrs = "main" | "sidebar" | "history"; type LineContainerStrs = "main" | "sidebar" | "history";
type AppUpdateStatusType = "unavailable" | "ready"; type AppUpdateStatusType = "unavailable" | "ready";
type NativeThemeSource = "system" | "light" | "dark"; type NativeThemeSource = "system" | "light" | "dark";
type InputAuxViewType = null | "history" | "info" | "aichat";
type OV<V> = mobx.IObservableValue<V>; type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>; type OArr<V> = mobx.IObservableArray<V>;

View File

@ -23,7 +23,7 @@ function getMonoFontSize(fontSize: number): MonoFontSize {
if (MonoFontSizes[fontSize] != null) { if (MonoFontSizes[fontSize] != null) {
return MonoFontSizes[fontSize]; return MonoFontSizes[fontSize];
} }
let size = measureText("W", { pre: true, mono: true, fontSize: fontSize }); const size = measureText("W", { pre: true, mono: true, fontSize: fontSize });
if (size.height != 0 && size.width != 0) { if (size.height != 0 && size.width != 0) {
MonoFontSizes[fontSize] = size; MonoFontSizes[fontSize] = size;
} }
@ -38,7 +38,7 @@ function measureText(text: string, textOpts: { pre?: boolean; mono?: boolean; fo
if (textOpts == null) { if (textOpts == null) {
throw new Error("invalid textOpts passed to measureText (null)"); throw new Error("invalid textOpts passed to measureText (null)");
} }
let textElem = document.createElement("span"); const textElem = document.createElement("span");
if (textOpts.pre) { if (textOpts.pre) {
textElem.classList.add("pre"); textElem.classList.add("pre");
} }
@ -53,19 +53,19 @@ function measureText(text: string, textOpts: { pre?: boolean; mono?: boolean; fo
} }
} }
textElem.innerText = text; textElem.innerText = text;
let measureDiv = document.getElementById("measure"); const measureDiv = document.getElementById("measure");
if (measureDiv == null) { if (measureDiv == null) {
throw new Error("cannot measure text, no #measure div"); throw new Error("cannot measure text, no #measure div");
} }
measureDiv.replaceChildren(textElem); measureDiv.replaceChildren(textElem);
let height = Math.ceil(textElem.offsetHeight); const height = Math.ceil(textElem.offsetHeight);
let width = textElem.offsetWidth; const width = textElem.offsetWidth;
let pad = Math.floor(height / 2); const pad = Math.floor(height / 2);
return { width, height, pad, fontSize: textOpts.fontSize }; return { width, height, pad, fontSize: textOpts.fontSize };
} }
function windowWidthToCols(width: number, fontSize: number): number { function windowWidthToCols(width: number, fontSize: number): number {
let dr = getMonoFontSize(fontSize); const dr = getMonoFontSize(fontSize);
let cols = Math.trunc((width - MagicLayout.ScreenMaxContentWidthBuffer) / dr.width) - 1; let cols = Math.trunc((width - MagicLayout.ScreenMaxContentWidthBuffer) / dr.width) - 1;
cols = boundInt(cols, MinTermCols, MaxTermCols); cols = boundInt(cols, MinTermCols, MaxTermCols);
return cols; return cols;
@ -80,7 +80,7 @@ function windowHeightToRows(lhe: LineHeightEnv, height: number): number {
} }
function termWidthFromCols(cols: number, fontSize: number): number { function termWidthFromCols(cols: number, fontSize: number): number {
let dr = getMonoFontSize(fontSize); const dr = getMonoFontSize(fontSize);
return Math.ceil(dr.width * cols) + MagicLayout.TermWidthBuffer; return Math.ceil(dr.width * cols) + MagicLayout.TermWidthBuffer;
} }
@ -89,12 +89,12 @@ function termWidthFromCols(cols: number, fontSize: number): number {
// works out to `realHeight = round(ceil(height * dpr) * rows / dpr) / rows` // works out to `realHeight = round(ceil(height * dpr) * rows / dpr) / rows`
// their calculation is based off the "totalRows" (so that argument has been added) // their calculation is based off the "totalRows" (so that argument has been added)
function termHeightFromRows(rows: number, fontSize: number, totalRows: number): number { function termHeightFromRows(rows: number, fontSize: number, totalRows: number): number {
let dr = getMonoFontSize(fontSize); const dr = getMonoFontSize(fontSize);
const dpr = window.devicePixelRatio; const dpr = window.devicePixelRatio;
if (totalRows == null || totalRows == 0) { if (totalRows == null || totalRows == 0) {
totalRows = rows > 25 ? rows : 25; totalRows = rows > 25 ? rows : 25;
} }
let realHeight = Math.round((Math.ceil(dr.height * dpr) * totalRows) / dpr) / totalRows; const realHeight = Math.round((Math.ceil(dr.height * dpr) * totalRows) / dpr) / totalRows;
return Math.ceil(realHeight * rows); return Math.ceil(realHeight * rows);
} }

View File

@ -485,6 +485,7 @@ type FileInfo struct {
ModTs int64 `json:"modts"` ModTs int64 `json:"modts"`
IsDir bool `json:"isdir,omitempty"` IsDir bool `json:"isdir,omitempty"`
Perm int `json:"perm"` Perm int `json:"perm"`
MimeType string `json:"mimetype,omitempty"`
NotFound bool `json:"notfound,omitempty"` // when NotFound is set, Perm will be set to permission for directory NotFound bool `json:"notfound,omitempty"` // when NotFound is set, Perm will be set to permission for directory
} }

View File

@ -575,12 +575,14 @@ func (m *MServer) streamFile(pk *packet.StreamFilePacketType) {
m.Sender.SendPacket(resp) m.Sender.SendPacket(resp)
return return
} }
mimeType := utilfn.DetectMimeType(pk.Path)
resp.Info = &packet.FileInfo{ resp.Info = &packet.FileInfo{
Name: pk.Path, Name: pk.Path,
Size: finfo.Size(), Size: finfo.Size(),
ModTs: finfo.ModTime().UnixMilli(), ModTs: finfo.ModTime().UnixMilli(),
IsDir: finfo.IsDir(), IsDir: finfo.IsDir(),
Perm: int(finfo.Mode().Perm()), MimeType: mimeType,
Perm: int(finfo.Mode().Perm()),
} }
if pk.StatOnly { if pk.StatOnly {
resp.Done = true resp.Done = true
@ -748,6 +750,10 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) {
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("invalid shellstate version: %w", err)) m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("invalid shellstate version: %w", err))
return return
} }
if runPacket.Command == "wave:testerror" {
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("test error"))
return
}
ecmd, err := shexec.MakeMShellSingleCmd() ecmd, err := shexec.MakeMShellSingleCmd()
if err != nil { if err != nil {
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require valid ck: %s", err)) m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require valid ck: %s", err))

View File

@ -6,6 +6,7 @@ package shellapi
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"os/exec" "os/exec"
"runtime" "runtime"
@ -266,6 +267,22 @@ func (bashShellApi) MakeShellStateDiff(oldState *packet.ShellState, oldStateHash
return rtn, nil return rtn, nil
} }
func (bashShellApi) ValidateCommandSyntax(cmdStr string) error {
ctx, cancelFn := context.WithTimeout(context.Background(), ValidateTimeout)
defer cancelFn()
cmd := exec.CommandContext(ctx, GetLocalBashPath(), "-n", "-c", cmdStr)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
errStr := utilfn.GetFirstLine(string(output))
errStr = strings.TrimPrefix(errStr, "bash: -c: ")
if len(errStr) == 0 {
return errors.New("bash syntax error")
}
return errors.New(errStr)
}
func (bashShellApi) ApplyShellStateDiff(oldState *packet.ShellState, diff *packet.ShellStateDiff) (*packet.ShellState, error) { func (bashShellApi) ApplyShellStateDiff(oldState *packet.ShellState, diff *packet.ShellStateDiff) (*packet.ShellState, error) {
if oldState == nil { if oldState == nil {
return nil, fmt.Errorf("cannot apply diff, oldState is nil") return nil, fmt.Errorf("cannot apply diff, oldState is nil")

View File

@ -214,9 +214,16 @@ func bashParseDeclareOutput(state *packet.ShellState, declareBytes []byte, pvarB
firstParseErr = err firstParseErr = err
} }
} }
if decl != nil && !BashNoStoreVarNames[decl.Name] { if decl == nil {
declMap[decl.Name] = decl continue
} }
if BashNoStoreVarNames[decl.Name] {
continue
}
if strings.HasPrefix(decl.Name, "_wavetemp_") {
continue
}
declMap[decl.Name] = decl
} }
pvarMap := parseExtVarOutput(pvarBytes, "", "") pvarMap := parseExtVarOutput(pvarBytes, "", "")
utilfn.CombineMaps(declMap, pvarMap) utilfn.CombineMaps(declMap, pvarMap)

View File

@ -28,6 +28,7 @@ import (
) )
const GetVersionTimeout = 5 * time.Second const GetVersionTimeout = 5 * time.Second
const ValidateTimeout = 2 * time.Second
const GetGitBranchCmdStr = `printf "GITBRANCH %s\x00" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"` const GetGitBranchCmdStr = `printf "GITBRANCH %s\x00" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"`
const GetK8sContextCmdStr = `printf "K8SCONTEXT %s\x00" "$(kubectl config current-context 2>/dev/null)"` const GetK8sContextCmdStr = `printf "K8SCONTEXT %s\x00" "$(kubectl config current-context 2>/dev/null)"`
const GetK8sNamespaceCmdStr = `printf "K8SNAMESPACE %s\x00" "$(kubectl config view --minify --output 'jsonpath={..namespace}' 2>/dev/null)"` const GetK8sNamespaceCmdStr = `printf "K8SNAMESPACE %s\x00" "$(kubectl config view --minify --output 'jsonpath={..namespace}' 2>/dev/null)"`
@ -69,6 +70,7 @@ type ShellStateOutput struct {
type ShellApi interface { type ShellApi interface {
GetShellType() string GetShellType() string
MakeExitTrap(fdNum int) (string, []byte) MakeExitTrap(fdNum int) (string, []byte)
ValidateCommandSyntax(cmdStr string) error
GetLocalMajorVersion() string GetLocalMajorVersion() string
GetLocalShellPath() string GetLocalShellPath() string
GetRemoteShellPath() string GetRemoteShellPath() string

View File

@ -211,27 +211,41 @@ type ZshMap = map[ZshParamKey]string
type zshShellApi struct{} type zshShellApi struct{}
func (z zshShellApi) GetShellType() string { func (zshShellApi) GetShellType() string {
return packet.ShellType_zsh return packet.ShellType_zsh
} }
func (z zshShellApi) MakeExitTrap(fdNum int) (string, []byte) { func (zshShellApi) MakeExitTrap(fdNum int) (string, []byte) {
return MakeZshExitTrap(fdNum) return MakeZshExitTrap(fdNum)
} }
func (z zshShellApi) GetLocalMajorVersion() string { func (zshShellApi) GetLocalMajorVersion() string {
return GetLocalZshMajorVersion() return GetLocalZshMajorVersion()
} }
func (z zshShellApi) GetLocalShellPath() string { func (zshShellApi) GetLocalShellPath() string {
return "/bin/zsh" return "/bin/zsh"
} }
func (z zshShellApi) GetRemoteShellPath() string { func (zshShellApi) GetRemoteShellPath() string {
return "zsh" return "zsh"
} }
func (z zshShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string { func (zshShellApi) ValidateCommandSyntax(cmdStr string) error {
ctx, cancelFn := context.WithTimeout(context.Background(), ValidateTimeout)
defer cancelFn()
cmd := exec.CommandContext(ctx, GetLocalZshPath(), "-n", "-c", cmdStr)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
if len(output) == 0 {
return errors.New("zsh syntax error")
}
return errors.New(utilfn.GetFirstLine(string(output)))
}
func (zshShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string {
if !opts.Sudo { if !opts.Sudo {
return cmdStr return cmdStr
} }
@ -242,7 +256,7 @@ func (z zshShellApi) MakeRunCommand(cmdStr string, opts RunCommandOpts) string {
} }
} }
func (z zshShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd { func (zshShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd {
return exec.Command(GetLocalZshPath(), "-l", "-i", "-c", cmdStr) return exec.Command(GetLocalZshPath(), "-l", "-i", "-c", cmdStr)
} }
@ -274,7 +288,7 @@ func (z zshShellApi) GetShellState(ctx context.Context, outCh chan ShellStateOut
outCh <- ShellStateOutput{ShellState: rtn, Stats: stats} outCh <- ShellStateOutput{ShellState: rtn, Stats: stats}
} }
func (z zshShellApi) GetBaseShellOpts() string { func (zshShellApi) GetBaseShellOpts() string {
return BaseZshOpts return BaseZshOpts
} }
@ -343,6 +357,9 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string {
if strings.HasPrefix(varDecl.Name, "ZFTP_") { if strings.HasPrefix(varDecl.Name, "ZFTP_") {
continue continue
} }
if strings.HasPrefix(varDecl.Name, "_wavetemp_") {
continue
}
if varDecl.IsExtVar { if varDecl.IsExtVar {
continue continue
} }
@ -709,7 +726,7 @@ func makeZshFuncsStrForShellState(fnMap map[ZshParamKey]string) string {
return buf.String() return buf.String()
} }
func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellState, *packet.ShellStateStats, error) { func (zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellState, *packet.ShellStateStats, error) {
if scbase.IsDevMode() && DebugState { if scbase.IsDevMode() && DebugState {
writeStateToFile(packet.ShellType_zsh, outputBytes) writeStateToFile(packet.ShellType_zsh, outputBytes)
} }

View File

@ -2,7 +2,9 @@ package shellapi
import ( import (
"fmt" "fmt"
"log"
"testing" "testing"
"time"
) )
func testSingleDecl(declStr string) { func testSingleDecl(declStr string) {
@ -45,3 +47,35 @@ func TestZshSafeDeclName(t *testing.T) {
t.Errorf("should not be safe") t.Errorf("should not be safe")
} }
} }
func testValidate(t *testing.T, shell string, cmd string, expectErr bool) {
var sapi ShellApi
if shell == "bash" {
sapi = bashShellApi{}
} else if shell == "zsh" {
sapi = zshShellApi{}
} else {
t.Errorf("unknown shell %q", shell)
return
}
tstart := time.Now()
err := sapi.ValidateCommandSyntax(cmd)
log.Printf("shell:%s dur:%v err: %v\n", shell, time.Since(tstart), err)
if expectErr && err == nil {
t.Errorf("cmd %q, expected error", cmd)
}
if !expectErr && err != nil {
t.Errorf("cmd %q, unexpected error: %v", cmd, err)
}
}
func TestValidate(t *testing.T) {
testValidate(t, "zsh", "echo foo", false)
testValidate(t, "zsh", "foo >& &", true)
testValidate(t, "zsh", "cd .", false)
testValidate(t, "zsh", "echo foo | grep foo", false)
testValidate(t, "zsh", "x; echo \"hello", true)
testValidate(t, "bash", "echo foo", false)
testValidate(t, "bash", "foo >& &", true)
testValidate(t, "bash", "cd .; echo \"", true)
}

View File

@ -12,6 +12,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/base" "github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet" "github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
) )
@ -274,7 +275,7 @@ func (cproc *ClientProc) ProxySingleOutput(ck base.CommandKey, sender *packet.Pa
cmdDuration := endTs.Sub(cproc.StartTs) cmdDuration := endTs.Sub(cproc.StartTs)
donePacket := packet.MakeCmdDonePacket(ck) donePacket := packet.MakeCmdDonePacket(ck)
donePacket.Ts = endTs.UnixMilli() donePacket.Ts = endTs.UnixMilli()
donePacket.ExitCode = GetExitCode(exitErr) donePacket.ExitCode = utilfn.GetExitCode(exitErr)
donePacket.DurationMs = int64(cmdDuration / time.Millisecond) donePacket.DurationMs = int64(cmdDuration / time.Millisecond)
sender.SendPacket(donePacket) sender.SendPacket(donePacket)
} }

View File

@ -31,6 +31,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/shellapi" "github.com/wavetermdev/waveterm/waveshell/pkg/shellapi"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellenv" "github.com/wavetermdev/waveterm/waveshell/pkg/shellenv"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellutil" "github.com/wavetermdev/waveterm/waveshell/pkg/shellutil"
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
"github.com/wavetermdev/waveterm/waveshell/pkg/wlog" "github.com/wavetermdev/waveterm/waveshell/pkg/wlog"
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
@ -826,6 +827,10 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro
var rtnStateWriter *os.File var rtnStateWriter *os.File
rcFileStr := sapi.MakeRcFileStr(pk) rcFileStr := sapi.MakeRcFileStr(pk)
if pk.ReturnState { if pk.ReturnState {
err := sapi.ValidateCommandSyntax(pk.Command)
if err != nil {
return nil, err
}
pr, pw, err := os.Pipe() pr, pw, err := os.Pipe()
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot create returnstate pipe: %v", err) return nil, fmt.Errorf("cannot create returnstate pipe: %v", err)
@ -894,7 +899,12 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro
os.Remove(cmd.TmpRcFileName) os.Remove(cmd.TmpRcFileName)
}() }()
} }
cmd.Cmd = sapi.MakeShExecCommand(pk.Command, rcFileName, pk.UsePty) fullCmdStr := pk.Command
if pk.ReturnState {
// this ensures that the last command is a shell buitin so we always get our exit trap to run
fullCmdStr = fullCmdStr + "\nexit $? 2> /dev/null"
}
cmd.Cmd = sapi.MakeShExecCommand(fullCmdStr, rcFileName, pk.UsePty)
if !pk.StateComplete { if !pk.StateComplete {
cmd.Cmd.Env = os.Environ() cmd.Cmd.Env = os.Environ()
} }
@ -1075,34 +1085,6 @@ func copyToCirFile(dest *cirfile.File, src io.Reader) error {
} }
} }
func GetCmdExitCode(cmd *exec.Cmd, err error) int {
if cmd == nil || cmd.ProcessState == nil {
return GetExitCode(err)
}
status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !ok {
return cmd.ProcessState.ExitCode()
}
signaled := status.Signaled()
if signaled {
signal := status.Signal()
return 128 + int(signal)
}
exitStatus := status.ExitStatus()
return exitStatus
}
func GetExitCode(err error) int {
if err == nil {
return 0
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
} else {
return -1
}
}
func (c *ShExecType) ProcWait() error { func (c *ShExecType) ProcWait() error {
exitErr := c.Cmd.Wait() exitErr := c.Cmd.Wait()
c.Lock.Lock() c.Lock.Lock()
@ -1139,7 +1121,7 @@ func (c *ShExecType) WaitForCommand() *packet.CmdDonePacketType {
endTs := time.Now() endTs := time.Now()
cmdDuration := endTs.Sub(c.StartTs) cmdDuration := endTs.Sub(c.StartTs)
donePacket.Ts = endTs.UnixMilli() donePacket.Ts = endTs.UnixMilli()
donePacket.ExitCode = GetCmdExitCode(c.Cmd, exitErr) donePacket.ExitCode = utilfn.GetCmdExitCode(c.Cmd, exitErr)
donePacket.DurationMs = int64(cmdDuration / time.Millisecond) donePacket.DurationMs = int64(cmdDuration / time.Millisecond)
if c.FileNames != nil { if c.FileNames != nil {
os.Remove(c.FileNames.StdinFifo) // best effort (no need to check error) os.Remove(c.FileNames.StdinFifo) // best effort (no need to check error)

View File

@ -10,3 +10,7 @@ func AnsiResetColor() string {
func AnsiGreenColor() string { func AnsiGreenColor() string {
return "\033[32m" return "\033[32m"
} }
func AnsiRedColor() string {
return "\033[31m"
}

View File

@ -13,9 +13,13 @@ import (
"io" "io"
"math" "math"
mathrand "math/rand" mathrand "math/rand"
"net/http"
"os"
"os/exec"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"syscall"
"unicode/utf8" "unicode/utf8"
) )
@ -611,3 +615,61 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
} }
} }
} }
// on error just returns ""
// does not return "application/octet-stream" as this is considered a detection failure
func DetectMimeType(path string) string {
fd, err := os.Open(path)
if err != nil {
return ""
}
defer fd.Close()
buf := make([]byte, 512)
// ignore the error (EOF / UnexpectedEOF is fine, just process how much we got back)
n, _ := io.ReadAtLeast(fd, buf, 512)
if n == 0 {
return ""
}
buf = buf[:n]
rtn := http.DetectContentType(buf)
if rtn == "application/octet-stream" {
return ""
}
return rtn
}
func GetCmdExitCode(cmd *exec.Cmd, err error) int {
if cmd == nil || cmd.ProcessState == nil {
return GetExitCode(err)
}
status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !ok {
return cmd.ProcessState.ExitCode()
}
signaled := status.Signaled()
if signaled {
signal := status.Signal()
return 128 + int(signal)
}
exitStatus := status.ExitStatus()
return exitStatus
}
func GetExitCode(err error) int {
if err == nil {
return 0
}
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
} else {
return -1
}
}
func GetFirstLine(s string) string {
idx := strings.Index(s, "\n")
if idx == -1 {
return s
}
return s[0:idx]
}

View File

@ -508,11 +508,8 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) {
qvals := r.URL.Query() qvals := r.URL.Query()
screenId := qvals.Get("screenid") screenId := qvals.Get("screenid")
lineId := qvals.Get("lineid") lineId := qvals.Get("lineid")
path := qvals.Get("path") // validate path? path := qvals.Get("path") // validate path?
contentType := qvals.Get("mimetype") contentType := qvals.Get("mimetype") // force a mimetype
if contentType == "" {
contentType = "application/octet-stream"
}
if screenId == "" || lineId == "" { if screenId == "" || lineId == "" {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("must specify sessionid, screenid, and lineid")) w.Write([]byte("must specify sessionid, screenid, and lineid"))
@ -533,7 +530,7 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(fmt.Sprintf(ErrorInvalidLineId, err))) w.Write([]byte(fmt.Sprintf(ErrorInvalidLineId, err)))
return return
} }
if !ContentTypeHeaderValidRe.MatchString(contentType) { if contentType != "" && !ContentTypeHeaderValidRe.MatchString(contentType) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("invalid mimetype specified")) w.Write([]byte("invalid mimetype specified"))
return return
@ -599,6 +596,12 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) {
return return
} }
infoJson, _ := json.Marshal(resp.Info) infoJson, _ := json.Marshal(resp.Info)
if contentType == "" && resp.Info.MimeType != "" {
contentType = resp.Info.MimeType
}
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("X-FileInfo", base64.StdEncoding.EncodeToString(infoJson)) w.Header().Set("X-FileInfo", base64.StdEncoding.EncodeToString(infoJson))
w.Header().Set(ContentTypeHeaderKey, contentType) w.Header().Set(ContentTypeHeaderKey, contentType)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@ -1018,6 +1021,8 @@ func main() {
wlog.GlobalSubsystem = base.ProcessType_WaveSrv wlog.GlobalSubsystem = base.ProcessType_WaveSrv
wlog.LogConsumer = wlog.LogWithLogger wlog.LogConsumer = wlog.LogWithLogger
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
if len(os.Args) >= 2 && os.Args[1] == "--test" { if len(os.Args) >= 2 && os.Args[1] == "--test" {
log.Printf("running test fn\n") log.Printf("running test fn\n")
err := test() err := test()

View File

@ -27,7 +27,7 @@ CREATE TABLE remote_instance (
festate json NOT NULL, festate json NOT NULL,
statebasehash varchar(36) NOT NULL, statebasehash varchar(36) NOT NULL,
statediffhasharr json NOT NULL statediffhasharr json NOT NULL
); , shelltype varchar(20) NOT NULL DEFAULT 'bash');
CREATE TABLE state_base ( CREATE TABLE state_base (
basehash varchar(36) PRIMARY KEY, basehash varchar(36) PRIMARY KEY,
ts bigint NOT NULL, ts bigint NOT NULL,
@ -55,10 +55,8 @@ CREATE TABLE remote (
lastconnectts bigint NOT NULL, lastconnectts bigint NOT NULL,
local boolean NOT NULL, local boolean NOT NULL,
archived boolean NOT NULL, archived boolean NOT NULL,
remoteidx int NOT NULL, remoteidx int NOT NULL
statevars json NOT NULL DEFAULT '{}', , statevars json NOT NULL DEFAULT '{}', openaiopts json NOT NULL DEFAULT '{}', sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual', shellpref varchar(20) NOT NULL DEFAULT 'detect');
sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual',
openaiopts json NOT NULL DEFAULT '{}');
CREATE TABLE history ( CREATE TABLE history (
historyid varchar(36) PRIMARY KEY, historyid varchar(36) PRIMARY KEY,
ts bigint NOT NULL, ts bigint NOT NULL,
@ -203,7 +201,7 @@ CREATE TABLE IF NOT EXISTS "cmd" (
rtnstate boolean NOT NULL, rtnstate boolean NOT NULL,
rtnbasehash varchar(36) NOT NULL, rtnbasehash varchar(36) NOT NULL,
rtndiffhasharr json NOT NULL, rtndiffhasharr json NOT NULL,
runout json NOT NULL, runout json NOT NULL, restartts bigint NOT NULL DEFAULT 0,
PRIMARY KEY (screenid, lineid) PRIMARY KEY (screenid, lineid)
); );
CREATE TABLE cmd_migrate20 ( CREATE TABLE cmd_migrate20 (

View File

@ -1242,12 +1242,13 @@ func deferWriteCmdStatus(ctx context.Context, cmd *sstore.CmdType, startTime tim
exitCode = 1 exitCode = 1
} }
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
donePk := packet.MakeCmdDonePacket(ck) doneInfo := sstore.CmdDoneDataValues{
donePk.Ts = time.Now().UnixMilli() Ts: time.Now().UnixMilli(),
donePk.ExitCode = exitCode ExitCode: exitCode,
donePk.DurationMs = duration.Milliseconds() DurationMs: duration.Milliseconds(),
}
update := scbus.MakeUpdatePacket() update := scbus.MakeUpdatePacket()
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus) err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus)
if err != nil { if err != nil {
// nothing to do // nothing to do
log.Printf("error updating cmddoneinfo: %v\n", err) log.Printf("error updating cmddoneinfo: %v\n", err)
@ -2623,12 +2624,13 @@ func doOpenAICompletion(cmd *sstore.CmdType, opts *sstore.OpenAIOptsType, prompt
exitCode = 1 exitCode = 1
} }
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
donePk := packet.MakeCmdDonePacket(ck) doneInfo := sstore.CmdDoneDataValues{
donePk.Ts = time.Now().UnixMilli() Ts: time.Now().UnixMilli(),
donePk.ExitCode = exitCode ExitCode: exitCode,
donePk.DurationMs = duration.Milliseconds() DurationMs: duration.Milliseconds(),
}
update := scbus.MakeUpdatePacket() update := scbus.MakeUpdatePacket()
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus) err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus)
if err != nil { if err != nil {
// nothing to do // nothing to do
log.Printf("error updating cmddoneinfo (in openai): %v\n", err) log.Printf("error updating cmddoneinfo (in openai): %v\n", err)
@ -2783,12 +2785,13 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore
exitCode = 1 exitCode = 1
} }
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
donePk := packet.MakeCmdDonePacket(ck) doneInfo := sstore.CmdDoneDataValues{
donePk.Ts = time.Now().UnixMilli() Ts: time.Now().UnixMilli(),
donePk.ExitCode = exitCode ExitCode: exitCode,
donePk.DurationMs = duration.Milliseconds() DurationMs: duration.Milliseconds(),
}
update := scbus.MakeUpdatePacket() update := scbus.MakeUpdatePacket()
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus) err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus)
if err != nil { if err != nil {
// nothing to do // nothing to do
log.Printf("error updating cmddoneinfo (in openai): %v\n", err) log.Printf("error updating cmddoneinfo (in openai): %v\n", err)

View File

@ -1735,7 +1735,7 @@ func (msh *MShellProc) Launch(interactive bool) {
msh.WriteToPtyBuffer("connected to %s\n", remoteCopy.RemoteCanonicalName) msh.WriteToPtyBuffer("connected to %s\n", remoteCopy.RemoteCanonicalName)
go func() { go func() {
exitErr := cproc.Cmd.Wait() exitErr := cproc.Cmd.Wait()
exitCode := shexec.GetExitCode(exitErr) exitCode := utilfn.GetExitCode(exitErr)
msh.WithLock(func() { msh.WithLock(func() {
if msh.Status == StatusConnected || msh.Status == StatusConnecting { if msh.Status == StatusConnected || msh.Status == StatusConnecting {
msh.Status = StatusDisconnected msh.Status = StatusDisconnected
@ -2012,30 +2012,33 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
removeCmdWait(runPacket.CK) removeCmdWait(runPacket.CK)
} }
}() }()
runningCmdType := &RunCmdType{
CK: runPacket.CK,
SessionId: sessionId,
ScreenId: screenId,
RemotePtr: remotePtr,
RunPacket: runPacket,
EphemeralOpts: rcOpts.EphemeralOpts,
}
// RegisterRpc + WaitForResponse is used to get any waveshell side errors // RegisterRpc + WaitForResponse is used to get any waveshell side errors
// waveshell will either return an error (in a ResponsePacketType) or a CmdStartPacketType // waveshell will either return an error (in a ResponsePacketType) or a CmdStartPacketType
msh.ServerProc.Output.RegisterRpc(runPacket.ReqId) msh.ServerProc.Output.RegisterRpc(runPacket.ReqId)
err = shexec.SendRunPacketAndRunData(ctx, msh.ServerProc.Input, runPacket) go func() {
if err != nil { startPk, err := msh.sendRunPacketAndReturnResponse(runPacket)
return nil, nil, fmt.Errorf("sending run packet to remote: %w", err) runCmdUpdateFn(runPacket.CK, func() {
} if err != nil {
rtnPk := msh.ServerProc.Output.WaitForResponse(ctx, runPacket.ReqId) // the cmd failed (never started)
if rtnPk == nil { msh.handleCmdStartError(runningCmdType, err)
return nil, nil, ctx.Err() return
} }
startPk, ok := rtnPk.(*packet.CmdStartPacketType) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
if !ok { defer cancelFn()
respPk, ok := rtnPk.(*packet.ResponsePacketType) err = sstore.UpdateCmdStartInfo(ctx, runPacket.CK, startPk.Pid, startPk.MShellPid)
if !ok { if err != nil {
return nil, nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk)) log.Printf("error updating cmd start info (in remote.RunCommand): %v\n", err)
} }
if respPk.Error != "" { })
return nil, nil, respPk.Err() }()
}
return nil, nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk))
}
// command is now successfully runnning // command is now successfully runnning
status := sstore.CmdStatusRunning status := sstore.CmdStatusRunning
if runPacket.Detached { if runPacket.Detached {
@ -2051,8 +2054,6 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
StatePtr: *statePtr, StatePtr: *statePtr,
TermOpts: makeTermOpts(runPacket), TermOpts: makeTermOpts(runPacket),
Status: status, Status: status,
CmdPid: startPk.Pid,
RemotePid: startPk.MShellPid,
ExitCode: 0, ExitCode: 0,
DurationMs: 0, DurationMs: 0,
RunOut: nil, RunOut: nil,
@ -2065,18 +2066,36 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
return nil, nil, fmt.Errorf("cannot create local ptyout file for running command: %v", err) return nil, nil, fmt.Errorf("cannot create local ptyout file for running command: %v", err)
} }
} }
runningCmdType := &RunCmdType{
CK: runPacket.CK,
SessionId: sessionId,
ScreenId: screenId,
RemotePtr: remotePtr,
RunPacket: runPacket,
EphemeralOpts: rcOpts.EphemeralOpts}
msh.AddRunningCmd(runningCmdType) msh.AddRunningCmd(runningCmdType)
return cmd, func() { removeCmdWait(runPacket.CK) }, nil return cmd, func() { removeCmdWait(runPacket.CK) }, nil
} }
// no context because it is called as a goroutine
func (msh *MShellProc) sendRunPacketAndReturnResponse(runPacket *packet.RunPacketType) (*packet.CmdStartPacketType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
err := shexec.SendRunPacketAndRunData(ctx, msh.ServerProc.Input, runPacket)
if err != nil {
return nil, fmt.Errorf("sending run packet to remote: %w", err)
}
rtnPk := msh.ServerProc.Output.WaitForResponse(ctx, runPacket.ReqId)
if rtnPk == nil {
return nil, ctx.Err()
}
startPk, ok := rtnPk.(*packet.CmdStartPacketType)
if !ok {
respPk, ok := rtnPk.(*packet.ResponsePacketType)
if !ok {
return nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk))
}
if respPk.Error != "" {
return nil, respPk.Err()
}
return nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk))
}
return startPk, nil
}
// helper func to construct the proper error given what information we have // helper func to construct the proper error given what information we have
func makePSCLineError(existingPSC base.CommandKey, line *sstore.LineType, lineErr error) error { func makePSCLineError(existingPSC base.CommandKey, line *sstore.LineType, lineErr error) error {
if lineErr != nil { if lineErr != nil {
@ -2342,6 +2361,42 @@ func (msh *MShellProc) updateRIWithFinalState(ctx context.Context, rct *RunCmdTy
return sstore.UpdateRemoteState(ctx, rct.SessionId, rct.ScreenId, rct.RemotePtr, feState, nil, newStateDiff) return sstore.UpdateRemoteState(ctx, rct.SessionId, rct.ScreenId, rct.RemotePtr, feState, nil, newStateDiff)
} }
func (msh *MShellProc) handleCmdStartError(rct *RunCmdType, startErr error) {
if rct == nil {
log.Printf("handleCmdStartError, no rct\n")
return
}
defer msh.RemoveRunningCmd(rct.CK)
if rct.EphemeralOpts != nil {
// nothing to do for ephemeral commands besides remove the running command
return
}
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
update := scbus.MakeUpdatePacket()
errOutputStr := fmt.Sprintf("%serror: %v%s\n", utilfn.AnsiRedColor(), startErr, utilfn.AnsiResetColor())
msh.writeToCmdPtyOut(ctx, rct.ScreenId, rct.CK.GetCmdId(), []byte(errOutputStr))
doneInfo := sstore.CmdDoneDataValues{
Ts: time.Now().UnixMilli(),
ExitCode: 1,
DurationMs: 0,
}
err := sstore.UpdateCmdDoneInfo(ctx, update, rct.CK, doneInfo, sstore.CmdStatusError)
if err != nil {
log.Printf("error updating cmddone info (in handleCmdStartError): %v\n", err)
return
}
screen, err := sstore.UpdateScreenFocusForDoneCmd(ctx, rct.CK.GetGroupId(), rct.CK.GetCmdId())
if err != nil {
log.Printf("error trying to update screen focus type (in handleCmdDonePacket): %v\n", err)
// fall-through (nothing to do)
}
if screen != nil {
update.AddUpdate(*screen)
}
scbus.MainUpdateBus.DoUpdate(update)
}
func (msh *MShellProc) handleCmdDonePacket(rct *RunCmdType, donePk *packet.CmdDonePacketType) { func (msh *MShellProc) handleCmdDonePacket(rct *RunCmdType, donePk *packet.CmdDonePacketType) {
if rct == nil { if rct == nil {
log.Printf("cmddone packet received, but no running command found for it %q\n", donePk.CK) log.Printf("cmddone packet received, but no running command found for it %q\n", donePk.CK)
@ -2359,7 +2414,12 @@ func (msh *MShellProc) handleCmdDonePacket(rct *RunCmdType, donePk *packet.CmdDo
update := scbus.MakeUpdatePacket() update := scbus.MakeUpdatePacket()
if rct.EphemeralOpts == nil { if rct.EphemeralOpts == nil {
// only update DB for non-ephemeral commands // only update DB for non-ephemeral commands
err := sstore.UpdateCmdDoneInfo(ctx, update, donePk.CK, donePk, sstore.CmdStatusDone) cmdDoneInfo := sstore.CmdDoneDataValues{
Ts: donePk.Ts,
ExitCode: donePk.ExitCode,
DurationMs: donePk.DurationMs,
}
err := sstore.UpdateCmdDoneInfo(ctx, update, donePk.CK, cmdDoneInfo, sstore.CmdStatusDone)
if err != nil { if err != nil {
log.Printf("error updating cmddone info (in handleCmdDonePacket): %v\n", err) log.Printf("error updating cmddone info (in handleCmdDonePacket): %v\n", err)
return return
@ -2453,6 +2513,19 @@ func (msh *MShellProc) ResetDataPos(ck base.CommandKey) {
msh.DataPosMap.Delete(ck) msh.DataPosMap.Delete(ck)
} }
func (msh *MShellProc) writeToCmdPtyOut(ctx context.Context, screenId string, lineId string, data []byte) error {
dataPos := msh.DataPosMap.Get(base.MakeCommandKey(screenId, lineId))
update, err := sstore.AppendToCmdPtyBlob(ctx, screenId, lineId, data, dataPos)
if err != nil {
return err
}
utilfn.IncSyncMap(msh.DataPosMap, base.MakeCommandKey(screenId, lineId), int64(len(data)))
if update != nil {
scbus.MainUpdateBus.DoScreenUpdate(screenId, update)
}
return nil
}
func (msh *MShellProc) handleDataPacket(rct *RunCmdType, dataPk *packet.DataPacketType, dataPosMap *utilfn.SyncMap[base.CommandKey, int64]) { func (msh *MShellProc) handleDataPacket(rct *RunCmdType, dataPk *packet.DataPacketType, dataPosMap *utilfn.SyncMap[base.CommandKey, int64]) {
if rct == nil { if rct == nil {
log.Printf("error handling data packet: no running cmd found %s\n", dataPk.CK) log.Printf("error handling data packet: no running cmd found %s\n", dataPk.CK)

View File

@ -412,6 +412,12 @@ func createHostKeyCallback(opts *sstore.SSHOpts) (ssh.HostKeyCallback, error) {
} }
} }
if basicCallback == nil {
basicCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return &knownhosts.KeyError{}
}
}
waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error { waveHostKeyCallback := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := basicCallback(hostname, remote, key) err := basicCallback(hostname, remote, key)
if err == nil { if err == nil {

View File

@ -743,10 +743,21 @@ func UpdateCmdForRestart(ctx context.Context, ck base.CommandKey, ts int64, cmdP
}) })
} }
func UpdateCmdDoneInfo(ctx context.Context, update *scbus.ModelUpdatePacketType, ck base.CommandKey, donePk *packet.CmdDonePacketType, status string) error { func UpdateCmdStartInfo(ctx context.Context, ck base.CommandKey, cmdPid int, mshellPid int) error {
if donePk == nil { return WithTx(ctx, func(tx *TxWrap) error {
return fmt.Errorf("invalid cmddone packet") query := `UPDATE cmd SET cmdpid = ?, remotepid = ? WHERE screenid = ? AND lineid = ?`
} tx.Exec(query, cmdPid, mshellPid, ck.GetGroupId(), lineIdFromCK(ck))
return nil
})
}
type CmdDoneDataValues struct {
Ts int64
ExitCode int
DurationMs int64
}
func UpdateCmdDoneInfo(ctx context.Context, update *scbus.ModelUpdatePacketType, ck base.CommandKey, donePk CmdDoneDataValues, status string) error {
if ck.IsEmpty() { if ck.IsEmpty() {
return fmt.Errorf("cannot update cmddoneinfo, empty ck") return fmt.Errorf("cannot update cmddoneinfo, empty ck")
} }

View File

@ -6,7 +6,10 @@ package telemetry
import ( import (
"context" "context"
"database/sql/driver" "database/sql/driver"
"fmt"
"log" "log"
"regexp"
"strconv"
"time" "time"
"github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil" "github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil"
@ -81,6 +84,99 @@ func GetCurDayStr() string {
return dayStr return dayStr
} }
func GetRelDayStr(relDays int) string {
now := time.Now()
dayStr := now.AddDate(0, 0, relDays).Format("2006-01-02")
return dayStr
}
// accepts a custom format string to return a daystr
// can be either a prefix, a delta, or a prefix w/ a delta
// if no prefix is given, "today" is assumed
// examples: today-2d, bow, bom+1m-1d (that's end of the month), 2024-04-01+1w
//
// prefixes:
//
// yyyy-mm-dd
// today
// yesterday
// bom (beginning of month)
// bow (beginning of week -- sunday)
//
// deltas:
//
// +[n]d, -[n]d (e.g. +1d, -5d)
// +[n]w, -[n]w (e.g. +2w)
// +[n]m, -[n]m (e.g. -1m)
// deltas can be combined e.g. +1w-2d
func GetCustomDayStr(format string) (string, error) {
m := customDayStrRe.FindStringSubmatch(format)
if m == nil {
return "", fmt.Errorf("invalid daystr format")
}
prefix, deltas := m[1], m[2]
if prefix == "" {
prefix = "today"
}
var rtnTime time.Time
now := time.Now()
switch prefix {
case "today":
rtnTime = now
case "yesterday":
rtnTime = now.AddDate(0, 0, -1)
case "bom":
rtnTime = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
case "bow":
weekday := now.Weekday()
if weekday == time.Sunday {
rtnTime = now
} else {
rtnTime = now.AddDate(0, 0, -int(weekday))
}
default:
m = daystrRe.FindStringSubmatch(prefix)
if m == nil {
return "", fmt.Errorf("invalid prefix format")
}
year, month, day := m[1], m[2], m[3]
yearInt, monthInt, dayInt := atoiNoErr(year), atoiNoErr(month), atoiNoErr(day)
if yearInt == 0 || monthInt == 0 || dayInt == 0 {
return "", fmt.Errorf("invalid prefix format")
}
rtnTime = time.Date(yearInt, time.Month(monthInt), dayInt, 0, 0, 0, 0, now.Location())
}
for _, delta := range regexp.MustCompile(`[+-]\d+[dwm]`).FindAllString(deltas, -1) {
deltaVal, err := strconv.Atoi(delta[1 : len(delta)-1])
if err != nil {
return "", fmt.Errorf("invalid delta format")
}
if delta[0] == '-' {
deltaVal = -deltaVal
}
switch delta[len(delta)-1] {
case 'd':
rtnTime = rtnTime.AddDate(0, 0, deltaVal)
case 'w':
rtnTime = rtnTime.AddDate(0, 0, deltaVal*7)
case 'm':
rtnTime = rtnTime.AddDate(0, deltaVal, 0)
}
}
return rtnTime.Format("2006-01-02"), nil
}
func atoiNoErr(str string) int {
val, err := strconv.Atoi(str)
if err != nil {
return 0
}
return val
}
var customDayStrRe = regexp.MustCompile(`^((?:\d{4}-\d{2}-\d{2})|today|yesterday|bom|bow)?((?:[+-]\d+[dwm])*)$`)
var daystrRe = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})$`)
func UpdateCurrentActivity(ctx context.Context, update ActivityUpdate) error { func UpdateCurrentActivity(ctx context.Context, update ActivityUpdate) error {
now := time.Now() now := time.Now()
dayStr := GetCurDayStr() dayStr := GetCurDayStr()

View File

@ -0,0 +1,41 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package telemetry
import (
"testing"
"time"
)
func testCustomDaystr(t *testing.T, customDayStr string, expectedDayStr string, shouldErr bool) {
rtn, err := GetCustomDayStr(customDayStr)
if err != nil {
if !shouldErr {
t.Errorf("unexpected error: %v", err)
}
} else {
if rtn != expectedDayStr {
t.Errorf("for %q expected %q, got %q", customDayStr, expectedDayStr, rtn)
}
}
}
func TestDaystrCustom(t *testing.T) {
now := time.Now()
bom := now.AddDate(0, 0, -now.Day()+1)
testCustomDaystr(t, "today", GetCurDayStr(), false)
testCustomDaystr(t, "yesterday", GetRelDayStr(-1), false)
testCustomDaystr(t, "bom", bom.Format("2006-01-02"), false)
bow := now.AddDate(0, 0, -int(now.Weekday()))
testCustomDaystr(t, "bow", bow.Format("2006-01-02"), false)
testCustomDaystr(t, "today-1d", GetRelDayStr(-1), false)
testCustomDaystr(t, "today+1d", GetRelDayStr(1), false)
testCustomDaystr(t, "today-1w", GetRelDayStr(-7), false)
day1 := bom.AddDate(0, 1, -1)
testCustomDaystr(t, "bom+1m-1d", day1.Format("2006-01-02"), false)
testCustomDaystr(t, "foo", "", true)
testCustomDaystr(t, "2000-1-1", "", true)
testCustomDaystr(t, "2024-01-01+1w", "2024-01-08", false)
testCustomDaystr(t, "2024-01-01+1m+1w-1d", "2024-02-07", false)
}