diff --git a/public/themes/default.css b/public/themes/default.css index c015dbbfd..9233a226d 100644 --- a/public/themes/default.css +++ b/public/themes/default.css @@ -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); diff --git a/public/themes/light.css b/public/themes/light.css index 7431c68af..30a9194ef 100644 --- a/public/themes/light.css +++ b/public/themes/light.css @@ -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); diff --git a/src/app/appconst.ts b/src/app/appconst.ts index 0acecdfca..062b36e9b 100644 --- a/src/app/appconst.ts +++ b/src/app/appconst.ts @@ -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"; diff --git a/src/app/common/elements/button.tsx b/src/app/common/elements/button.tsx index d411f47be..325317437 100644 --- a/src/app/common/elements/button.tsx +++ b/src/app/common/elements/button.tsx @@ -6,7 +6,7 @@ import "./button.less"; interface ButtonProps { children: React.ReactNode; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => 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 { @@ -23,14 +24,14 @@ class Button extends React.Component { }; @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 ( + ); + } +} + +export { CopyButton }; diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx index 215ba6dbe..197a411af 100644 --- a/src/app/common/elements/index.tsx +++ b/src/app/common/elements/index.tsx @@ -19,3 +19,4 @@ export { Tooltip } from "./tooltip"; export { TabIcon } from "./tabicon"; export { DatePicker } from "./datepicker"; export { StyleBlock } from "./styleblock"; +export { CopyButton } from "./copybutton"; diff --git a/src/app/common/elements/markdown.tsx b/src/app/common/elements/markdown.tsx index 5762b05d1..0ffc2a7d3 100644 --- a/src/app/common/elements/markdown.tsx +++ b/src/app/common/elements/markdown.tsx @@ -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; 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 {props.children}; + return ( + + {props.children} + + ); } else { const clickHandler = (e: React.MouseEvent) => { 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 (
diff --git a/src/app/common/modals/createremoteconn.tsx b/src/app/common/modals/createremoteconn.tsx index d8016f736..55872df56 100644 --- a/src/app/common/modals/createremoteconn.tsx +++ b/src/app/common/modals/createremoteconn.tsx @@ -312,7 +312,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> { endDecoration: ( } > diff --git a/src/app/common/modals/editremoteconn.tsx b/src/app/common/modals/editremoteconn.tsx index e63008cae..d8d18136b 100644 --- a/src/app/common/modals/editremoteconn.tsx +++ b/src/app/common/modals/editremoteconn.tsx @@ -338,7 +338,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> { endDecoration: ( } > diff --git a/src/app/history/history.less b/src/app/history/history.less index a386e7536..e323e0dd1 100644 --- a/src/app/history/history.less +++ b/src/app/history/history.less @@ -357,6 +357,10 @@ cursor: pointer; } + .wave-button { + padding: 5px 5px; + } + visibility: hidden; } diff --git a/src/app/history/history.tsx b/src/app/history/history.tsx index 1d1481d18..7aecebd07 100644 --- a/src/app/history/history.tsx +++ b/src/app/history/history.tsx @@ -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 (
- -
-
copied
-
-
{cmdstr}
-
- -
-
- -
+ +
); @@ -190,7 +180,6 @@ class HistoryView extends React.Component<{}, {}> { tableRszObs: ResizeObserver; sessionDropdownActive: OV = mobx.observable.box(false, { name: "sessionDropdownActive" }); remoteDropdownActive: OV = mobx.observable.box(false, { name: "remoteDropdownActive" }); - copiedItemId: OV = 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} /> diff --git a/src/app/line/line.less b/src/app/line/line.less index 532ac3490..c05603ac9 100644 --- a/src/app/line/line.less +++ b/src/app/line/line.less @@ -106,11 +106,6 @@ } } - &.top-border { - border-top: 1px solid #777; - padding: 10px; - } - &:hover .meta .termopts { display: block; } diff --git a/src/app/sidebar/main.tsx b/src/app/sidebar/main.tsx index 1ed22ce6f..80828fe27 100644 --- a/src/app/sidebar/main.tsx +++ b/src/app/sidebar/main.tsx @@ -242,7 +242,7 @@ class MainSideBar extends React.Component { } render() { - let mainView = GlobalModel.activeMainView.get(); + const mainView = GlobalModel.activeMainView.get(); const historyActive = mainView == "history"; const connectionsActive = mainView == "connections"; const settingsActive = mainView == "clientsettings"; diff --git a/src/app/workspace/cmdinput/aichat.less b/src/app/workspace/cmdinput/aichat.less new file mode 100644 index 000000000..fe42fba72 --- /dev/null +++ b/src/app/workspace/cmdinput/aichat.less @@ -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; + } + } +} diff --git a/src/app/workspace/cmdinput/aichat.tsx b/src/app/workspace/cmdinput/aichat.tsx index 8f386b3cd..72e1072da 100644 --- a/src/app/workspace/cmdinput/aichat.tsx +++ b/src/app/workspace/cmdinput/aichat.tsx @@ -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 = mobx.observable.box(1, { name: "textAreaNumLines" }); chatWindowScrollRef: React.RefObject; textAreaRef: React.RefObject; isFocused: OV; + 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); - GlobalModel.inputModel.codeSelectDeselectAll(); - })(); + // 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 = (
@@ -226,68 +234,52 @@ class AIChat extends React.Component<{}, {}> { ); } - renderChatWindow(): any { - let model = GlobalModel; - let inputModel = model.inputModel; - let chatMessageItems = inputModel.AICmdInfoChatItems.slice(); - let chitem: OpenAICmdInfoChatMessageType = null; - return ( -
- - {this.renderChatMessage(chitem)} - -
- ); - } - 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(); + 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 ( -
- + GlobalModel.inputModel.closeAuxView()} + iconClass="fa-sharp fa-solid fa-sparkles" + > + -
-
- -
-
Wave AI
-
-
inputModel.closeAIAssistantChat(true)} - > - -
+
+ + {this.renderChatMessage(chitem)} +
-
- {this.renderChatWindow()} - -
+
+ +
+ ); } } diff --git a/src/app/workspace/cmdinput/auxview.less b/src/app/workspace/cmdinput/auxview.less new file mode 100644 index 000000000..374d9940a --- /dev/null +++ b/src/app/workspace/cmdinput/auxview.less @@ -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); + } +} diff --git a/src/app/workspace/cmdinput/auxview.tsx b/src/app/workspace/cmdinput/auxview.tsx new file mode 100644 index 000000000..281d43de4 --- /dev/null +++ b/src/app/workspace/cmdinput/auxview.tsx @@ -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; + }, + {} +> { + render() { + const { title, className, iconClass, titleBarContents, children, onClose } = this.props; + + return ( +
+
+ +
+ +
+
+
{title}
+ + {titleBarContents} + +
+ + +
+ +
+
+
+ +
{children}
+
+
+ ); + } +} diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less index 973159f73..66785d4e4 100644 --- a/src/app/workspace/cmdinput/cmdinput.less +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -4,130 +4,19 @@ 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; - } - } - - .titlebar-spacer { - height: 31px; - } - - .cmdinput-conn { - position: absolute; - top: 0; - left: 0; - border-radius: 0 0 4px 0; - background-color: rgba(88, 193, 66, 0.3); - padding: 2px 10px 4px 10px; - font-size: calc(var(--termfontsize)); - cursor: pointer; - - i { - margin-left: 5px; - } - - &:hover { - background-color: rgba(88, 193, 66, 0.5); - } - } - - .cmdinput-actions { - position: absolute; - border-radius: 4px; - padding-left: 4px; - padding-right: 4px; - font-size: calc(var(--termfontsize) + 2px); - line-height: 1.2; - - // we want to align to 2nd line of meta. that's 2xPad + 1xLineHightSm - // height of actions is 1xLineHeight + 8px (2x2px padding on icons) - top: calc(var(--termpad)); - right: calc(var(--termpad) * 2); - - display: flex; - flex-direction: row; - align-items: center; - - .cmdinput-icon { - display: inline-flex; - color: var(--app-icon-hover-color); - opacity: 0.5; - - .centered-icon { - .positional-icon-visible; - } - - &.running-cmds { - .rotate { - fill: var(--app-warning-color); - } - } - - &.active { - opacity: 1; - } - - &:hover { - opacity: 1; - } - - padding: 4px 6px; - cursor: pointer; - } - - .line-icon + .line-icon:not(.line-icon-shrink-left) { - margin-left: 3px; - } - } - - .cmdinput-titlebar { - position: absolute; - z-index: 22; - top: 0; - left: 0; - background-color: var(--app-panel-bg-color); - color: var(--term-blue); - padding: 6px 10px 6px 10px; - display: flex; - flex-direction: row; - width: 100%; - overflow-x: auto; - border-bottom: 1px solid var(--app-border-color); - font: var(--base-font); - - .title-icon { - margin-right: 10px; - } - - .title-string { - font-weight: bold; - } - - .close-button { - cursor: pointer; - i { - color: var(--app-icon-color); - - &:hover { - color: var(--app-icon-hover-color); - } - } - } - - .spacer { - flex: 0 0 10px; + &.has-history, + &.has-info { + .base-cmdinput { + border-top: 1px solid var(--app-border-color); } } @@ -135,22 +24,12 @@ 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; @@ -164,394 +43,153 @@ } } - .input-minmax-control { - position: absolute; - top: 5px; - right: 5px; - color: var(--term-foreground); - - padding: 5px; - cursor: pointer; - } - .cmd-input-grow-spacer { 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 { + .base-cmdinput { position: relative; - padding-right: var(--termpad); - font-family: var(--termfontfamily); - font-weight: normal; - line-height: var(--termlineheight); - font-size: var(--termfontsize); + // 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-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; + .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-radius: 4px 4px 0 4px; // 0 is for shelltag + border: none; + cursor: text; - &.display-disabled { - background-color: #444; + // 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; + bottom: -14px; + right: 0px; + } + .control { + padding: 1em 2px; } - &:focus { + .textareainput-div { + position: relative; + + &.control { + padding: var(--termpad) 0; + } + + .shelltag { + position: absolute; + // 13px = 10px height + 3px padding. subtract termpad to account for textareainput-div padding (2px not sure?) + bottom: calc(-13px + var(--termpad)); + right: 0; + font-size: 10px; + color: var(--app-text-secondary-color); + line-height: 1; + user-select: none; + } + } + + textarea { + color: var(--app-text-primary-color); + background-color: var(--app-bg-color); + padding: var(--termpad) 0; + resize: none; + overflow: auto; + overflow-wrap: anywhere; + font-family: var(--termfontfamily); + line-height: var(--termlineheight); + font-size: var(--termfontsize); border: none; + box-shadow: none; + } + + input.history-input { + border: 0; + padding: 0; + height: 0; + } + + .cmd-quick-context .button { + background-color: var(--app-bg-color) !important; + color: var(--app-text-color); + } + + &.inputmode-global .cmd-quick-context .button { + color: var(--app-bg-color); + background-color: var(--cmdinput-button-bg-color) !important; + } + + &.inputmode-comment .cmd-quick-context .button { + color: var(--app-bg-color); + background-color: var(--cmdinput-comment-button-bg-color) !important; } } - input.history-input { - 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 { + .cmdinput-actions { 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; + font-size: calc(var(--termfontsize) + 2px); + line-height: 1.2; - .hint-elem { - cursor: pointer; - opacity: 0.5; + // Align to the same bounds as the input field + top: var(--padding-top); + right: var(--padding-sides); - &:hover { - opacity: 1; + display: flex; + flex-direction: row; + align-items: center; + + .cmdinput-icon { + display: inline-flex; + color: var(--app-icon-hover-color); + opacity: 0.5; + + .centered-icon { + .positional-icon-visible; + } + + &.running-cmds { + .rotate { + fill: var(--app-warning-color); } } - } - } - } - .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; + &.active { + opacity: 1; } - .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); + opacity: 1; } - } - .history-clickable-opt { - white-space: nowrap; + // 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; } - } - .history-items { - color: var(--app-text-color); - - padding-bottom: 6px; - - .history-line { - white-space: pre; + .line-icon + .line-icon:not(.line-icon-shrink-left) { + margin-left: 3px; } - - .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; } } } diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx index 5588206ed..df763774f 100644 --- a/src/app/workspace/cmdinput/cmdinput.tsx +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -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,8 +80,12 @@ class CmdInput extends React.Component<{}, {}> { clickAIAction(e: any): void { e.preventDefault(); e.stopPropagation(); - let inputModel = GlobalModel.inputModel; - inputModel.openAIAssistantChat(); + const inputModel = GlobalModel.inputModel; + if (inputModel.getActiveAuxView() === appconst.InputAuxView_AIChat) { + inputModel.closeAuxView(); + } else { + inputModel.openAIAssistantChat(); + } } @boundMethod @@ -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 ( -
-
- 0}> -
this.toggleFilter(screen)} - > - {numRunningLines}{" "} - - - -
-
-
- -
-
- -
-
- -
- -
- -
- -
- +
+ + +
+ +
+ +
+ +
+ + + +
WARNING:  @@ -252,7 +231,43 @@ class CmdInput extends React.Component<{}, {}> {
-
+
+
+ 0}> +
this.toggleFilter(screen)} + > + {numRunningLines}{" "} + + + +
+
+
+ +
+
+ +
+
diff --git a/src/app/workspace/cmdinput/historyinfo.less b/src/app/workspace/cmdinput/historyinfo.less new file mode 100644 index 000000000..882520458 --- /dev/null +++ b/src/app/workspace/cmdinput/historyinfo.less @@ -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); + } + } + } +} diff --git a/src/app/workspace/cmdinput/historyinfo.tsx b/src/app/workspace/cmdinput/historyinfo.tsx index 9481f3228..cbe6cdf84 100644 --- a/src/app/workspace/cmdinput/historyinfo.tsx +++ b/src/app/workspace/cmdinput/historyinfo.tsx @@ -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 (
{ containingText: mobx.IObservableValue = 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 [ +
+ [for {opts.queryType} ⌘S] +
, +
+ [containing '{opts.queryStr}'] +
, +
+ [{opts.limitRemote ? "this" : "any"} remote ⌘R] +
, + ]; + } + 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 = {}; let scrNames: Record = {}; if (opts.queryType == "global") { @@ -218,29 +237,13 @@ class HistoryInfo extends React.Component<{}, {}> { scrNames = GlobalModel.getScreenNames(); } return ( -
-
-
- -
-
History
-
-
- [for {opts.queryType} ⌘S] -
-
-
- [containing '{opts.queryStr}'] -
-
-
- [{opts.limitRemote ? "this" : "any"} remote ⌘R] -
-
-
- -
-
+
{ { "show-sessions": opts.queryType == "global" } )} > -
[no history] 0}> @@ -264,7 +266,7 @@ class HistoryInfo extends React.Component<{}, {}> {
-
+
); } } diff --git a/src/app/workspace/cmdinput/infomsg.less b/src/app/workspace/cmdinput/infomsg.less new file mode 100644 index 000000000..4e08f5b9e --- /dev/null +++ b/src/app/workspace/cmdinput/infomsg.less @@ -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; + } +} diff --git a/src/app/workspace/cmdinput/infomsg.tsx b/src/app/workspace/cmdinput/infomsg.tsx index 95b217a71..f7143f91a 100644 --- a/src/app/workspace/cmdinput/infomsg.tsx +++ b/src/app/workspace/cmdinput/infomsg.tsx @@ -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 ( -
- -
- {titleStr} -
-
+
@@ -108,7 +104,7 @@ class InfoMsg extends React.Component<{}, {}> {
to reset, run: /reset:cwd
-
+
); } } diff --git a/src/app/workspace/cmdinput/textareainput.tsx b/src/app/workspace/cmdinput/textareainput.tsx index 4b1dd8203..b0fe26304 100644 --- a/src/app/workspace/cmdinput/textareainput.tsx +++ b/src/app/workspace/cmdinput/textareainput.tsx @@ -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 = React.createRef(); lastHeight: number = 0; lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos }; - version: OV = mobx.observable.box(0); // forces render updates - mainInputFocused: OV = mobx.observable.box(true); - historyFocused: OV = mobx.observable.box(false); + version: OV = 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()) { - e.preventDefault(); - if (this.historyInputRef.current != null) { - this.historyInputRef.current.focus(); - } - return; - } - inputModel.setPhysicalInputFocused(true); - mobx.action(() => { - this.mainInputFocused.set(true); - })(); + handleFocus(e: any) { + e.preventDefault(); + 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 (
- + - +
{shellType}
{ 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 }, {}> {
-
+
You're connected to "{getRemoteStrWithAlias(rptr)}". Do you want to change it?
-
+
To change connection from the command line use `cr [alias|user@host]`
@@ -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<{}, {}> {
-
+
+ @@ -269,7 +263,6 @@ class WorkspaceView extends React.Component<{}, {}> { -
diff --git a/src/models/input.ts b/src/models/input.ts index e07bfe4f4..3957fa2e2 100644 --- a/src/models/input.ts +++ b/src/models/input.ts @@ -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 = mobx.observable.box(false); - infoShow: OV = mobx.observable.box(false); - aIChatShow: OV = mobx.observable.box(false); + activeAuxView: OV = mobx.observable.box(null); + auxViewFocus: OV = mobx.observable.box(false); cmdInputHeight: OV = mobx.observable.box(0); aiChatTextAreaRef: React.RefObject; aiChatWindowRef: React.RefObject; codeSelectBlockRefArray: Array>; codeSelectSelectedIndex: OV = mobx.observable.box(-1); + codeSelectUuid: string; + inputPopUpType: OV = mobx.observable.box("none"); AICmdInfoChatItems: mobx.IObservableArray = 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); - this.dropModHistory(true); - this.giveFocus(); - })(); + if (this.getActiveAuxView() != appconst.InputAuxView_History) { + this.dropModHistory(true); + 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): number { + addCodeBlockToCodeSelect(blockRef: React.RefObject, 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,23 +603,21 @@ 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; - let elemBottom = elemTop - currentRef.offsetHeight; - let elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop; - if (!elementIsInView) { - this.aiChatWindowRef.current.scrollTop = - elemBottom - this.aiChatWindowRef.current.clientHeight / 3; - } + 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; + const elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop; + if (!elementIsInView) { + this.aiChatWindowRef.current.scrollTop = + elemBottom - this.aiChatWindowRef.current.clientHeight / 3; } } - this.codeSelectBlockRefArray = []; - this.setAIChatFocus(); } + this.codeSelectBlockRefArray = []; + this.setAIChatFocus(); })(); } @@ -580,7 +630,7 @@ class InputModel { } else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) { 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; - } - let isShowing = this.infoShow.get(); - if (isShowing) { - this.infoShow.set(false); - } else { - if (this.infoMsg.get() != null) { - this.infoShow.set(true); - } - } - })(); + if (this.activeAuxView.get() == appconst.InputAuxView_Info) { + this.setActiveAuxView(null); + } else if (this.infoMsg.get() != null) { + this.setActiveAuxView(appconst.InputAuxView_Info); + } } @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); diff --git a/src/models/modals.ts b/src/models/modals.ts index 4c4dcfd99..cbd0554a4 100644 --- a/src/models/modals.ts +++ b/src/models/modals.ts @@ -24,6 +24,10 @@ class ModalsModel { })(); callback && callback(); } + + hasOpenModals(): boolean { + return this.store.length > 0; + } } export { ModalsModel }; diff --git a/src/models/model.ts b/src/models/model.ts index 577d6fda1..bab0aad65 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -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); diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index b7c0a6bd8..30003e0a2 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -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 = mobx.IObservableValue; type OArr = mobx.IObservableArray; diff --git a/src/util/textmeasure.ts b/src/util/textmeasure.ts index 0d7c24c2e..5254dc2ff 100644 --- a/src/util/textmeasure.ts +++ b/src/util/textmeasure.ts @@ -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); } diff --git a/waveshell/pkg/packet/packet.go b/waveshell/pkg/packet/packet.go index 22e9828fe..d3c6e38e5 100644 --- a/waveshell/pkg/packet/packet.go +++ b/waveshell/pkg/packet/packet.go @@ -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 } diff --git a/waveshell/pkg/server/server.go b/waveshell/pkg/server/server.go index 89e588a54..520c7d1b5 100644 --- a/waveshell/pkg/server/server.go +++ b/waveshell/pkg/server/server.go @@ -575,12 +575,14 @@ 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(), - Perm: int(finfo.Mode().Perm()), + Name: pk.Path, + Size: finfo.Size(), + ModTs: finfo.ModTime().UnixMilli(), + IsDir: finfo.IsDir(), + MimeType: mimeType, + Perm: int(finfo.Mode().Perm()), } if pk.StatOnly { resp.Done = true @@ -748,6 +750,10 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("invalid shellstate version: %w", err)) 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)) diff --git a/waveshell/pkg/shellapi/bashapi.go b/waveshell/pkg/shellapi/bashapi.go index d2cbedfa0..8f1d18126 100644 --- a/waveshell/pkg/shellapi/bashapi.go +++ b/waveshell/pkg/shellapi/bashapi.go @@ -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") diff --git a/waveshell/pkg/shellapi/bashparser.go b/waveshell/pkg/shellapi/bashparser.go index f56b33b37..889171ae4 100644 --- a/waveshell/pkg/shellapi/bashparser.go +++ b/waveshell/pkg/shellapi/bashparser.go @@ -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) diff --git a/waveshell/pkg/shellapi/shellapi.go b/waveshell/pkg/shellapi/shellapi.go index 81c481269..c948a56ee 100644 --- a/waveshell/pkg/shellapi/shellapi.go +++ b/waveshell/pkg/shellapi/shellapi.go @@ -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 diff --git a/waveshell/pkg/shellapi/zshapi.go b/waveshell/pkg/shellapi/zshapi.go index 71cbdf4b4..461543c70 100644 --- a/waveshell/pkg/shellapi/zshapi.go +++ b/waveshell/pkg/shellapi/zshapi.go @@ -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) } diff --git a/waveshell/pkg/shellapi/zshapi_test.go b/waveshell/pkg/shellapi/zshapi_test.go index 557ac99ec..b5d1762e4 100644 --- a/waveshell/pkg/shellapi/zshapi_test.go +++ b/waveshell/pkg/shellapi/zshapi_test.go @@ -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) +} diff --git a/waveshell/pkg/shexec/client.go b/waveshell/pkg/shexec/client.go index 9efcf2250..04aea9b68 100644 --- a/waveshell/pkg/shexec/client.go +++ b/waveshell/pkg/shexec/client.go @@ -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) } diff --git a/waveshell/pkg/shexec/shexec.go b/waveshell/pkg/shexec/shexec.go index 9ff6211da..7a5bdef51 100644 --- a/waveshell/pkg/shexec/shexec.go +++ b/waveshell/pkg/shexec/shexec.go @@ -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) diff --git a/waveshell/pkg/utilfn/ansi.go b/waveshell/pkg/utilfn/ansi.go index 276989816..34d897f6b 100644 --- a/waveshell/pkg/utilfn/ansi.go +++ b/waveshell/pkg/utilfn/ansi.go @@ -10,3 +10,7 @@ func AnsiResetColor() string { func AnsiGreenColor() string { return "\033[32m" } + +func AnsiRedColor() string { + return "\033[31m" +} diff --git a/waveshell/pkg/utilfn/utilfn.go b/waveshell/pkg/utilfn/utilfn.go index 8e8638e36..b9ef0e5c6 100644 --- a/waveshell/pkg/utilfn/utilfn.go +++ b/waveshell/pkg/utilfn/utilfn.go @@ -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] +} diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index 41e37de71..e139a8163 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -508,11 +508,8 @@ func HandleReadFile(w http.ResponseWriter, r *http.Request) { qvals := r.URL.Query() screenId := qvals.Get("screenid") lineId := qvals.Get("lineid") - path := qvals.Get("path") // validate path? - contentType := qvals.Get("mimetype") - if contentType == "" { - contentType = "application/octet-stream" - } + path := qvals.Get("path") // validate path? + 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() diff --git a/wavesrv/db/schema.sql b/wavesrv/db/schema.sql index dcdfea604..d7225ff70 100644 --- a/wavesrv/db/schema.sql +++ b/wavesrv/db/schema.sql @@ -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 ( diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index f84b51914..e422cb99c 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -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) diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index 0328905cd..99de0f9b6 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -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) - if err != nil { - return nil, nil, fmt.Errorf("sending run packet to remote: %w", err) - } - rtnPk := msh.ServerProc.Output.WaitForResponse(ctx, runPacket.ReqId) - if rtnPk == nil { - return nil, nil, ctx.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)) - } - + go func() { + startPk, err := msh.sendRunPacketAndReturnResponse(runPacket) + runCmdUpdateFn(runPacket.CK, func() { + if err != nil { + // the cmd failed (never started) + msh.handleCmdStartError(runningCmdType, err) + return + } + 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) + } + }) + }() // 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) diff --git a/wavesrv/pkg/remote/sshclient.go b/wavesrv/pkg/remote/sshclient.go index 8da49ed5c..ee56aba16 100644 --- a/wavesrv/pkg/remote/sshclient.go +++ b/wavesrv/pkg/remote/sshclient.go @@ -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 { diff --git a/wavesrv/pkg/sstore/dbops.go b/wavesrv/pkg/sstore/dbops.go index ca35f87a7..f28a3da61 100644 --- a/wavesrv/pkg/sstore/dbops.go +++ b/wavesrv/pkg/sstore/dbops.go @@ -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") } diff --git a/wavesrv/pkg/telemetry/telemetry.go b/wavesrv/pkg/telemetry/telemetry.go index 865d949aa..a8418aa06 100644 --- a/wavesrv/pkg/telemetry/telemetry.go +++ b/wavesrv/pkg/telemetry/telemetry.go @@ -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() diff --git a/wavesrv/pkg/telemetry/telemetry_test.go b/wavesrv/pkg/telemetry/telemetry_test.go new file mode 100644 index 000000000..875f834e5 --- /dev/null +++ b/wavesrv/pkg/telemetry/telemetry_test.go @@ -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) +}