mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
merge main
This commit is contained in:
commit
b4d04c3a57
@ -42,16 +42,19 @@
|
||||
--app-bg-color: black;
|
||||
--app-accent-color: rgb(88, 193, 66);
|
||||
--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-primary-color: rgb(255, 255, 255);
|
||||
--app-text-secondary-color: rgb(195, 200, 194);
|
||||
--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-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-dev: rgb(21, 23, 48);
|
||||
--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);
|
||||
|
||||
/* global status colors */
|
||||
@ -122,18 +125,18 @@
|
||||
|
||||
/* line colors */
|
||||
--line-sidebar-message-color: rgb(196, 160, 0);
|
||||
--line-background: rgba(21, 23, 21, 1);
|
||||
--line-avatar-color: #eceeec;
|
||||
--line-background: var(--app-panel-bg-color);
|
||||
--line-avatar-color: rgb(236, 238, 236);
|
||||
--line-text-color: rgb(211, 215, 207);
|
||||
--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-error-color: var(--app-error-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-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-bg-color: rgb(19, 4, 3);
|
||||
--line-error-bg-color: rgba(200, 0, 0, 0.1);
|
||||
@ -152,22 +155,21 @@
|
||||
/* table colors */
|
||||
--table-border-color: rgba(241, 246, 243, 0.15);
|
||||
--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-tr-border-bottom-color: rgba(241, 246, 243, 0.15);
|
||||
--table-tr-hover-bg-color: rgba(255, 255, 255, 0.06);
|
||||
--table-tr-selected-bg-color: #222;
|
||||
--table-tr-selected-hover-bg-color: #333;
|
||||
--table-tr-selected-bg-color: rgb(34, 34, 34);
|
||||
--table-tr-selected-hover-bg-color: var(--app-maincontent-bg-color);
|
||||
|
||||
/* cmdinput colors */
|
||||
--cmdinput-textarea-bg-color: #171717;
|
||||
--cmdinput-bg-color: var(--app-text-bg-color);
|
||||
--cmdinput-text-error-color: var(--term-red);
|
||||
--cmdinput-history-item-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-comment-button-bg-color: rgb(57, 113, 255);
|
||||
--cmdinput-disabled-icon-color: rgb(76, 81, 75, 1);
|
||||
--cmdinput-history-bg-color: rgb(21, 23, 21, 1);
|
||||
--cmdinput-button-bg-color: var(--tab-green);
|
||||
--cmdinput-disabled-bg-color: var(--app-text-bg-disabled-color);
|
||||
--cmdinput-history-bg-color: var(--app-bg-color);
|
||||
|
||||
/* screen view color */
|
||||
--screen-view-text-caption-color: rgb(139, 145, 138);
|
||||
|
@ -5,20 +5,20 @@
|
||||
@import url("./term-light.css");
|
||||
|
||||
:root {
|
||||
--app-bg-color: #fefefe;
|
||||
--app-bg-color: rgb(254, 254, 254);
|
||||
--app-accent-color: rgb(75, 166, 57);
|
||||
--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-secondary-color: rgb(0, 0, 0, 0.7);
|
||||
--app-border-color: rgb(139 145 138);
|
||||
--app-panel-bg-color: #e0e0e0;
|
||||
--app-panel-bg-color-dev: #e0e0e0;
|
||||
--app-icon-color: rgb(80, 80, 80);
|
||||
--app-icon-hover-color: rgb(100, 100, 100);
|
||||
--app-panel-bg-color: rgb(224, 224, 224);
|
||||
--app-panel-bg-color-dev: rgb(224, 224, 224);
|
||||
--app-icon-color: rgb(110, 110, 110);
|
||||
--app-icon-hover-color: rgb(80, 80, 80);
|
||||
--app-selected-mask-color: rgba(0, 0, 0, 0.06);
|
||||
|
||||
--input-bg-color: #eeeeee;
|
||||
--input-bg-color: rgb(238, 238, 238);
|
||||
|
||||
/* tab color */
|
||||
--tab-white: rgb(0, 0, 0, 0.6);
|
||||
@ -30,8 +30,8 @@
|
||||
--table-thead-bg-color: rgba(250, 250, 250, 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-selected-bg-color: #dddddd;
|
||||
--table-tr-selected-hover-bg-color: #cccccc;
|
||||
--table-tr-selected-bg-color: rgb(221, 221, 221);
|
||||
--table-tr-selected-hover-bg-color: rgb(204, 204, 204);
|
||||
|
||||
/* form colors */
|
||||
--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-secondary-color: rgba(0, 0, 0, 0.09);
|
||||
--form-element-icon-color: rgb(0, 0, 0, 0.6);
|
||||
--form-element-disabled-text-color: #b7b7b7;
|
||||
--form-element-placeholder-color: #b7b7b7;
|
||||
--form-element-disabled-text-color: rgb(183, 183, 183);
|
||||
--form-element-placeholder-color: rgb(183, 183, 183);
|
||||
|
||||
--markdown-bg-color: rgb(0, 0, 0, 0.1);
|
||||
|
||||
@ -50,8 +50,6 @@
|
||||
--modal-header-bottom-border-color: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* cmd input */
|
||||
--cmdinput-textarea-bg-color: rgba(0, 0, 0, 0.1);
|
||||
--cmdinput-textarea-border-color: var(--form-element-border-color);
|
||||
|
||||
/* scroll colors */
|
||||
--scrollbar-background-color: var(--app-bg-color);
|
||||
@ -64,15 +62,15 @@
|
||||
--line-actions-inactive-color: rgba(0, 0, 0, 0.3);
|
||||
--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);
|
||||
|
||||
--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-header-fade-color: rgba(0, 0, 0, 0.4);
|
||||
--datepicker-year-header-bg-color: #f5f5f5;
|
||||
--datepicker-year-header-border-color: #dcdcdc;
|
||||
--datepicker-year-header-bg-color: rgb(245, 245, 245);
|
||||
--datepicker-year-header-border-color: rgb(220, 220, 220);
|
||||
|
||||
/* toggle colors */
|
||||
--toggle-thumb-color: var(--app-bg-color);
|
||||
|
@ -64,3 +64,7 @@ export enum StatusIndicatorLevel {
|
||||
|
||||
// matches packet.go
|
||||
export const ErrorCode_InvalidCwd = "ERRCWD";
|
||||
|
||||
export const InputAuxView_History = "history";
|
||||
export const InputAuxView_Info = "info";
|
||||
export const InputAuxView_AIChat = "aichat";
|
||||
|
@ -6,7 +6,7 @@ import "./button.less";
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
disabled?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
@ -14,6 +14,7 @@ interface ButtonProps {
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
termInline?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
class Button extends React.Component<ButtonProps> {
|
||||
@ -23,14 +24,14 @@ class Button extends React.Component<ButtonProps> {
|
||||
};
|
||||
|
||||
@boundMethod
|
||||
handleClick() {
|
||||
handleClick(e) {
|
||||
if (this.props.onClick && !this.props.disabled) {
|
||||
this.props.onClick();
|
||||
this.props.onClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
@ -39,6 +40,7 @@ class Button extends React.Component<ButtonProps> {
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
autoFocus={autoFocus}
|
||||
title={title}
|
||||
>
|
||||
{leftIcon && <span className="icon-left">{leftIcon}</span>}
|
||||
{children}
|
||||
|
7
src/app/common/elements/copybutton.less
Normal file
7
src/app/common/elements/copybutton.less
Normal file
@ -0,0 +1,7 @@
|
||||
.copy-button {
|
||||
padding: 5px 5px;
|
||||
|
||||
.fa-check {
|
||||
color: var(--app-success-color);
|
||||
}
|
||||
}
|
51
src/app/common/elements/copybutton.tsx
Normal file
51
src/app/common/elements/copybutton.tsx
Normal 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 };
|
@ -19,3 +19,4 @@ export { Tooltip } from "./tooltip";
|
||||
export { TabIcon } from "./tabicon";
|
||||
export { DatePicker } from "./datepicker";
|
||||
export { StyleBlock } from "./styleblock";
|
||||
export { CopyButton } from "./copybutton";
|
||||
|
@ -7,8 +7,10 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import cn from "classnames";
|
||||
import { GlobalModel } from "@/models";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import "./markdown.less";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
|
||||
function LinkRenderer(props: any): any {
|
||||
let newUrl = "https://extern?" + encodeURIComponent(props.href);
|
||||
@ -28,14 +30,17 @@ function CodeRenderer(props: any): any {
|
||||
}
|
||||
|
||||
@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;
|
||||
blockRef: React.RefObject<HTMLPreElement>;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.blockRef = React.createRef();
|
||||
this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef);
|
||||
this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef, this.props.uuid);
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -62,9 +67,21 @@ class Markdown extends React.Component<
|
||||
{ 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) {
|
||||
return <CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex}>{props.children}</CodeBlockMarkdown>;
|
||||
return (
|
||||
<CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex} uuid={curUuid}>
|
||||
{props.children}
|
||||
</CodeBlockMarkdown>
|
||||
);
|
||||
} else {
|
||||
const clickHandler = (e: React.MouseEvent<HTMLElement>) => {
|
||||
let blockText = (e.target as HTMLElement).innerText;
|
||||
@ -90,7 +107,7 @@ class Markdown extends React.Component<
|
||||
h5: (props) => HeaderRenderer(props, 5),
|
||||
h6: (props) => HeaderRenderer(props, 6),
|
||||
code: (props) => CodeRenderer(props),
|
||||
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex),
|
||||
pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex, this.curUuid),
|
||||
};
|
||||
return (
|
||||
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>
|
||||
|
@ -312,7 +312,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<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" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
|
@ -338,7 +338,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
<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" />}
|
||||
>
|
||||
<i className="fa-sharp fa-regular fa-circle-question" />
|
||||
|
@ -357,6 +357,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wave-button {
|
||||
padding: 5px 5px;
|
||||
}
|
||||
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import { Line } from "@/app/line/linecomps";
|
||||
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 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 TrashIcon } from "@/assets/icons/trash.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 { MainView } from "../common/elements/mainview";
|
||||
@ -115,7 +113,6 @@ class HistoryCmdStr extends React.Component<
|
||||
cmdstr: string;
|
||||
onUse: () => void;
|
||||
onCopy: () => void;
|
||||
isCopied: boolean;
|
||||
fontSize: "normal" | "large";
|
||||
limitHeight: boolean;
|
||||
},
|
||||
@ -138,24 +135,17 @@ class HistoryCmdStr extends React.Component<
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isCopied, cmdstr, fontSize, limitHeight } = this.props;
|
||||
const { cmdstr, fontSize, limitHeight } = this.props;
|
||||
return (
|
||||
<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">
|
||||
<code>{cmdstr}</code>
|
||||
</div>
|
||||
<div key="copy" className="actions-block">
|
||||
<div className="action-item" onClick={this.handleCopy} title="copy">
|
||||
<CopyIcon className="icon" />
|
||||
</div>
|
||||
<div key="use" className="action-item" title="Use Command" onClick={this.handleUse}>
|
||||
<CheckIcon className="icon" />
|
||||
</div>
|
||||
<CopyButton onClick={this.handleCopy} title="Copy" />
|
||||
<Button className="secondary ghost" title="Use Command" onClick={this.handleUse}>
|
||||
<i className="fa-sharp fa-solid fa-play"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -190,7 +180,6 @@ class HistoryView extends React.Component<{}, {}> {
|
||||
tableRszObs: ResizeObserver;
|
||||
sessionDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "sessionDropdownActive" });
|
||||
remoteDropdownActive: OV<boolean> = mobx.observable.box(false, { name: "remoteDropdownActive" });
|
||||
copiedItemId: OV<string> = mobx.observable.box(null, { name: "copiedItemId" });
|
||||
|
||||
@boundMethod
|
||||
handleNext() {
|
||||
@ -377,14 +366,6 @@ class HistoryView extends React.Component<{}, {}> {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(item.cmdstr);
|
||||
mobx.action(() => {
|
||||
this.copiedItemId.set(item.historyid);
|
||||
})();
|
||||
setTimeout(() => {
|
||||
mobx.action(() => {
|
||||
this.copiedItemId.set(null);
|
||||
})();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
@ -394,7 +375,7 @@ class HistoryView extends React.Component<{}, {}> {
|
||||
}
|
||||
mobx.action(() => {
|
||||
GlobalModel.showSessionView();
|
||||
GlobalModel.inputModel.setCurLine(item.cmdstr);
|
||||
GlobalModel.inputModel.updateCmdLine({ str: item.cmdstr, pos: item.cmdstr.length });
|
||||
setTimeout(() => GlobalModel.inputModel.giveFocus(), 50);
|
||||
})();
|
||||
}
|
||||
@ -569,7 +550,6 @@ class HistoryView extends React.Component<{}, {}> {
|
||||
cmdstr={item.cmdstr}
|
||||
onUse={() => this.handleUse(item)}
|
||||
onCopy={() => this.handleCopy(item)}
|
||||
isCopied={this.copiedItemId.get() == item.historyid}
|
||||
fontSize="normal"
|
||||
limitHeight={true}
|
||||
/>
|
||||
|
@ -106,11 +106,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.top-border {
|
||||
border-top: 1px solid #777;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
&:hover .meta .termopts {
|
||||
display: block;
|
||||
}
|
||||
|
@ -242,7 +242,7 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
|
||||
}
|
||||
|
||||
render() {
|
||||
let mainView = GlobalModel.activeMainView.get();
|
||||
const mainView = GlobalModel.activeMainView.get();
|
||||
const historyActive = mainView == "history";
|
||||
const connectionsActive = mainView == "connections";
|
||||
const settingsActive = mainView == "clientsettings";
|
||||
|
70
src/app/workspace/cmdinput/aichat.less
Normal file
70
src/app/workspace/cmdinput/aichat.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -5,18 +5,18 @@ import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { GlobalModel } from "@/models";
|
||||
import { isBlank } from "@/util/util";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import cn from "classnames";
|
||||
import { If, For } from "tsx-control-statements/components";
|
||||
import { Markdown } from "@/elements";
|
||||
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
|
||||
import { AuxiliaryCmdView } from "./auxview";
|
||||
|
||||
import "./aichat.less";
|
||||
|
||||
class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> {
|
||||
componentDidMount(): void {
|
||||
let AIChatObject = this.props.AIChatObject;
|
||||
let keybindManager = GlobalModel.keybindManager;
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
const AIChatObject = this.props.AIChatObject;
|
||||
const keybindManager = GlobalModel.keybindManager;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
|
||||
keybindManager.registerKeybinding("pane", "aichat", "generic:confirm", (waveEvent) => {
|
||||
AIChatObject.onEnterKeyPressed();
|
||||
@ -27,7 +27,7 @@ class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> {
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "aichat", "generic:cancel", (waveEvent) => {
|
||||
inputModel.closeAIAssistantChat(true);
|
||||
inputModel.closeAuxView();
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "aichat", "aichat:clearHistory", (waveEvent) => {
|
||||
@ -54,10 +54,10 @@ class AIChatKeybindings extends React.Component<{ AIChatObject: AIChat }, {}> {
|
||||
@mobxReact.observer
|
||||
class AIChat extends React.Component<{}, {}> {
|
||||
chatListKeyCount: number = 0;
|
||||
textAreaNumLines: mobx.IObservableValue<number> = mobx.observable.box(1, { name: "textAreaNumLines" });
|
||||
chatWindowScrollRef: React.RefObject<HTMLDivElement>;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
isFocused: OV<boolean>;
|
||||
termFontSize: number = 14;
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
@ -69,9 +69,8 @@ class AIChat extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) {
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
if (this.chatWindowScrollRef?.current != null) {
|
||||
this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight;
|
||||
}
|
||||
if (this.textAreaRef.current != null) {
|
||||
@ -79,10 +78,11 @@ class AIChat extends React.Component<{}, {}> {
|
||||
inputModel.setCmdInfoChatRefs(this.textAreaRef, this.chatWindowScrollRef);
|
||||
}
|
||||
this.requestChatUpdate();
|
||||
this.onTextAreaChange(null);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.chatWindowScrollRef != null && this.chatWindowScrollRef.current != null) {
|
||||
if (this.chatWindowScrollRef?.current != null) {
|
||||
this.chatWindowScrollRef.current.scrollTop = this.chatWindowScrollRef.current.scrollHeight;
|
||||
}
|
||||
}
|
||||
@ -92,20 +92,18 @@ class AIChat extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
submitChatMessage(messageStr: string) {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
let curLine = inputModel.getCurLine();
|
||||
let prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false);
|
||||
const curLine = GlobalModel.inputModel.getCurLine();
|
||||
const prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false);
|
||||
prtn.then((rtn) => {
|
||||
if (!rtn.success) {
|
||||
console.log("submit chat command error: " + rtn.error);
|
||||
}
|
||||
}).catch((error) => {});
|
||||
}).catch((_) => {});
|
||||
}
|
||||
|
||||
getLinePos(elem: any): { numLines: number; linePos: number } {
|
||||
let numLines = elem.value.split("\n").length;
|
||||
let linePos = elem.value.substr(0, elem.selectionStart).split("\n").length;
|
||||
const numLines = elem.value.split("\n").length;
|
||||
const linePos = elem.value.substr(0, elem.selectionStart).split("\n").length;
|
||||
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) {
|
||||
// set height of textarea based on number of newlines
|
||||
mobx.action(() => {
|
||||
this.textAreaNumLines.set(e.target.value.split(/\n/).length);
|
||||
// Calculate the bounding height of the text area
|
||||
const textAreaMaxLines = 4;
|
||||
const textAreaLineHeight = this.termFontSize * 1.5;
|
||||
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() {
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
let currentRef = this.textAreaRef.current;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const currentRef = this.textAreaRef.current;
|
||||
if (currentRef == null) {
|
||||
return;
|
||||
}
|
||||
if (inputModel.getCodeSelectSelectedIndex() == -1) {
|
||||
let messageStr = currentRef.value;
|
||||
const messageStr = currentRef.value;
|
||||
this.submitChatMessage(messageStr);
|
||||
currentRef.value = "";
|
||||
} else {
|
||||
@ -145,7 +153,7 @@ class AIChat extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
onExpandInputPressed() {
|
||||
let currentRef = this.textAreaRef.current;
|
||||
const currentRef = this.textAreaRef.current;
|
||||
if (currentRef == null) {
|
||||
return;
|
||||
}
|
||||
@ -154,7 +162,7 @@ class AIChat extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
onArrowUpPressed(): boolean {
|
||||
let currentRef = this.textAreaRef.current;
|
||||
const currentRef = this.textAreaRef.current;
|
||||
if (currentRef == null) {
|
||||
return false;
|
||||
}
|
||||
@ -168,8 +176,8 @@ class AIChat extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
onArrowDownPressed(): boolean {
|
||||
let currentRef = this.textAreaRef.current;
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
const currentRef = this.textAreaRef.current;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
if (currentRef == null) {
|
||||
return false;
|
||||
}
|
||||
@ -190,10 +198,10 @@ class AIChat extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
renderChatMessage(chatItem: OpenAICmdInfoChatMessageType): any {
|
||||
let curKey = "chatmsg-" + this.chatListKeyCount;
|
||||
const curKey = "chatmsg-" + this.chatListKeyCount;
|
||||
this.chatListKeyCount++;
|
||||
let senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user";
|
||||
let msgClassName = "chat-msg " + senderClassName;
|
||||
const senderClassName = chatItem.isassistantresponse ? "chat-msg-assistant" : "chat-msg-user";
|
||||
const msgClassName = "chat-msg " + senderClassName;
|
||||
let innerHTML: React.JSX.Element = (
|
||||
<span>
|
||||
<div className="chat-msg-header">
|
||||
@ -226,53 +234,36 @@ class AIChat extends React.Component<{}, {}> {
|
||||
);
|
||||
}
|
||||
|
||||
renderChatWindow(): any {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
let chatMessageItems = inputModel.AICmdInfoChatItems.slice();
|
||||
let chitem: OpenAICmdInfoChatMessageType = null;
|
||||
render() {
|
||||
const chatMessageItems = GlobalModel.inputModel.AICmdInfoChatItems.slice();
|
||||
const chitem: OpenAICmdInfoChatMessageType = null;
|
||||
const renderKeybindings = mobx
|
||||
.computed(() => {
|
||||
return (
|
||||
this.isFocused.get() ||
|
||||
GlobalModel.inputModel.hasFocus() ||
|
||||
(GlobalModel.getActiveScreen().getFocusType() == "input" &&
|
||||
GlobalModel.activeMainView.get() == "session")
|
||||
);
|
||||
})
|
||||
.get();
|
||||
|
||||
return (
|
||||
<AuxiliaryCmdView
|
||||
title="Wave AI"
|
||||
className="cmd-aichat"
|
||||
onClose={() => GlobalModel.inputModel.closeAuxView()}
|
||||
iconClass="fa-sharp fa-solid fa-sparkles"
|
||||
>
|
||||
<If condition={renderKeybindings}>
|
||||
<AIChatKeybindings AIChatObject={this}></AIChatKeybindings>
|
||||
</If>
|
||||
<div className="chat-window" ref={this.chatWindowScrollRef}>
|
||||
<For each="chitem" index="idx" of={chatMessageItems}>
|
||||
{this.renderChatMessage(chitem)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
|
||||
const termFontSize = 14;
|
||||
const textAreaMaxLines = 4;
|
||||
const textAreaLineHeight = termFontSize * 1.5;
|
||||
const textAreaPadding = 2 * 0.5 * termFontSize;
|
||||
let textAreaMaxHeight = textAreaLineHeight * textAreaMaxLines + textAreaPadding;
|
||||
let textAreaInnerHeight = this.textAreaNumLines.get() * textAreaLineHeight + textAreaPadding;
|
||||
let isFocused = this.isFocused.get();
|
||||
|
||||
return (
|
||||
<div className="cmd-aichat">
|
||||
<If condition={isFocused}>
|
||||
<AIChatKeybindings AIChatObject={this}></AIChatKeybindings>
|
||||
</If>
|
||||
<div className="cmdinput-titlebar">
|
||||
<div className="title-icon">
|
||||
<i className="fa-sharp fa-solid fa-sparkles" />
|
||||
</div>
|
||||
<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 className="titlebar-spacer" />
|
||||
{this.renderChatWindow()}
|
||||
<div className="chat-input">
|
||||
<textarea
|
||||
key="main"
|
||||
ref={this.textAreaRef}
|
||||
@ -283,11 +274,12 @@ class AIChat extends React.Component<{}, {}> {
|
||||
onBlur={this.onTextAreaBlur.bind(this)}
|
||||
onChange={this.onTextAreaChange.bind(this)}
|
||||
onKeyDown={this.onKeyDown}
|
||||
style={{ height: textAreaInnerHeight, maxHeight: textAreaMaxHeight, fontSize: termFontSize }}
|
||||
className={cn("chat-textarea")}
|
||||
style={{ fontSize: this.termFontSize }}
|
||||
className="chat-textarea"
|
||||
placeholder="Send a Message..."
|
||||
></textarea>
|
||||
</div>
|
||||
</AuxiliaryCmdView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
51
src/app/workspace/cmdinput/auxview.less
Normal file
51
src/app/workspace/cmdinput/auxview.less
Normal 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);
|
||||
}
|
||||
}
|
50
src/app/workspace/cmdinput/auxview.tsx
Normal file
50
src/app/workspace/cmdinput/auxview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -4,57 +4,155 @@
|
||||
max-height: max(300px, 40%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: calc(var(--termpad) * 2) calc(var(--termpad) * 2) calc(var(--termpad) * 2) calc(var(--termpad) * 3 - 1px);
|
||||
z-index: 100;
|
||||
border-top: 2px solid var(--app-border-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-history {
|
||||
.cmdinput-actions {
|
||||
display: none;
|
||||
&.has-history,
|
||||
&.has-info {
|
||||
.base-cmdinput {
|
||||
border-top: 1px solid var(--app-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.titlebar-spacer {
|
||||
height: 31px;
|
||||
&.has-info {
|
||||
padding-top: var(--termpad);
|
||||
}
|
||||
|
||||
.cmdinput-conn {
|
||||
&.has-history,
|
||||
&.has-aichat {
|
||||
padding-top: var(--termpad);
|
||||
height: max(300px, 70%);
|
||||
}
|
||||
|
||||
.remote-status-warning {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: var(--app-warning-color);
|
||||
align-items: center;
|
||||
|
||||
.wave-button,
|
||||
.button {
|
||||
margin-left: 10px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-input-grow-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.base-cmdinput {
|
||||
position: relative;
|
||||
// Rather than apply the padding to the whole container, we will apply it to the inner contents directly.
|
||||
// This is more fragile, but allows us to capture a larger target area for the individual components.
|
||||
--padding-top: var(--termpad);
|
||||
--padding-sides: calc(var(--termpad) * 2);
|
||||
|
||||
.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);
|
||||
|
||||
// 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);
|
||||
font-size: var(--termfontsize);
|
||||
border: none;
|
||||
cursor: text;
|
||||
|
||||
// We don't want to pad the top or it will push the input field down.
|
||||
padding: 0 var(--padding-sides) var(--padding-top) var(--padding-sides);
|
||||
|
||||
.cmd-hints {
|
||||
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;
|
||||
bottom: -14px;
|
||||
right: 0px;
|
||||
}
|
||||
.control {
|
||||
padding: 1em 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(88, 193, 66, 0.5);
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
// Align to the same bounds as the input field
|
||||
top: var(--padding-top);
|
||||
right: var(--padding-sides);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -83,7 +181,9 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
padding: 4px 6px;
|
||||
// This aligns the icons with the prompt field.
|
||||
// We don't need right padding because the whole input field is already padded.
|
||||
padding: 2px 0 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -91,467 +191,5 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-info {
|
||||
padding-top: var(--termpad);
|
||||
}
|
||||
|
||||
.focus-indicator {
|
||||
height: 90%;
|
||||
top: 5%;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
&.has-history,
|
||||
&.has-aichat {
|
||||
padding-top: var(--termpad);
|
||||
height: max(300px, 70%);
|
||||
}
|
||||
|
||||
&.has-remote {
|
||||
max-height: max(300px, 70%);
|
||||
}
|
||||
|
||||
.remote-status-warning {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: var(--app-warning-color);
|
||||
align-items: center;
|
||||
|
||||
.wave-button,
|
||||
.button {
|
||||
margin-left: 10px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-minmax-control {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
color: var(--term-foreground);
|
||||
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmd-input-grow-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.base-cmdinput:not(:first-child) {
|
||||
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;
|
||||
padding-right: var(--termpad);
|
||||
font-family: var(--termfontfamily);
|
||||
font-weight: normal;
|
||||
line-height: var(--termlineheight);
|
||||
font-size: var(--termfontsize);
|
||||
|
||||
.cmd-hints {
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
right: 0px;
|
||||
}
|
||||
.control {
|
||||
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);
|
||||
line-height: var(--termlineheight);
|
||||
font-size: var(--termfontsize);
|
||||
border-radius: 4px 4px 0 4px; // 0 is for shelltag
|
||||
|
||||
&.display-disabled {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
input.history-input {
|
||||
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;
|
||||
right: 0;
|
||||
.icon {
|
||||
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 {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cmd-aichat {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-flow: column nowrap;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.chat-window {
|
||||
overflow-y: auto;
|
||||
margin-bottom: 5px;
|
||||
flex-shrink: 1;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
color: 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 {
|
||||
color: var(--app-text-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.history-clickable-opt {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.history-items {
|
||||
color: var(--app-text-color);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import { Choose, If, When } from "tsx-control-statements/components";
|
||||
import cn from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
@ -18,6 +18,7 @@ import { Prompt } from "@/common/prompt/prompt";
|
||||
import { CenteredIcon, RotateIcon } from "@/common/icons/icons";
|
||||
import { AIChat } from "./aichat";
|
||||
import * as util from "@/util/util";
|
||||
import * as appconst from "@/app/appconst";
|
||||
|
||||
import "./cmdinput.less";
|
||||
|
||||
@ -61,12 +62,17 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
cmdInputClick(e: any): void {
|
||||
baseCmdInputClick(e: React.SyntheticEvent): void {
|
||||
if (this.promptRef.current != null) {
|
||||
if (this.promptRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((e.target as HTMLDivElement).classList.contains("cmd-input-context")) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
GlobalModel.inputModel.setAuxViewFocus(false);
|
||||
GlobalModel.inputModel.giveFocus();
|
||||
}
|
||||
|
||||
@ -74,9 +80,13 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
clickAIAction(e: any): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
if (inputModel.getActiveAuxView() === appconst.InputAuxView_AIChat) {
|
||||
inputModel.closeAuxView();
|
||||
} else {
|
||||
inputModel.openAIAssistantChat();
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
clickHistoryAction(e: any): void {
|
||||
@ -84,7 +94,7 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
e.stopPropagation();
|
||||
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
if (inputModel.historyShow.get()) {
|
||||
if (inputModel.getActiveAuxView() === appconst.InputAuxView_History) {
|
||||
inputModel.resetHistory();
|
||||
} else {
|
||||
inputModel.openHistory();
|
||||
@ -146,9 +156,6 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
remote = GlobalModel.getRemote(rptr.remoteid);
|
||||
}
|
||||
feState = feState || {};
|
||||
const infoShow = inputModel.infoShow.get();
|
||||
const historyShow = !infoShow && inputModel.historyShow.get();
|
||||
const aiChatShow = inputModel.aIChatShow.get();
|
||||
const focusVal = inputModel.physicalInputFocused.get();
|
||||
const inputMode: string = inputModel.inputMode.get();
|
||||
const textAreaInputKey = screen == null ? "null" : screen.screenId;
|
||||
@ -160,6 +167,9 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
}
|
||||
let shellInitMsg: string = null;
|
||||
let hidePrompt = false;
|
||||
|
||||
const openView = inputModel.getActiveAuxView();
|
||||
const hasOpenView = openView ? `has-${openView}` : null;
|
||||
if (ri == null) {
|
||||
let shellStr = "shell";
|
||||
if (!util.isBlank(remote?.defaultshelltype)) {
|
||||
@ -172,51 +182,20 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={this.cmdInputRef}
|
||||
className={cn(
|
||||
"cmd-input",
|
||||
{ "has-info": infoShow },
|
||||
{ "has-aichat": aiChatShow },
|
||||
{ "has-history": historyShow },
|
||||
{ active: focusVal }
|
||||
)}
|
||||
>
|
||||
<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={historyShow}>
|
||||
<div ref={this.cmdInputRef} className={cn("cmd-input", hasOpenView, { active: focusVal })}>
|
||||
<Choose>
|
||||
<When condition={openView === appconst.InputAuxView_History}>
|
||||
<div className="cmd-input-grow-spacer"></div>
|
||||
<HistoryInfo />
|
||||
</If>
|
||||
<If condition={aiChatShow}>
|
||||
</When>
|
||||
<When condition={openView === appconst.InputAuxView_AIChat}>
|
||||
<div className="cmd-input-grow-spacer"></div>
|
||||
<AIChat />
|
||||
</If>
|
||||
</When>
|
||||
<When condition={openView === appconst.InputAuxView_Info}>
|
||||
<InfoMsg key="infomsg" />
|
||||
</When>
|
||||
</Choose>
|
||||
<If condition={remote && remote.status != "connected"}>
|
||||
<div className="remote-status-warning">
|
||||
WARNING:
|
||||
@ -252,7 +231,43 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
</Button>
|
||||
</div>
|
||||
</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}>
|
||||
<div key="prompt" className="cmd-input-context">
|
||||
<div className="has-text-white">
|
||||
|
60
src/app/workspace/cmdinput/historyinfo.less
Normal file
60
src/app/workspace/cmdinput/historyinfo.less
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -13,6 +13,9 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import { GlobalModel } from "@/models";
|
||||
import { isBlank } from "@/util/util";
|
||||
|
||||
import "./historyinfo.less";
|
||||
import { AuxiliaryCmdView } from "./auxview";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
const TDots = "⋮";
|
||||
@ -43,7 +46,7 @@ class HItem extends React.Component<
|
||||
if (hitem.remote == null || isBlank(hitem.remote.remoteid)) {
|
||||
return sprintf("%-15s ", "");
|
||||
}
|
||||
let r = GlobalModel.getRemote(hitem.remote.remoteid);
|
||||
const r = GlobalModel.getRemote(hitem.remote.remoteid);
|
||||
if (r == null) {
|
||||
return sprintf("%-15s ", "???");
|
||||
}
|
||||
@ -71,15 +74,15 @@ class HItem extends React.Component<
|
||||
if (!opts.limitRemote) {
|
||||
remoteStr = this.renderRemote(hitem);
|
||||
}
|
||||
let selectedStr = isSelected ? "*" : " ";
|
||||
let lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : "";
|
||||
const selectedStr = isSelected ? "*" : " ";
|
||||
const lineNumStr = hitem.linenum > 0 ? "(" + hitem.linenum + ")" : "";
|
||||
if (isBlank(opts.queryType) || opts.queryType == "screen") {
|
||||
return selectedStr + sprintf("%7s", lineNumStr) + " " + remoteStr;
|
||||
}
|
||||
if (opts.queryType == "session") {
|
||||
let screenStr = "";
|
||||
if (!isBlank(hitem.screenid)) {
|
||||
let scrName = scrNames[hitem.screenid];
|
||||
const scrName = scrNames[hitem.screenid];
|
||||
if (scrName != null) {
|
||||
screenStr = "[" + truncateWithTDots(scrName, 15) + "]";
|
||||
}
|
||||
@ -89,19 +92,18 @@ class HItem extends React.Component<
|
||||
if (opts.queryType == "global") {
|
||||
let sessionStr = "";
|
||||
if (!isBlank(hitem.sessionid)) {
|
||||
let sessionName = snames[hitem.sessionid];
|
||||
const sessionName = snames[hitem.sessionid];
|
||||
if (sessionName != null) {
|
||||
sessionStr = "#" + truncateWithTDots(sessionName, 15);
|
||||
}
|
||||
}
|
||||
let screenStr = "";
|
||||
if (!isBlank(hitem.screenid)) {
|
||||
let scrName = scrNames[hitem.screenid];
|
||||
const scrName = scrNames[hitem.screenid];
|
||||
if (scrName != null) {
|
||||
screenStr = "[" + truncateWithTDots(scrName, 13) + "]";
|
||||
}
|
||||
}
|
||||
let ssStr = sessionStr + screenStr;
|
||||
return (
|
||||
selectedStr +
|
||||
sprintf("%15s ", sessionStr) +
|
||||
@ -116,12 +118,12 @@ class HItem extends React.Component<
|
||||
}
|
||||
|
||||
render() {
|
||||
let { hitem, isSelected, opts, snames, scrNames } = this.props;
|
||||
let lines = hitem.cmdstr.split("\n");
|
||||
const { hitem, isSelected, opts, snames, scrNames } = this.props;
|
||||
const lines = hitem.cmdstr.split("\n");
|
||||
let line: string = "";
|
||||
let idx = 0;
|
||||
let infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames);
|
||||
let infoTextSpacer = sprintf("%" + infoText.length + "s", "");
|
||||
const infoText = this.renderHInfoText(hitem, opts, isSelected, snames, scrNames);
|
||||
const infoTextSpacer = sprintf("%" + infoText.length + "s", "");
|
||||
return (
|
||||
<div
|
||||
key={hitem.historynum}
|
||||
@ -153,7 +155,7 @@ class HistoryInfo extends React.Component<{}, {}> {
|
||||
containingText: mobx.IObservableValue<string> = mobx.observable.box("");
|
||||
|
||||
componentDidMount() {
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
let hitem = inputModel.getHistorySelectedItem();
|
||||
if (hitem == null) {
|
||||
hitem = inputModel.getFirstHistoryItem();
|
||||
@ -165,20 +167,20 @@ class HistoryInfo extends React.Component<{}, {}> {
|
||||
|
||||
@boundMethod
|
||||
handleClose() {
|
||||
GlobalModel.inputModel.toggleInfoMsg();
|
||||
GlobalModel.inputModel.closeAuxView();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleItemClick(hitem: HistoryItem) {
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
let selItem = inputModel.getHistorySelectedItem();
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const selItem = inputModel.getHistorySelectedItem();
|
||||
inputModel.setAuxViewFocus(false);
|
||||
if (this.lastClickHNum == hitem.historynum && selItem != null && selItem.historynum == hitem.historynum) {
|
||||
inputModel.grabSelectedHistoryItem();
|
||||
return;
|
||||
}
|
||||
inputModel.giveFocus();
|
||||
inputModel.setHistorySelectionNum(hitem.historynum);
|
||||
let now = Date.now();
|
||||
const now = Date.now();
|
||||
this.lastClickHNum = hitem.historynum;
|
||||
this.lastClickTs = now;
|
||||
setTimeout(() => {
|
||||
@ -191,24 +193,41 @@ class HistoryInfo extends React.Component<{}, {}> {
|
||||
|
||||
@boundMethod
|
||||
handleClickType() {
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
inputModel.setAuxViewFocus(true);
|
||||
inputModel.toggleHistoryType();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleClickRemote() {
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
inputModel.setAuxViewFocus(true);
|
||||
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} ⌘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 ⌘R]
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
let inputModel = GlobalModel.inputModel;
|
||||
let idx: number = 0;
|
||||
let selItem = inputModel.getHistorySelectedItem();
|
||||
let hitems = inputModel.getFilteredHistoryItems();
|
||||
hitems = hitems.slice().reverse();
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const selItem = inputModel.getHistorySelectedItem();
|
||||
const hitems = inputModel.getFilteredHistoryItems().slice().reverse();
|
||||
const opts = inputModel.historyQueryOpts.get();
|
||||
let hitem: HistoryItem = null;
|
||||
let opts = inputModel.historyQueryOpts.get();
|
||||
let snames: Record<string, string> = {};
|
||||
let scrNames: Record<string, string> = {};
|
||||
if (opts.queryType == "global") {
|
||||
@ -218,29 +237,13 @@ class HistoryInfo extends React.Component<{}, {}> {
|
||||
scrNames = GlobalModel.getScreenNames();
|
||||
}
|
||||
return (
|
||||
<div className="cmd-history hide-scrollbar">
|
||||
<div className="cmdinput-titlebar history-title">
|
||||
<div className="title-icon">
|
||||
<i className="fa-sharp fa-solid fa-clock-rotate-left" />
|
||||
</div>
|
||||
<div className="title-string">History</div>
|
||||
<div className="spacer"></div>
|
||||
<div className="history-opt history-clickable-opt" onClick={this.handleClickType}>
|
||||
[for {opts.queryType} ⌘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 ⌘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>
|
||||
<AuxiliaryCmdView
|
||||
title="History"
|
||||
className="cmd-history hide-scrollbar"
|
||||
onClose={this.handleClose}
|
||||
titleBarContents={this.getTitleBarContents()}
|
||||
iconClass="fa-sharp fa-solid fa-clock-rotate-left"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"history-items",
|
||||
@ -248,7 +251,6 @@ class HistoryInfo extends React.Component<{}, {}> {
|
||||
{ "show-sessions": opts.queryType == "global" }
|
||||
)}
|
||||
>
|
||||
<div className="titlebar-spacer" />
|
||||
<If condition={hitems.length == 0}>[no history]</If>
|
||||
<If condition={hitems.length > 0}>
|
||||
<For each="hitem" index="idx" of={hitems}>
|
||||
@ -264,7 +266,7 @@ class HistoryInfo extends React.Component<{}, {}> {
|
||||
</For>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
</AuxiliaryCmdView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
49
src/app/workspace/cmdinput/infomsg.less
Normal file
49
src/app/workspace/cmdinput/infomsg.less
Normal 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;
|
||||
}
|
||||
}
|
@ -9,6 +9,9 @@ import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import { GlobalModel } from "@/models";
|
||||
import * as appconst from "@/app/appconst";
|
||||
import { AuxiliaryCmdView } from "./auxview";
|
||||
|
||||
import "./infomsg.less";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
@ -40,29 +43,22 @@ class InfoMsg extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
render() {
|
||||
let model = GlobalModel;
|
||||
let inputModel = model.inputModel;
|
||||
let infoMsg = inputModel.infoMsg.get();
|
||||
let infoShow = inputModel.infoShow.get();
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const infoMsg: InfoType = inputModel.infoMsg.get();
|
||||
const infoShow = inputModel.getActiveAuxView() == appconst.InputAuxView_Info;
|
||||
let line: string = null;
|
||||
let istr: string = null;
|
||||
let idx: number = 0;
|
||||
let titleStr = null;
|
||||
let remoteEditKey = "inforemoteedit";
|
||||
if (infoMsg != null) {
|
||||
titleStr = infoMsg.infotitle;
|
||||
}
|
||||
let activeScreen = model.getActiveScreen();
|
||||
if (!infoShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cmd-input-info" style={{ display: infoShow ? "block" : "none" }}>
|
||||
<If condition={infoMsg?.infotitle}>
|
||||
<div key="infotitle" className="info-title">
|
||||
{titleStr}
|
||||
</div>
|
||||
</If>
|
||||
<AuxiliaryCmdView title={titleStr} className="cmd-input-info">
|
||||
<If condition={infoMsg?.infomsg}>
|
||||
<div key="infomsg" className="info-msg">
|
||||
<If condition={infoMsg.infomsghtml}>
|
||||
@ -108,7 +104,7 @@ class InfoMsg extends React.Component<{}, {}> {
|
||||
<div className="info-error">to reset, run: /reset:cwd</div>
|
||||
</If>
|
||||
</If>
|
||||
</div>
|
||||
</AuxiliaryCmdView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -152,11 +152,7 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "cmdinput", "generic:cancel", (waveEvent) => {
|
||||
GlobalModel.closeTabSettings();
|
||||
inputModel.toggleInfoMsg();
|
||||
if (inputModel.inputMode.get() != null) {
|
||||
inputModel.resetInputMode();
|
||||
}
|
||||
inputModel.closeAIAssistantChat(true);
|
||||
inputModel.closeAuxView();
|
||||
return true;
|
||||
});
|
||||
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();
|
||||
lastHeight: number = 0;
|
||||
lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos };
|
||||
version: OV<number> = mobx.observable.box(0); // forces render updates
|
||||
mainInputFocused: OV<boolean> = mobx.observable.box(true);
|
||||
historyFocused: OV<boolean> = mobx.observable.box(false);
|
||||
version: OV<number> = mobx.observable.box(0, { name: "textAreaInput-version" }); // forces render updates
|
||||
|
||||
incVersion(): void {
|
||||
const v = this.version.get();
|
||||
@ -286,12 +280,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
}
|
||||
|
||||
setFocus(): void {
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
if (inputModel.historyShow.get()) {
|
||||
this.historyInputRef.current.focus();
|
||||
} else {
|
||||
this.mainInputRef.current.focus();
|
||||
}
|
||||
GlobalModel.inputModel.giveFocus();
|
||||
}
|
||||
|
||||
getTextAreaMaxCols(): number {
|
||||
@ -532,7 +521,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
if (selStart > value.length || selEnd > value.length) {
|
||||
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 };
|
||||
GlobalModel.inputModel.updateCmdLine(cmdLineUpdate);
|
||||
});
|
||||
@ -549,19 +538,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleMainFocus(e: any) {
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
if (inputModel.historyShow.get()) {
|
||||
handleFocus(e: any) {
|
||||
e.preventDefault();
|
||||
if (this.historyInputRef.current != null) {
|
||||
this.historyInputRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
inputModel.setPhysicalInputFocused(true);
|
||||
mobx.action(() => {
|
||||
this.mainInputFocused.set(true);
|
||||
})();
|
||||
GlobalModel.inputModel.giveFocus();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
@ -570,25 +549,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
return;
|
||||
}
|
||||
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
|
||||
@ -597,9 +557,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
return;
|
||||
}
|
||||
GlobalModel.inputModel.setPhysicalInputFocused(false);
|
||||
mobx.action(() => {
|
||||
this.historyFocused.set(false);
|
||||
})();
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -616,8 +573,9 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
if (numLines > 1 || longLine || inputModel.inputExpanded.get()) {
|
||||
displayLines = 5;
|
||||
}
|
||||
const disabled = inputModel.historyShow.get();
|
||||
if (disabled) {
|
||||
|
||||
const auxViewFocused = inputModel.getAuxViewFocus();
|
||||
if (auxViewFocused) {
|
||||
displayLines = 1;
|
||||
}
|
||||
const activeScreen = GlobalModel.getActiveScreen();
|
||||
@ -633,7 +591,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
const screen = GlobalModel.getActiveScreen();
|
||||
if (screen != null) {
|
||||
const ri = screen.getCurRemoteInstance();
|
||||
if (ri != null && ri.shelltype != null) {
|
||||
if (ri?.shelltype != null) {
|
||||
shellType = ri.shelltype;
|
||||
}
|
||||
if (shellType == "") {
|
||||
@ -646,22 +604,21 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
const isMainInputFocused = this.mainInputFocused.get();
|
||||
const isHistoryFocused = this.historyFocused.get();
|
||||
const isHistoryFocused = auxViewFocused && inputModel.getActiveAuxView() == appconst.InputAuxView_History;
|
||||
return (
|
||||
<div
|
||||
className="textareainput-div control is-expanded"
|
||||
ref={this.controlRef}
|
||||
style={{ height: computedOuterHeight }}
|
||||
>
|
||||
<If condition={isMainInputFocused}>
|
||||
<If condition={!auxViewFocused}>
|
||||
<CmdInputKeybindings inputObject={this}></CmdInputKeybindings>
|
||||
</If>
|
||||
<If condition={isHistoryFocused}>
|
||||
<HistoryKeybindings inputObject={this}></HistoryKeybindings>
|
||||
</If>
|
||||
|
||||
<If condition={!disabled && !util.isBlank(shellType)}>
|
||||
<If condition={!util.isBlank(shellType)}>
|
||||
<div className="shelltag">{shellType}</div>
|
||||
</If>
|
||||
<textarea
|
||||
@ -671,14 +628,15 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
id="main-cmd-input"
|
||||
onFocus={this.handleMainFocus}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleMainBlur}
|
||||
style={{ height: computedInnerHeight, minHeight: computedInnerHeight, fontSize: termFontSize }}
|
||||
value={curLine}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
onSelect={this.onSelect}
|
||||
className={cn("textarea", { "display-disabled": disabled })}
|
||||
placeholder="Type here..."
|
||||
className={cn("textarea", { "display-disabled": auxViewFocused })}
|
||||
></textarea>
|
||||
<input
|
||||
key="history"
|
||||
@ -688,7 +646,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
autoCorrect="off"
|
||||
className="history-input"
|
||||
type="text"
|
||||
onFocus={this.handleHistoryFocus}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleHistoryBlur}
|
||||
onKeyDown={this.onHistoryKeyDown}
|
||||
onChange={this.handleHistoryInput}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,6 @@ import { CmdInput } from "./cmdinput/cmdinput";
|
||||
import { ScreenView } from "./screen/screenview";
|
||||
import { ScreenTabs } from "./screen/tabs";
|
||||
import { ErrorBoundary } from "@/common/error/errorboundary";
|
||||
import * as textmeasure from "@/util/textmeasure";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import type { Screen } from "@/models";
|
||||
import { Button, StyleBlock } from "@/elements";
|
||||
@ -34,7 +33,7 @@ Are you sure you want to delete this tab?
|
||||
|
||||
class SessionKeybindings extends React.Component<{}, {}> {
|
||||
componentDidMount() {
|
||||
let keybindManager = GlobalModel.keybindManager;
|
||||
const keybindManager = GlobalModel.keybindManager;
|
||||
keybindManager.registerKeybinding("mainview", "session", "app:toggleSidebar", (waveEvent) => {
|
||||
GlobalModel.handleToggleSidebar();
|
||||
return true;
|
||||
@ -96,7 +95,7 @@ class SessionKeybindings extends React.Component<{}, {}> {
|
||||
@mobxReact.observer
|
||||
class TabSettingsPulldownKeybindings extends React.Component<{}, {}> {
|
||||
componentDidMount() {
|
||||
let keybindManager = GlobalModel.keybindManager;
|
||||
const keybindManager = GlobalModel.keybindManager;
|
||||
keybindManager.registerKeybinding("pane", "tabsettings", "generic:cancel", (waveEvent) => {
|
||||
GlobalModel.closeTabSettings();
|
||||
return true;
|
||||
@ -127,13 +126,13 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
|
||||
GlobalModel.modalsModel.popModal();
|
||||
return;
|
||||
}
|
||||
let message = ScreenDeleteMessage;
|
||||
let alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true });
|
||||
const message = ScreenDeleteMessage;
|
||||
const alertRtn = GlobalModel.showAlert({ message: message, confirm: true, markdown: true });
|
||||
alertRtn.then((result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
let prtn = GlobalCommandRunner.screenDelete(screen.screenId, false);
|
||||
const prtn = GlobalCommandRunner.screenDelete(screen.screenId, false);
|
||||
util.commandRtnHandler(prtn, this.errorMessage);
|
||||
GlobalModel.modalsModel.popModal();
|
||||
});
|
||||
@ -151,8 +150,8 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
|
||||
}
|
||||
|
||||
render() {
|
||||
let { screen } = this.props;
|
||||
let rptr = screen.curRemote.get();
|
||||
const { screen } = this.props;
|
||||
const rptr = screen.curRemote.get();
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes);
|
||||
const currTermTheme = GlobalModel.getTermTheme()[screen.screenId] ?? termThemes[0].label;
|
||||
return (
|
||||
@ -162,13 +161,13 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
|
||||
</div>
|
||||
<div className="newtab-spacer" />
|
||||
<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?
|
||||
</div>
|
||||
<div>
|
||||
<TabRemoteSelector screen={screen} errorMessage={this.errorMessage} />
|
||||
</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]`
|
||||
</div>
|
||||
</div>
|
||||
@ -218,18 +217,13 @@ class WorkspaceView extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const model = GlobalModel;
|
||||
const session = model.getActiveSession();
|
||||
const session = GlobalModel.getActiveSession();
|
||||
let activeScreen: Screen = null;
|
||||
let sessionId: string = "none";
|
||||
if (session != null) {
|
||||
sessionId = session.sessionId;
|
||||
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 mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
const showTabSettings = GlobalModel.tabSettingsOpen.get();
|
||||
@ -258,9 +252,9 @@ class WorkspaceView extends React.Component<{}, {}> {
|
||||
<ScreenTabs key={"tabs-" + sessionId} session={session} />
|
||||
<If condition={activeScreen != null}>
|
||||
<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" />
|
||||
</div>
|
||||
</button>
|
||||
<TabSettings key={activeScreen.screenId} screen={activeScreen} />
|
||||
<If condition={showTabSettings && !isHidden}>
|
||||
<TabSettingsPulldownKeybindings />
|
||||
@ -269,7 +263,6 @@ class WorkspaceView extends React.Component<{}, {}> {
|
||||
</If>
|
||||
<ErrorBoundary key="eb">
|
||||
<ScreenView key={`screenview-${sessionId}`} session={session} screen={activeScreen} />
|
||||
<div className="cmdinput-height-placeholder" style={{ height: cmdInputHeight }}></div>
|
||||
<If condition={activeScreen != null}>
|
||||
<CmdInput key={"cmdinput-" + sessionId} />
|
||||
</If>
|
||||
|
@ -8,6 +8,7 @@ import { isBlank } from "@/util/util";
|
||||
import * as appconst from "@/app/appconst";
|
||||
import { Model } from "./model";
|
||||
import { GlobalCommandRunner } from "./global";
|
||||
import { app } from "electron";
|
||||
|
||||
function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
|
||||
return {
|
||||
@ -24,14 +25,15 @@ function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
|
||||
|
||||
class InputModel {
|
||||
globalModel: Model;
|
||||
historyShow: OV<boolean> = mobx.observable.box(false);
|
||||
infoShow: OV<boolean> = mobx.observable.box(false);
|
||||
aIChatShow: OV<boolean> = mobx.observable.box(false);
|
||||
activeAuxView: OV<InputAuxViewType> = mobx.observable.box(null);
|
||||
auxViewFocus: OV<boolean> = mobx.observable.box(false);
|
||||
cmdInputHeight: OV<number> = mobx.observable.box(0);
|
||||
aiChatTextAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
aiChatWindowRef: React.RefObject<HTMLDivElement>;
|
||||
codeSelectBlockRefArray: Array<React.RefObject<HTMLElement>>;
|
||||
codeSelectSelectedIndex: OV<number> = mobx.observable.box(-1);
|
||||
codeSelectUuid: string;
|
||||
inputPopUpType: OV<string> = mobx.observable.box("none");
|
||||
|
||||
AICmdInfoChatItems: mobx.IObservableArray<OpenAICmdInfoChatMessageType> = mobx.observable.array([], {
|
||||
name: "aicmdinfo-chat",
|
||||
@ -80,6 +82,7 @@ class InputModel {
|
||||
this.codeSelectSelectedIndex.set(-1);
|
||||
this.codeSelectBlockRefArray = [];
|
||||
})();
|
||||
this.codeSelectUuid = "";
|
||||
}
|
||||
|
||||
setInputMode(inputMode: null | "comment" | "global"): void {
|
||||
@ -89,7 +92,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
toggleHistoryType(): void {
|
||||
let opts = mobx.toJS(this.historyQueryOpts.get());
|
||||
const opts = mobx.toJS(this.historyQueryOpts.get());
|
||||
let htype = opts.queryType;
|
||||
if (htype == "screen") {
|
||||
htype = "session";
|
||||
@ -102,7 +105,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
toggleRemoteType(): void {
|
||||
let opts = mobx.toJS(this.historyQueryOpts.get());
|
||||
const opts = mobx.toJS(this.historyQueryOpts.get());
|
||||
if (opts.limitRemote) {
|
||||
opts.limitRemote = false;
|
||||
opts.limitRemoteInstance = false;
|
||||
@ -135,26 +138,39 @@ class InputModel {
|
||||
})();
|
||||
}
|
||||
|
||||
_focusCmdInput(): void {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Focuses the main input or the auxiliary view, depending on the active auxiliary view
|
||||
giveFocus(): void {
|
||||
if (this.historyShow.get()) {
|
||||
this._focusHistoryInput();
|
||||
} else {
|
||||
this._focusCmdInput();
|
||||
// Override active view to the main input if aux view does not have focus
|
||||
const activeAuxView = this.getAuxViewFocus() ? this.getActiveAuxView() : null;
|
||||
mobx.action(() => {
|
||||
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 {
|
||||
@ -162,7 +178,7 @@ class InputModel {
|
||||
this.physicalInputFocused.set(isFocused);
|
||||
})();
|
||||
if (isFocused) {
|
||||
let screen = this.globalModel.getActiveScreen();
|
||||
const screen = this.globalModel.getActiveScreen();
|
||||
if (screen != null) {
|
||||
if (screen.focusType.get() != "input") {
|
||||
GlobalCommandRunner.screenSetFocus("input");
|
||||
@ -172,14 +188,18 @@ class InputModel {
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
let mainInputElem = document.getElementById("main-cmd-input");
|
||||
const mainInputElem = document.getElementById("main-cmd-input");
|
||||
if (document.activeElement == mainInputElem) {
|
||||
return true;
|
||||
}
|
||||
let historyInputElem = document.querySelector(".cmd-input input.history-input");
|
||||
const historyInputElem = document.querySelector(".cmd-input input.history-input");
|
||||
if (document.activeElement == historyInputElem) {
|
||||
return true;
|
||||
}
|
||||
let aiChatInputElem = document.querySelector(".cmd-input chat-cmd-input");
|
||||
if (document.activeElement == aiChatInputElem) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -194,20 +214,19 @@ class InputModel {
|
||||
if (oldItem == null) {
|
||||
return 0;
|
||||
}
|
||||
let newItems = this.getFilteredHistoryItems();
|
||||
const newItems = this.getFilteredHistoryItems();
|
||||
if (newItems.length == 0) {
|
||||
return 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
|
||||
let item = newItems[i];
|
||||
if (item.historynum == oldItem.historynum) {
|
||||
bestIdx = i;
|
||||
break;
|
||||
}
|
||||
let bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts);
|
||||
let curTsDiff = Math.abs(item.ts - oldItem.ts);
|
||||
const bestTsDiff = Math.abs(item.ts - newItems[bestIdx].ts);
|
||||
const curTsDiff = Math.abs(item.ts - oldItem.ts);
|
||||
if (curTsDiff < bestTsDiff) {
|
||||
bestIdx = i;
|
||||
}
|
||||
@ -217,9 +236,9 @@ class InputModel {
|
||||
|
||||
setHistoryQueryOpts(opts: HistoryQueryOpts): void {
|
||||
mobx.action(() => {
|
||||
let oldItem = this.getHistorySelectedItem();
|
||||
const oldItem = this.getHistorySelectedItem();
|
||||
this.historyQueryOpts.set(opts);
|
||||
let bestIndex = this.findBestNewIndex(oldItem);
|
||||
const bestIndex = this.findBestNewIndex(oldItem);
|
||||
setTimeout(() => this.setHistoryIndex(bestIndex, true), 10);
|
||||
})();
|
||||
}
|
||||
@ -229,23 +248,11 @@ class InputModel {
|
||||
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 {
|
||||
if (this.historyLoading.get()) {
|
||||
return false;
|
||||
}
|
||||
let hitems = this.historyItems.get();
|
||||
const hitems = this.historyItems.get();
|
||||
return hitems != null;
|
||||
}
|
||||
|
||||
@ -273,13 +280,9 @@ class InputModel {
|
||||
this.loadHistory(true, 0, "screen");
|
||||
return;
|
||||
}
|
||||
if (!this.historyShow.get()) {
|
||||
mobx.action(() => {
|
||||
this.setHistoryShow(true);
|
||||
this.infoShow.set(false);
|
||||
if (this.getActiveAuxView() != appconst.InputAuxView_History) {
|
||||
this.dropModHistory(true);
|
||||
this.giveFocus();
|
||||
})();
|
||||
this.setActiveAuxView(appconst.InputAuxView_History);
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,11 +296,11 @@ class InputModel {
|
||||
}
|
||||
|
||||
getHistorySelectedItem(): HistoryItem {
|
||||
let hidx = this.historyIndex.get();
|
||||
const hidx = this.historyIndex.get();
|
||||
if (hidx == 0) {
|
||||
return null;
|
||||
}
|
||||
let hitems = this.getFilteredHistoryItems();
|
||||
const hitems = this.getFilteredHistoryItems();
|
||||
if (hidx > hitems.length) {
|
||||
return null;
|
||||
}
|
||||
@ -305,7 +308,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
getFirstHistoryItem(): HistoryItem {
|
||||
let hitems = this.getFilteredHistoryItems();
|
||||
const hitems = this.getFilteredHistoryItems();
|
||||
if (hitems.length == 0) {
|
||||
return null;
|
||||
}
|
||||
@ -313,9 +316,9 @@ class InputModel {
|
||||
}
|
||||
|
||||
setHistorySelectionNum(hnum: string): void {
|
||||
let hitems = this.getFilteredHistoryItems();
|
||||
for (let i = 0; i < hitems.length; i++) {
|
||||
if (hitems[i].historynum == hnum) {
|
||||
const hitems = this.getFilteredHistoryItems();
|
||||
for (const [i, hitem] of hitems.entries()) {
|
||||
if (hitem.historynum == hnum) {
|
||||
this.setHistoryIndex(i + 1);
|
||||
return;
|
||||
}
|
||||
@ -324,8 +327,8 @@ class InputModel {
|
||||
|
||||
setHistoryInfo(hinfo: HistoryInfoType): void {
|
||||
mobx.action(() => {
|
||||
let oldItem = this.getHistorySelectedItem();
|
||||
let hitems: HistoryItem[] = hinfo.items ?? [];
|
||||
const oldItem = this.getHistorySelectedItem();
|
||||
const hitems: HistoryItem[] = hinfo.items ?? [];
|
||||
this.historyItems.set(hitems);
|
||||
this.historyLoading.set(false);
|
||||
this.historyQueryOpts.get().queryType = hinfo.historytype;
|
||||
@ -334,7 +337,7 @@ class InputModel {
|
||||
this.historyQueryOpts.get().limitRemoteInstance = false;
|
||||
}
|
||||
if (this.historyAfterLoadIndex == -1) {
|
||||
let bestIndex = this.findBestNewIndex(oldItem);
|
||||
const bestIndex = this.findBestNewIndex(oldItem);
|
||||
setTimeout(() => this.setHistoryIndex(bestIndex, true), 100);
|
||||
} else if (this.historyAfterLoadIndex) {
|
||||
if (hitems.length >= this.historyAfterLoadIndex) {
|
||||
@ -353,10 +356,10 @@ class InputModel {
|
||||
}
|
||||
|
||||
_getFilteredHistoryItems(): HistoryItem[] {
|
||||
let hitems: HistoryItem[] = this.historyItems.get() ?? [];
|
||||
let rtn: HistoryItem[] = [];
|
||||
let opts = mobx.toJS(this.historyQueryOpts.get());
|
||||
let ctx = this.globalModel.getUIContext();
|
||||
const hitems: HistoryItem[] = this.historyItems.get() ?? [];
|
||||
const rtn: HistoryItem[] = [];
|
||||
const opts: HistoryQueryOpts = mobx.toJS(this.historyQueryOpts.get());
|
||||
const ctx = this.globalModel.getUIContext();
|
||||
let curRemote: RemotePtrType = ctx.remote;
|
||||
if (curRemote == null) {
|
||||
curRemote = { ownerid: "", name: "", remoteid: "" };
|
||||
@ -393,7 +396,7 @@ class InputModel {
|
||||
if (isBlank(hitem.cmdstr)) {
|
||||
continue;
|
||||
}
|
||||
let idx = hitem.cmdstr.indexOf(opts.queryStr);
|
||||
const idx = hitem.cmdstr.indexOf(opts.queryStr);
|
||||
if (idx == -1) {
|
||||
continue;
|
||||
}
|
||||
@ -405,24 +408,24 @@ class InputModel {
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
let historyDiv = elem.closest(".cmd-history");
|
||||
const historyDiv = elem.closest(".cmd-history");
|
||||
if (historyDiv == null) {
|
||||
return;
|
||||
}
|
||||
let buffer = 15;
|
||||
const buffer = 15;
|
||||
let titleHeight = 24;
|
||||
let titleDiv: HTMLElement = document.querySelector(".cmd-history .history-title");
|
||||
const titleDiv: HTMLElement = document.querySelector(".cmd-history .history-title");
|
||||
if (titleDiv != null) {
|
||||
titleHeight = titleDiv.offsetHeight + 2;
|
||||
}
|
||||
let elemOffset = elem.offsetTop;
|
||||
let elemHeight = elem.clientHeight;
|
||||
let topPos = historyDiv.scrollTop;
|
||||
let endPos = topPos + historyDiv.clientHeight;
|
||||
const elemOffset = elem.offsetTop;
|
||||
const elemHeight = elem.clientHeight;
|
||||
const topPos = historyDiv.scrollTop;
|
||||
const endPos = topPos + historyDiv.clientHeight;
|
||||
if (elemOffset + elemHeight + buffer > endPos) {
|
||||
if (elemHeight + buffer > historyDiv.clientHeight - titleHeight) {
|
||||
historyDiv.scrollTop = elemOffset - titleHeight;
|
||||
@ -441,7 +444,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
grabSelectedHistoryItem(): void {
|
||||
let hitem = this.getHistorySelectedItem();
|
||||
const hitem = this.getHistorySelectedItem();
|
||||
if (hitem == null) {
|
||||
this.resetHistory();
|
||||
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 {
|
||||
if (hidx < 0) {
|
||||
return;
|
||||
@ -461,7 +509,7 @@ class InputModel {
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.historyIndex.set(hidx);
|
||||
if (this.historyShow.get()) {
|
||||
if (this.getActiveAuxView() == appconst.InputAuxView_History) {
|
||||
let hitem = this.getHistorySelectedItem();
|
||||
if (hitem == null) {
|
||||
hitem = this.getFirstHistoryItem();
|
||||
@ -480,9 +528,8 @@ class InputModel {
|
||||
if (!this.isHistoryLoaded()) {
|
||||
return;
|
||||
}
|
||||
let hitems = this.getFilteredHistoryItems();
|
||||
let idx = this.historyIndex.get();
|
||||
idx += amt;
|
||||
const hitems = this.getFilteredHistoryItems();
|
||||
let idx = this.historyIndex.get() + amt;
|
||||
if (idx < 0) {
|
||||
idx = 0;
|
||||
}
|
||||
@ -496,16 +543,18 @@ class InputModel {
|
||||
this._clearInfoTimeout();
|
||||
mobx.action(() => {
|
||||
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) {
|
||||
this.infoTimeoutId = setTimeout(() => {
|
||||
if (this.historyShow.get()) {
|
||||
console.log("clearing info msg");
|
||||
if (this.activeAuxView.get() != appconst.InputAuxView_Info) {
|
||||
return;
|
||||
}
|
||||
this.clearInfoMsg(false);
|
||||
@ -532,16 +581,19 @@ class InputModel {
|
||||
this.codeSelectSelectedIndex.get() >= 0 &&
|
||||
this.codeSelectSelectedIndex.get() < this.codeSelectBlockRefArray.length
|
||||
) {
|
||||
let curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()];
|
||||
let codeText = curBlockRef.current.innerText;
|
||||
codeText = codeText.replace(/\n$/, ""); // remove trailing newline
|
||||
const curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()];
|
||||
const codeText = curBlockRef.current.innerText.replace(/\n$/, ""); // remove trailing newline
|
||||
this.setCurLine(codeText);
|
||||
this.giveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
addCodeBlockToCodeSelect(blockRef: React.RefObject<HTMLElement>): number {
|
||||
addCodeBlockToCodeSelect(blockRef: React.RefObject<HTMLElement>, uuid: string): number {
|
||||
let rtn = -1;
|
||||
if (uuid != this.codeSelectUuid) {
|
||||
this.codeSelectUuid = uuid;
|
||||
this.codeSelectBlockRefArray = [];
|
||||
}
|
||||
rtn = this.codeSelectBlockRefArray.length;
|
||||
this.codeSelectBlockRefArray.push(blockRef);
|
||||
return rtn;
|
||||
@ -551,14 +603,13 @@ class InputModel {
|
||||
mobx.action(() => {
|
||||
if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) {
|
||||
this.codeSelectSelectedIndex.set(blockIndex);
|
||||
let currentRef = this.codeSelectBlockRefArray[blockIndex].current;
|
||||
if (currentRef != null) {
|
||||
if (this.aiChatWindowRef?.current != null) {
|
||||
let chatWindowTop = this.aiChatWindowRef.current.scrollTop;
|
||||
let chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100;
|
||||
let elemTop = currentRef.offsetTop;
|
||||
const currentRef = this.codeSelectBlockRefArray[blockIndex].current;
|
||||
if (currentRef != null && this.aiChatWindowRef?.current != null) {
|
||||
const chatWindowTop = this.aiChatWindowRef.current.scrollTop;
|
||||
const chatWindowBottom = chatWindowTop + this.aiChatWindowRef.current.clientHeight - 100;
|
||||
const elemTop = currentRef.offsetTop;
|
||||
let elemBottom = elemTop - currentRef.offsetHeight;
|
||||
let elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop;
|
||||
const elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop;
|
||||
if (!elementIsInView) {
|
||||
this.aiChatWindowRef.current.scrollTop =
|
||||
elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
|
||||
@ -567,7 +618,6 @@ class InputModel {
|
||||
}
|
||||
this.codeSelectBlockRefArray = [];
|
||||
this.setAIChatFocus();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@ -580,7 +630,7 @@ class InputModel {
|
||||
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
|
||||
return;
|
||||
}
|
||||
let incBlockIndex = this.codeSelectSelectedIndex.get() + 1;
|
||||
const incBlockIndex = this.codeSelectSelectedIndex.get() + 1;
|
||||
if (this.codeSelectSelectedIndex.get() == this.codeSelectBlockRefArray.length - 1) {
|
||||
this.codeSelectDeselectAll();
|
||||
if (this.aiChatWindowRef?.current != null) {
|
||||
@ -604,7 +654,7 @@ class InputModel {
|
||||
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
|
||||
return;
|
||||
}
|
||||
let decBlockIndex = this.codeSelectSelectedIndex.get() - 1;
|
||||
const decBlockIndex = this.codeSelectSelectedIndex.get() - 1;
|
||||
if (decBlockIndex < 0) {
|
||||
this.codeSelectDeselectAll(this.codeSelectTop);
|
||||
if (this.aiChatWindowRef?.current != null) {
|
||||
@ -640,28 +690,11 @@ class InputModel {
|
||||
}
|
||||
|
||||
openAIAssistantChat(): void {
|
||||
mobx.action(() => {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
this.setActiveAuxView(appconst.InputAuxView_AIChat);
|
||||
}
|
||||
|
||||
clearAIAssistantChat(): void {
|
||||
let prtn = this.globalModel.submitChatInfoCommand("", "", true);
|
||||
const prtn = this.globalModel.submitChatInfoCommand("", "", true);
|
||||
prtn.then((rtn) => {
|
||||
if (!rtn.success) {
|
||||
console.log("submit chat command error: " + rtn.error);
|
||||
@ -672,14 +705,14 @@ class InputModel {
|
||||
}
|
||||
|
||||
hasScrollingInfoMsg(): boolean {
|
||||
if (!this.infoShow.get()) {
|
||||
if (this.activeAuxView.get() !== appconst.InputAuxView_Info) {
|
||||
return false;
|
||||
}
|
||||
let info = this.infoMsg.get();
|
||||
const info = this.infoMsg.get();
|
||||
if (info == null) {
|
||||
return false;
|
||||
}
|
||||
let div = document.querySelector(".cmd-input-info");
|
||||
const div = document.querySelector(".cmd-input-info");
|
||||
if (div == null) {
|
||||
return false;
|
||||
}
|
||||
@ -695,9 +728,11 @@ class InputModel {
|
||||
|
||||
clearInfoMsg(setNull: boolean): void {
|
||||
this._clearInfoTimeout();
|
||||
|
||||
if (this.getActiveAuxView() == appconst.InputAuxView_Info) {
|
||||
this.setActiveAuxView(null);
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.setHistoryShow(false);
|
||||
this.infoShow.set(false);
|
||||
if (setNull) {
|
||||
this.infoMsg.set(null);
|
||||
}
|
||||
@ -706,26 +741,17 @@ class InputModel {
|
||||
|
||||
toggleInfoMsg(): void {
|
||||
this._clearInfoTimeout();
|
||||
mobx.action(() => {
|
||||
if (this.historyShow.get()) {
|
||||
this.setHistoryShow(false);
|
||||
return;
|
||||
if (this.activeAuxView.get() == appconst.InputAuxView_Info) {
|
||||
this.setActiveAuxView(null);
|
||||
} else if (this.infoMsg.get() != null) {
|
||||
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
|
||||
uiSubmitCommand(): void {
|
||||
mobx.action(() => {
|
||||
let commandStr = this.getCurLine();
|
||||
const commandStr = this.getCurLine();
|
||||
if (commandStr.trim() == "") {
|
||||
return;
|
||||
}
|
||||
@ -746,7 +772,7 @@ class InputModel {
|
||||
}
|
||||
|
||||
setCurLine(val: string): void {
|
||||
let hidx = this.historyIndex.get();
|
||||
const hidx = this.historyIndex.get();
|
||||
mobx.action(() => {
|
||||
if (this.modHistory.length <= hidx) {
|
||||
this.modHistory.length = hidx + 1;
|
||||
@ -757,9 +783,7 @@ class InputModel {
|
||||
|
||||
resetInput(): void {
|
||||
mobx.action(() => {
|
||||
this.setHistoryShow(false);
|
||||
this.closeAIAssistantChat(false);
|
||||
this.infoShow.set(false);
|
||||
this.setActiveAuxView(null);
|
||||
this.inputMode.set(null);
|
||||
this.resetHistory();
|
||||
this.dropModHistory(false);
|
||||
@ -778,15 +802,15 @@ class InputModel {
|
||||
}
|
||||
|
||||
getCurLine(): string {
|
||||
let hidx = this.historyIndex.get();
|
||||
const hidx = this.historyIndex.get();
|
||||
if (hidx < this.modHistory.length && this.modHistory[hidx] != null) {
|
||||
return this.modHistory[hidx];
|
||||
}
|
||||
let hitems = this.getFilteredHistoryItems();
|
||||
const hitems = this.getFilteredHistoryItems();
|
||||
if (hidx == 0 || hitems == null || hidx > hitems.length) {
|
||||
return "";
|
||||
}
|
||||
let hitem = hitems[hidx - 1];
|
||||
const hitem = hitems[hidx - 1];
|
||||
if (hitem == null) {
|
||||
return "";
|
||||
}
|
||||
@ -807,7 +831,9 @@ class InputModel {
|
||||
|
||||
resetHistory(): void {
|
||||
mobx.action(() => {
|
||||
this.setHistoryShow(false);
|
||||
if (this.getActiveAuxView() == appconst.InputAuxView_History) {
|
||||
this.setActiveAuxView(null);
|
||||
}
|
||||
this.historyLoading.set(false);
|
||||
this.historyType.set("screen");
|
||||
this.historyItems.set(null);
|
||||
|
@ -24,6 +24,10 @@ class ModalsModel {
|
||||
})();
|
||||
callback && callback();
|
||||
}
|
||||
|
||||
hasOpenModals(): boolean {
|
||||
return this.store.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export { ModalsModel };
|
||||
|
@ -205,6 +205,7 @@ class Model {
|
||||
getApi().onNativeThemeUpdated(this.onNativeThemeUpdated.bind(this));
|
||||
document.addEventListener("keydown", this.docKeyDownHandler.bind(this));
|
||||
document.addEventListener("selectionchange", this.docSelectionChangeHandler.bind(this));
|
||||
window.addEventListener("focus", this.windowFocus.bind(this));
|
||||
setTimeout(() => this.getClientDataLoop(1), 10);
|
||||
this.lineHeightEnv = {
|
||||
// defaults
|
||||
@ -241,6 +242,12 @@ class Model {
|
||||
});
|
||||
}
|
||||
|
||||
windowFocus(): void {
|
||||
if (this.activeMainView.get() == "session" && !this.modalsModel.hasOpenModals()) {
|
||||
this.refocus();
|
||||
}
|
||||
}
|
||||
|
||||
fetchTerminalThemes() {
|
||||
const url = new URL(this.getBaseHostPort() + "/config/terminal-themes");
|
||||
fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })
|
||||
@ -828,7 +835,6 @@ class Model {
|
||||
}
|
||||
|
||||
onMetaArrowDown(): void {
|
||||
console.log("meta arrow down?");
|
||||
GlobalCommandRunner.screenSelectLine("+1");
|
||||
}
|
||||
|
||||
@ -841,7 +847,6 @@ class Model {
|
||||
}
|
||||
|
||||
onSwitchSessionCmd(digit: number) {
|
||||
console.log("switching to ", digit);
|
||||
GlobalCommandRunner.switchSession(String(digit));
|
||||
}
|
||||
|
||||
@ -1089,7 +1094,7 @@ class Model {
|
||||
this.ws.watchScreen(newActiveSessionId, newActiveScreenId);
|
||||
this.closeTabSettings();
|
||||
const activeScreen = this.getActiveScreen();
|
||||
if (activeScreen != null && activeScreen.getCurRemoteInstance() != null) {
|
||||
if (activeScreen?.getCurRemoteInstance() != null) {
|
||||
setTimeout(() => {
|
||||
GlobalCommandRunner.syncShellState();
|
||||
}, 100);
|
||||
|
1
src/types/custom.d.ts
vendored
1
src/types/custom.d.ts
vendored
@ -15,6 +15,7 @@ declare global {
|
||||
type LineContainerStrs = "main" | "sidebar" | "history";
|
||||
type AppUpdateStatusType = "unavailable" | "ready";
|
||||
type NativeThemeSource = "system" | "light" | "dark";
|
||||
type InputAuxViewType = null | "history" | "info" | "aichat";
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
type OArr<V> = mobx.IObservableArray<V>;
|
||||
|
@ -23,7 +23,7 @@ function getMonoFontSize(fontSize: number): MonoFontSize {
|
||||
if (MonoFontSizes[fontSize] != null) {
|
||||
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) {
|
||||
MonoFontSizes[fontSize] = size;
|
||||
}
|
||||
@ -38,7 +38,7 @@ function measureText(text: string, textOpts: { pre?: boolean; mono?: boolean; fo
|
||||
if (textOpts == null) {
|
||||
throw new Error("invalid textOpts passed to measureText (null)");
|
||||
}
|
||||
let textElem = document.createElement("span");
|
||||
const textElem = document.createElement("span");
|
||||
if (textOpts.pre) {
|
||||
textElem.classList.add("pre");
|
||||
}
|
||||
@ -53,19 +53,19 @@ function measureText(text: string, textOpts: { pre?: boolean; mono?: boolean; fo
|
||||
}
|
||||
}
|
||||
textElem.innerText = text;
|
||||
let measureDiv = document.getElementById("measure");
|
||||
const measureDiv = document.getElementById("measure");
|
||||
if (measureDiv == null) {
|
||||
throw new Error("cannot measure text, no #measure div");
|
||||
}
|
||||
measureDiv.replaceChildren(textElem);
|
||||
let height = Math.ceil(textElem.offsetHeight);
|
||||
let width = textElem.offsetWidth;
|
||||
let pad = Math.floor(height / 2);
|
||||
const height = Math.ceil(textElem.offsetHeight);
|
||||
const width = textElem.offsetWidth;
|
||||
const pad = Math.floor(height / 2);
|
||||
return { width, height, pad, fontSize: textOpts.fontSize };
|
||||
}
|
||||
|
||||
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;
|
||||
cols = boundInt(cols, MinTermCols, MaxTermCols);
|
||||
return cols;
|
||||
@ -80,7 +80,7 @@ function windowHeightToRows(lhe: LineHeightEnv, height: 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;
|
||||
}
|
||||
|
||||
@ -89,12 +89,12 @@ function termWidthFromCols(cols: number, fontSize: number): number {
|
||||
// works out to `realHeight = round(ceil(height * dpr) * rows / dpr) / rows`
|
||||
// their calculation is based off the "totalRows" (so that argument has been added)
|
||||
function termHeightFromRows(rows: number, fontSize: number, totalRows: number): number {
|
||||
let dr = getMonoFontSize(fontSize);
|
||||
const dr = getMonoFontSize(fontSize);
|
||||
const dpr = window.devicePixelRatio;
|
||||
if (totalRows == null || totalRows == 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -485,6 +485,7 @@ type FileInfo struct {
|
||||
ModTs int64 `json:"modts"`
|
||||
IsDir bool `json:"isdir,omitempty"`
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -575,11 +575,13 @@ func (m *MServer) streamFile(pk *packet.StreamFilePacketType) {
|
||||
m.Sender.SendPacket(resp)
|
||||
return
|
||||
}
|
||||
mimeType := utilfn.DetectMimeType(pk.Path)
|
||||
resp.Info = &packet.FileInfo{
|
||||
Name: pk.Path,
|
||||
Size: finfo.Size(),
|
||||
ModTs: finfo.ModTime().UnixMilli(),
|
||||
IsDir: finfo.IsDir(),
|
||||
MimeType: mimeType,
|
||||
Perm: int(finfo.Mode().Perm()),
|
||||
}
|
||||
if pk.StatOnly {
|
||||
@ -748,6 +750,10 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) {
|
||||
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("invalid shellstate version: %w", err))
|
||||
return
|
||||
}
|
||||
if runPacket.Command == "wave:testerror" {
|
||||
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("test error"))
|
||||
return
|
||||
}
|
||||
ecmd, err := shexec.MakeMShellSingleCmd()
|
||||
if err != nil {
|
||||
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require valid ck: %s", err))
|
||||
|
@ -6,6 +6,7 @@ package shellapi
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@ -266,6 +267,22 @@ func (bashShellApi) MakeShellStateDiff(oldState *packet.ShellState, oldStateHash
|
||||
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) {
|
||||
if oldState == nil {
|
||||
return nil, fmt.Errorf("cannot apply diff, oldState is nil")
|
||||
|
@ -214,9 +214,16 @@ func bashParseDeclareOutput(state *packet.ShellState, declareBytes []byte, pvarB
|
||||
firstParseErr = err
|
||||
}
|
||||
}
|
||||
if decl != nil && !BashNoStoreVarNames[decl.Name] {
|
||||
declMap[decl.Name] = decl
|
||||
if decl == nil {
|
||||
continue
|
||||
}
|
||||
if BashNoStoreVarNames[decl.Name] {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(decl.Name, "_wavetemp_") {
|
||||
continue
|
||||
}
|
||||
declMap[decl.Name] = decl
|
||||
}
|
||||
pvarMap := parseExtVarOutput(pvarBytes, "", "")
|
||||
utilfn.CombineMaps(declMap, pvarMap)
|
||||
|
@ -28,6 +28,7 @@ import (
|
||||
)
|
||||
|
||||
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 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)"`
|
||||
@ -69,6 +70,7 @@ type ShellStateOutput struct {
|
||||
type ShellApi interface {
|
||||
GetShellType() string
|
||||
MakeExitTrap(fdNum int) (string, []byte)
|
||||
ValidateCommandSyntax(cmdStr string) error
|
||||
GetLocalMajorVersion() string
|
||||
GetLocalShellPath() string
|
||||
GetRemoteShellPath() string
|
||||
|
@ -211,27 +211,41 @@ type ZshMap = map[ZshParamKey]string
|
||||
|
||||
type zshShellApi struct{}
|
||||
|
||||
func (z zshShellApi) GetShellType() string {
|
||||
func (zshShellApi) GetShellType() string {
|
||||
return packet.ShellType_zsh
|
||||
}
|
||||
|
||||
func (z zshShellApi) MakeExitTrap(fdNum int) (string, []byte) {
|
||||
func (zshShellApi) MakeExitTrap(fdNum int) (string, []byte) {
|
||||
return MakeZshExitTrap(fdNum)
|
||||
}
|
||||
|
||||
func (z zshShellApi) GetLocalMajorVersion() string {
|
||||
func (zshShellApi) GetLocalMajorVersion() string {
|
||||
return GetLocalZshMajorVersion()
|
||||
}
|
||||
|
||||
func (z zshShellApi) GetLocalShellPath() string {
|
||||
func (zshShellApi) GetLocalShellPath() string {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
|
||||
func (z zshShellApi) GetRemoteShellPath() string {
|
||||
func (zshShellApi) GetRemoteShellPath() string {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -274,7 +288,7 @@ func (z zshShellApi) GetShellState(ctx context.Context, outCh chan ShellStateOut
|
||||
outCh <- ShellStateOutput{ShellState: rtn, Stats: stats}
|
||||
}
|
||||
|
||||
func (z zshShellApi) GetBaseShellOpts() string {
|
||||
func (zshShellApi) GetBaseShellOpts() string {
|
||||
return BaseZshOpts
|
||||
}
|
||||
|
||||
@ -343,6 +357,9 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string {
|
||||
if strings.HasPrefix(varDecl.Name, "ZFTP_") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(varDecl.Name, "_wavetemp_") {
|
||||
continue
|
||||
}
|
||||
if varDecl.IsExtVar {
|
||||
continue
|
||||
}
|
||||
@ -709,7 +726,7 @@ func makeZshFuncsStrForShellState(fnMap map[ZshParamKey]string) 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 {
|
||||
writeStateToFile(packet.ShellType_zsh, outputBytes)
|
||||
}
|
||||
|
@ -2,7 +2,9 @@ package shellapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testSingleDecl(declStr string) {
|
||||
@ -45,3 +47,35 @@ func TestZshSafeDeclName(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
@ -274,7 +275,7 @@ func (cproc *ClientProc) ProxySingleOutput(ck base.CommandKey, sender *packet.Pa
|
||||
cmdDuration := endTs.Sub(cproc.StartTs)
|
||||
donePacket := packet.MakeCmdDonePacket(ck)
|
||||
donePacket.Ts = endTs.UnixMilli()
|
||||
donePacket.ExitCode = GetExitCode(exitErr)
|
||||
donePacket.ExitCode = utilfn.GetExitCode(exitErr)
|
||||
donePacket.DurationMs = int64(cmdDuration / time.Millisecond)
|
||||
sender.SendPacket(donePacket)
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/shellapi"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/shellenv"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/shellutil"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/wlog"
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/sys/unix"
|
||||
@ -826,6 +827,10 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro
|
||||
var rtnStateWriter *os.File
|
||||
rcFileStr := sapi.MakeRcFileStr(pk)
|
||||
if pk.ReturnState {
|
||||
err := sapi.ValidateCommandSyntax(pk.Command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
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)
|
||||
}()
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
exitErr := c.Cmd.Wait()
|
||||
c.Lock.Lock()
|
||||
@ -1139,7 +1121,7 @@ func (c *ShExecType) WaitForCommand() *packet.CmdDonePacketType {
|
||||
endTs := time.Now()
|
||||
cmdDuration := endTs.Sub(c.StartTs)
|
||||
donePacket.Ts = endTs.UnixMilli()
|
||||
donePacket.ExitCode = GetCmdExitCode(c.Cmd, exitErr)
|
||||
donePacket.ExitCode = utilfn.GetCmdExitCode(c.Cmd, exitErr)
|
||||
donePacket.DurationMs = int64(cmdDuration / time.Millisecond)
|
||||
if c.FileNames != nil {
|
||||
os.Remove(c.FileNames.StdinFifo) // best effort (no need to check error)
|
||||
|
@ -10,3 +10,7 @@ func AnsiResetColor() string {
|
||||
func AnsiGreenColor() string {
|
||||
return "\033[32m"
|
||||
}
|
||||
|
||||
func AnsiRedColor() string {
|
||||
return "\033[31m"
|
||||
}
|
||||
|
@ -13,9 +13,13 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"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]
|
||||
}
|
||||
|
@ -509,10 +509,7 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
screenId := qvals.Get("screenid")
|
||||
lineId := qvals.Get("lineid")
|
||||
path := qvals.Get("path") // validate path?
|
||||
contentType := qvals.Get("mimetype")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
contentType := qvals.Get("mimetype") // force a mimetype
|
||||
if screenId == "" || lineId == "" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
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)))
|
||||
return
|
||||
}
|
||||
if !ContentTypeHeaderValidRe.MatchString(contentType) {
|
||||
if contentType != "" && !ContentTypeHeaderValidRe.MatchString(contentType) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("invalid mimetype specified"))
|
||||
return
|
||||
@ -599,6 +596,12 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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(ContentTypeHeaderKey, contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@ -1018,6 +1021,8 @@ func main() {
|
||||
wlog.GlobalSubsystem = base.ProcessType_WaveSrv
|
||||
wlog.LogConsumer = wlog.LogWithLogger
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
|
||||
if len(os.Args) >= 2 && os.Args[1] == "--test" {
|
||||
log.Printf("running test fn\n")
|
||||
err := test()
|
||||
|
@ -27,7 +27,7 @@ CREATE TABLE remote_instance (
|
||||
festate json NOT NULL,
|
||||
statebasehash varchar(36) NOT NULL,
|
||||
statediffhasharr json NOT NULL
|
||||
);
|
||||
, shelltype varchar(20) NOT NULL DEFAULT 'bash');
|
||||
CREATE TABLE state_base (
|
||||
basehash varchar(36) PRIMARY KEY,
|
||||
ts bigint NOT NULL,
|
||||
@ -55,10 +55,8 @@ CREATE TABLE remote (
|
||||
lastconnectts bigint NOT NULL,
|
||||
local boolean NOT NULL,
|
||||
archived boolean NOT NULL,
|
||||
remoteidx int NOT NULL,
|
||||
statevars json NOT NULL DEFAULT '{}',
|
||||
sshconfigsrc varchar(36) NOT NULL DEFAULT 'waveterm-manual',
|
||||
openaiopts json NOT NULL DEFAULT '{}');
|
||||
remoteidx int NOT NULL
|
||||
, 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');
|
||||
CREATE TABLE history (
|
||||
historyid varchar(36) PRIMARY KEY,
|
||||
ts bigint NOT NULL,
|
||||
@ -203,7 +201,7 @@ CREATE TABLE IF NOT EXISTS "cmd" (
|
||||
rtnstate boolean NOT NULL,
|
||||
rtnbasehash varchar(36) NOT NULL,
|
||||
rtndiffhasharr json NOT NULL,
|
||||
runout json NOT NULL,
|
||||
runout json NOT NULL, restartts bigint NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (screenid, lineid)
|
||||
);
|
||||
CREATE TABLE cmd_migrate20 (
|
||||
|
@ -1242,12 +1242,13 @@ func deferWriteCmdStatus(ctx context.Context, cmd *sstore.CmdType, startTime tim
|
||||
exitCode = 1
|
||||
}
|
||||
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
|
||||
donePk := packet.MakeCmdDonePacket(ck)
|
||||
donePk.Ts = time.Now().UnixMilli()
|
||||
donePk.ExitCode = exitCode
|
||||
donePk.DurationMs = duration.Milliseconds()
|
||||
doneInfo := sstore.CmdDoneDataValues{
|
||||
Ts: time.Now().UnixMilli(),
|
||||
ExitCode: exitCode,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
}
|
||||
update := scbus.MakeUpdatePacket()
|
||||
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus)
|
||||
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus)
|
||||
if err != nil {
|
||||
// nothing to do
|
||||
log.Printf("error updating cmddoneinfo: %v\n", err)
|
||||
@ -2623,12 +2624,13 @@ func doOpenAICompletion(cmd *sstore.CmdType, opts *sstore.OpenAIOptsType, prompt
|
||||
exitCode = 1
|
||||
}
|
||||
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
|
||||
donePk := packet.MakeCmdDonePacket(ck)
|
||||
donePk.Ts = time.Now().UnixMilli()
|
||||
donePk.ExitCode = exitCode
|
||||
donePk.DurationMs = duration.Milliseconds()
|
||||
doneInfo := sstore.CmdDoneDataValues{
|
||||
Ts: time.Now().UnixMilli(),
|
||||
ExitCode: exitCode,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
}
|
||||
update := scbus.MakeUpdatePacket()
|
||||
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus)
|
||||
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus)
|
||||
if err != nil {
|
||||
// nothing to do
|
||||
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
|
||||
}
|
||||
ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId)
|
||||
donePk := packet.MakeCmdDonePacket(ck)
|
||||
donePk.Ts = time.Now().UnixMilli()
|
||||
donePk.ExitCode = exitCode
|
||||
donePk.DurationMs = duration.Milliseconds()
|
||||
doneInfo := sstore.CmdDoneDataValues{
|
||||
Ts: time.Now().UnixMilli(),
|
||||
ExitCode: exitCode,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
}
|
||||
update := scbus.MakeUpdatePacket()
|
||||
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, donePk, cmdStatus)
|
||||
err := sstore.UpdateCmdDoneInfo(context.Background(), update, ck, doneInfo, cmdStatus)
|
||||
if err != nil {
|
||||
// nothing to do
|
||||
log.Printf("error updating cmddoneinfo (in openai): %v\n", err)
|
||||
|
@ -1735,7 +1735,7 @@ func (msh *MShellProc) Launch(interactive bool) {
|
||||
msh.WriteToPtyBuffer("connected to %s\n", remoteCopy.RemoteCanonicalName)
|
||||
go func() {
|
||||
exitErr := cproc.Cmd.Wait()
|
||||
exitCode := shexec.GetExitCode(exitErr)
|
||||
exitCode := utilfn.GetExitCode(exitErr)
|
||||
msh.WithLock(func() {
|
||||
if msh.Status == StatusConnected || msh.Status == StatusConnecting {
|
||||
msh.Status = StatusDisconnected
|
||||
@ -2012,30 +2012,33 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
|
||||
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
|
||||
// waveshell will either return an error (in a ResponsePacketType) or a CmdStartPacketType
|
||||
msh.ServerProc.Output.RegisterRpc(runPacket.ReqId)
|
||||
err = shexec.SendRunPacketAndRunData(ctx, msh.ServerProc.Input, runPacket)
|
||||
go func() {
|
||||
startPk, err := msh.sendRunPacketAndReturnResponse(runPacket)
|
||||
runCmdUpdateFn(runPacket.CK, func() {
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("sending run packet to remote: %w", err)
|
||||
// the cmd failed (never started)
|
||||
msh.handleCmdStartError(runningCmdType, err)
|
||||
return
|
||||
}
|
||||
rtnPk := msh.ServerProc.Output.WaitForResponse(ctx, runPacket.ReqId)
|
||||
if rtnPk == nil {
|
||||
return nil, nil, ctx.Err()
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancelFn()
|
||||
err = sstore.UpdateCmdStartInfo(ctx, runPacket.CK, startPk.Pid, startPk.MShellPid)
|
||||
if err != nil {
|
||||
log.Printf("error updating cmd start info (in remote.RunCommand): %v\n", err)
|
||||
}
|
||||
startPk, ok := rtnPk.(*packet.CmdStartPacketType)
|
||||
if !ok {
|
||||
respPk, ok := rtnPk.(*packet.ResponsePacketType)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("invalid response received from server for run packet: %s", packet.AsString(rtnPk))
|
||||
}
|
||||
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
|
||||
status := sstore.CmdStatusRunning
|
||||
if runPacket.Detached {
|
||||
@ -2051,8 +2054,6 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
|
||||
StatePtr: *statePtr,
|
||||
TermOpts: makeTermOpts(runPacket),
|
||||
Status: status,
|
||||
CmdPid: startPk.Pid,
|
||||
RemotePid: startPk.MShellPid,
|
||||
ExitCode: 0,
|
||||
DurationMs: 0,
|
||||
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)
|
||||
}
|
||||
}
|
||||
runningCmdType := &RunCmdType{
|
||||
CK: runPacket.CK,
|
||||
SessionId: sessionId,
|
||||
ScreenId: screenId,
|
||||
RemotePtr: remotePtr,
|
||||
RunPacket: runPacket,
|
||||
EphemeralOpts: rcOpts.EphemeralOpts}
|
||||
msh.AddRunningCmd(runningCmdType)
|
||||
|
||||
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
|
||||
func makePSCLineError(existingPSC base.CommandKey, line *sstore.LineType, lineErr error) error {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if rct == nil {
|
||||
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()
|
||||
if rct.EphemeralOpts == nil {
|
||||
// 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 {
|
||||
log.Printf("error updating cmddone info (in handleCmdDonePacket): %v\n", err)
|
||||
return
|
||||
@ -2453,6 +2513,19 @@ func (msh *MShellProc) ResetDataPos(ck base.CommandKey) {
|
||||
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]) {
|
||||
if rct == nil {
|
||||
log.Printf("error handling data packet: no running cmd found %s\n", dataPk.CK)
|
||||
|
@ -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 {
|
||||
err := basicCallback(hostname, remote, key)
|
||||
if err == nil {
|
||||
|
@ -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 {
|
||||
if donePk == nil {
|
||||
return fmt.Errorf("invalid cmddone packet")
|
||||
func UpdateCmdStartInfo(ctx context.Context, ck base.CommandKey, cmdPid int, mshellPid int) error {
|
||||
return WithTx(ctx, func(tx *TxWrap) error {
|
||||
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() {
|
||||
return fmt.Errorf("cannot update cmddoneinfo, empty ck")
|
||||
}
|
||||
|
@ -6,7 +6,10 @@ package telemetry
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil"
|
||||
@ -81,6 +84,99 @@ func GetCurDayStr() string {
|
||||
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 {
|
||||
now := time.Now()
|
||||
dayStr := GetCurDayStr()
|
||||
|
41
wavesrv/pkg/telemetry/telemetry_test.go
Normal file
41
wavesrv/pkg/telemetry/telemetry_test.go
Normal 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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user