merge branch 'main' into use-ssh-library--add-user-input

This commit is contained in:
Sylvia Crowe 2024-01-31 14:46:43 -08:00
commit fa9a0bd228
23 changed files with 861 additions and 298 deletions

View File

@ -30,6 +30,7 @@ type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class App extends React.Component<{}, {}> {
dcWait: OV<boolean> = mobx.observable.box(false, { name: "dcWait" });
mainContentRef: React.RefObject<HTMLDivElement> = React.createRef();
constructor(props: any) {
super(props);
@ -75,6 +76,13 @@ class App extends React.Component<{}, {}> {
let hasClientStop = GlobalModel.getHasClientStop();
let dcWait = this.dcWait.get();
let platform = GlobalModel.getPlatform();
let clientData = GlobalModel.clientData.get();
// Previously, this is done in sidebar.tsx but it causes flicker when clientData is null cos screen-view shifts around.
// Doing it here fixes the flicker cos app is not rendered until clientData is populated.
if (clientData == null) {
return null;
}
if (disconnected || hasClientStop) {
if (!dcWait) {
@ -82,8 +90,8 @@ class App extends React.Component<{}, {}> {
}
return (
<div id="main" className={"platform-" + platform} onContextMenu={this.handleContextMenu}>
<div className="main-content">
<MainSideBar />
<div ref={this.mainContentRef} className="main-content">
<MainSideBar parentRef={this.mainContentRef} clientData={clientData} />
<div className="session-view" />
</div>
<If condition={dcWait}>
@ -102,8 +110,8 @@ class App extends React.Component<{}, {}> {
}
return (
<div id="main" className={"platform-" + platform} onContextMenu={this.handleContextMenu}>
<div className="main-content">
<MainSideBar />
<div ref={this.mainContentRef} className="main-content">
<MainSideBar parentRef={this.mainContentRef} clientData={clientData} />
<ErrorBoundary>
<PluginsView />
<WorkspaceView />

View File

@ -9,11 +9,12 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import cn from "classnames";
import { If } from "tsx-control-statements/components";
import { RemoteType, StatusIndicatorLevel } from "../../types/types";
import { RemoteType } from "../../types/types";
import ReactDOM from "react-dom";
import { GlobalModel } from "../../model/model";
import { GlobalModel, GlobalCommandRunner } from "../../model/model";
import * as appconst from "../appconst";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil";
import { MagicLayout } from "../magiclayout";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.svg";
@ -1265,6 +1266,166 @@ In order to use Wave's advanced features like unified history and persistent ses
});
}
interface ResizableSidebarProps {
parentRef: React.RefObject<HTMLElement>;
position: "left" | "right";
enableSnap?: boolean;
className?: string;
children?: (toggleCollapsed: () => void) => React.ReactNode;
toggleCollapse?: () => void;
}
@mobxReact.observer
class ResizableSidebar extends React.Component<ResizableSidebarProps> {
resizeStartWidth: number = 0;
startX: number = 0;
prevDelta: number = 0;
prevDragDirection: string = null;
disposeReaction: any;
@boundMethod
startResizing(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
const { parentRef, position } = this.props;
const parentRect = parentRef.current?.getBoundingClientRect();
if (!parentRect) return;
if (position === "right") {
this.startX = parentRect.right - event.clientX;
} else {
this.startX = event.clientX - parentRect.left;
}
const mainSidebarModel = GlobalModel.mainSidebarModel;
const collapsed = mainSidebarModel.getCollapsed();
this.resizeStartWidth = mainSidebarModel.getWidth();
document.addEventListener("mousemove", this.onMouseMove);
document.addEventListener("mouseup", this.stopResizing);
document.body.style.cursor = "col-resize";
mobx.action(() => {
mainSidebarModel.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed);
mainSidebarModel.isDragging.set(true);
})();
}
@boundMethod
onMouseMove(event: MouseEvent) {
event.preventDefault();
const { parentRef, enableSnap, position } = this.props;
const parentRect = parentRef.current?.getBoundingClientRect();
const mainSidebarModel = GlobalModel.mainSidebarModel;
if (!mainSidebarModel.isDragging.get() || !parentRect) return;
let delta: number, newWidth: number;
if (position === "right") {
delta = parentRect.right - event.clientX - this.startX;
} else {
delta = event.clientX - parentRect.left - this.startX;
}
newWidth = this.resizeStartWidth + delta;
if (enableSnap) {
const minWidth = MagicLayout.MainSidebarMinWidth;
const snapPoint = minWidth + MagicLayout.MainSidebarSnapThreshold;
const dragResistance = MagicLayout.MainSidebarDragResistance;
let dragDirection: string;
if (delta - this.prevDelta > 0) {
dragDirection = "+";
} else if (delta - this.prevDelta == 0) {
if (this.prevDragDirection == "+") {
dragDirection = "+";
} else {
dragDirection = "-";
}
} else {
dragDirection = "-";
}
this.prevDelta = delta;
this.prevDragDirection = dragDirection;
if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") {
newWidth = snapPoint;
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
} else if (newWidth + dragResistance < snapPoint && dragDirection == "-") {
newWidth = minWidth;
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true);
} else if (newWidth > snapPoint) {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
}
} else {
if (newWidth <= MagicLayout.MainSidebarMinWidth) {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true);
} else {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
}
}
}
@boundMethod
stopResizing() {
let mainSidebarModel = GlobalModel.mainSidebarModel;
GlobalCommandRunner.clientSetSidebar(
mainSidebarModel.tempWidth.get(),
mainSidebarModel.tempCollapsed.get()
).finally(() => {
mobx.action(() => {
mainSidebarModel.isDragging.set(false);
})();
});
document.removeEventListener("mousemove", this.onMouseMove);
document.removeEventListener("mouseup", this.stopResizing);
document.body.style.cursor = "";
}
@boundMethod
toggleCollapsed() {
const mainSidebarModel = GlobalModel.mainSidebarModel;
const tempCollapsed = mainSidebarModel.getCollapsed();
const width = mainSidebarModel.getWidth(true);
mainSidebarModel.setTempWidthAndTempCollapsed(width, !tempCollapsed);
GlobalCommandRunner.clientSetSidebar(width, !tempCollapsed);
}
render() {
const { className, children } = this.props;
const mainSidebarModel = GlobalModel.mainSidebarModel;
const width = mainSidebarModel.getWidth();
const isCollapsed = mainSidebarModel.getCollapsed();
return (
<div className={cn("sidebar", className, { collapsed: isCollapsed })} style={{ width }}>
<div className="sidebar-content">{children(this.toggleCollapsed)}</div>
<div
className="sidebar-handle"
style={{
position: "absolute",
top: 0,
[this.props.position === "left" ? "right" : "left"]: 0,
bottom: 0,
width: "5px",
cursor: "col-resize",
}}
onMouseDown={this.startResizing}
onDoubleClick={this.toggleCollapsed}
></div>
</div>
);
}
}
export {
CmdStrCode,
Toggle,
@ -1286,5 +1447,6 @@ export {
LinkButton,
Status,
Modal,
ResizableSidebar,
ShowWaveShellInstallPrompt,
};

View File

@ -48,13 +48,33 @@
}
}
/*
The following accounts for a debounce in the status indicator. We will only display the status indicator icon if the parent indicates that it should be visible AND one of the following is true:
1. There is a status to be shown.
2. There is a spinner to be shown and the required delay has passed.
*/
.status-indicator-visible {
&.spinner-visible,
&.output,
&.error,
&.success {
.positional-icon-visible;
}
// This is set by the timeout in the status indicator component.
&.spinner-visible {
#spinner {
visibility: visible;
}
}
}
.status-indicator {
#spinner,
#indicator {
visibility: hidden;
}
.spin #spinner {
visibility: visible;
stroke: @term-white;
}
&.error #indicator {

View File

@ -2,17 +2,27 @@ import React from "react";
import { StatusIndicatorLevel } from "../../../types/types";
import cn from "classnames";
import { ReactComponent as SpinnerIndicator } from "../../assets/icons/spinner-indicator.svg";
import { boundMethod } from "autobind-decorator";
import * as mobx from "mobx";
import * as mobxReact from "mobx-react";
import { ReactComponent as RotateIconSvg } from "../../assets/icons/line/rotate.svg";
interface PositionalIconProps {
children?: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
divRef?: React.RefObject<HTMLDivElement>;
}
export class FrontIcon extends React.Component<PositionalIconProps> {
render() {
return (
<div className={cn("front-icon", "positional-icon", this.props.className)}>
<div
ref={this.props.divRef}
className={cn("front-icon", "positional-icon", this.props.className)}
onClick={this.props.onClick}
>
<div className="positional-icon-inner">{this.props.children}</div>
</div>
);
@ -22,7 +32,11 @@ export class FrontIcon extends React.Component<PositionalIconProps> {
export class CenteredIcon extends React.Component<PositionalIconProps> {
render() {
return (
<div className={cn("centered-icon", "positional-icon", this.props.className)} onClick={this.props.onClick}>
<div
ref={this.props.divRef}
className={cn("centered-icon", "positional-icon", this.props.className)}
onClick={this.props.onClick}
>
<div className="positional-icon-inner">{this.props.children}</div>
</div>
);
@ -43,35 +57,182 @@ export class ActionsIcon extends React.Component<ActionsIconProps> {
}
}
class SyncSpin extends React.Component<{
classRef?: React.RefObject<HTMLDivElement>;
children?: React.ReactNode;
shouldSync?: boolean;
}> {
listenerAdded: boolean = false;
componentDidMount() {
this.syncSpinner();
}
componentDidUpdate() {
this.syncSpinner();
}
componentWillUnmount(): void {
const classRef = this.props.classRef;
if (classRef.current != null && this.listenerAdded) {
const elem = classRef.current;
const svgElem = elem.querySelector("svg");
if (svgElem != null) {
svgElem.removeEventListener("animationstart", this.handleAnimationStart);
}
}
}
@boundMethod
handleAnimationStart(e: AnimationEvent) {
const classRef = this.props.classRef;
if (classRef.current == null) {
return;
}
const svgElem = classRef.current.querySelector("svg");
if (svgElem == null) {
return;
}
const animArr = svgElem.getAnimations();
if (animArr == null || animArr.length == 0) {
return;
}
animArr[0].startTime = 0;
}
syncSpinner() {
const { classRef, shouldSync } = this.props;
const shouldSyncVal = shouldSync ?? true;
if (!shouldSyncVal || classRef.current == null) {
return;
}
const elem = classRef.current;
const svgElem = elem.querySelector("svg");
if (svgElem == null) {
return;
}
if (!this.listenerAdded) {
svgElem.addEventListener("animationstart", this.handleAnimationStart);
this.listenerAdded = true;
}
const animArr = svgElem.getAnimations();
if (animArr == null || animArr.length == 0) {
return;
}
animArr[0].startTime = 0;
}
render() {
return this.props.children;
}
}
interface StatusIndicatorProps {
/**
* The level of the status indicator. This will determine the color of the status indicator.
*/
level: StatusIndicatorLevel;
className?: string;
/**
* If true, a spinner will be shown around the status indicator.
*/
runningCommands?: boolean;
}
/**
* This component is used to show the status of a command. It will show a spinner around the status indicator if there are running commands. It will also delay showing the spinner for a short time to prevent flickering.
*/
@mobxReact.observer
export class StatusIndicator extends React.Component<StatusIndicatorProps> {
iconRef: React.RefObject<HTMLDivElement> = React.createRef();
spinnerVisible: mobx.IObservableValue<boolean> = mobx.observable.box(false);
timeout: NodeJS.Timeout;
clearSpinnerTimeout() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
mobx.action(() => {
this.spinnerVisible.set(false);
})();
}
/**
* This will apply a delay after there is a running command before showing the spinner. This prevents flickering for commands that return quickly.
*/
updateMountCallback() {
const runningCommands = this.props.runningCommands ?? false;
if (runningCommands && !this.timeout) {
this.timeout = setTimeout(
mobx.action(() => {
this.spinnerVisible.set(true);
}),
100
);
} else if (!runningCommands) {
this.clearSpinnerTimeout();
}
}
componentDidUpdate(): void {
this.updateMountCallback();
}
componentDidMount(): void {
this.updateMountCallback();
}
componentWillUnmount(): void {
this.clearSpinnerTimeout();
}
render() {
const { level, className, runningCommands } = this.props;
const spinnerVisible = this.spinnerVisible.get();
let statusIndicator = null;
if (level != StatusIndicatorLevel.None || runningCommands) {
let levelClass = null;
if (level != StatusIndicatorLevel.None || spinnerVisible) {
let indicatorLevelClass = null;
switch (level) {
case StatusIndicatorLevel.Output:
levelClass = "output";
indicatorLevelClass = "output";
break;
case StatusIndicatorLevel.Success:
levelClass = "success";
indicatorLevelClass = "success";
break;
case StatusIndicatorLevel.Error:
levelClass = "error";
indicatorLevelClass = "error";
break;
}
const spinnerVisibleClass = spinnerVisible ? "spinner-visible" : null;
statusIndicator = (
<CenteredIcon className={cn(className, levelClass, "status-indicator")}>
<SpinnerIndicator className={runningCommands ? "spin" : null} />
<CenteredIcon
divRef={this.iconRef}
className={cn(className, indicatorLevelClass, spinnerVisibleClass, "status-indicator")}
>
<SpinnerIndicator className={spinnerVisible ? "spin" : null} />
</CenteredIcon>
);
}
return statusIndicator;
return (
<SyncSpin classRef={this.iconRef} shouldSync={runningCommands}>
{statusIndicator}
</SyncSpin>
);
}
}
export class RotateIcon extends React.Component<{
className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}> {
iconRef: React.RefObject<HTMLDivElement> = React.createRef();
render() {
return (
<SyncSpin classRef={this.iconRef}>
<RotateIconSvg className={this.props.className ?? ""} />
</SyncSpin>
);
}
}

View File

@ -20,6 +20,12 @@
width: 100%;
}
.import-edit-warning {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.name-actions-section {
margin-bottom: 10px;
display: flex;

View File

@ -58,6 +58,10 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
return this.selectedRemote?.local;
}
isImportedRemote(): boolean {
return this.selectedRemote?.sshconfigsrc == "sshconfig-import";
}
componentDidMount(): void {
mobx.action(() => {
this.tempAlias.set(this.selectedRemote?.remotealias);
@ -259,6 +263,27 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
);
}
renderImportedRemoteEditWarning() {
return (
<div className="import-edit-warning">
<Tooltip
message={
<span>
Most options for connections imported from an ssh config file cannot be edited. For these
changes, you must edit the config file and import it again. The shell preference can be
edited, but will return to the default if you import again. It will stay changed if you
follow <a href="https://docs.waveterm.dev/features/sshconfig-imports">this procedure</a>.
</span>
}
icon={<i className="fa-sharp fa-regular fa-fw fa-triangle-exclamation" />}
>
<i className="fa-sharp fa-regular fa-fw fa-triangle-exclamation" />
</Tooltip>
&nbsp;SSH Config Import Behavior
</div>
);
}
renderAuthMode() {
let authMode = this.tempAuthMode.get();
return (
@ -344,6 +369,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
return null;
}
let isLocal = this.isLocalRemote();
let isImported = this.isImportedRemote();
return (
<Modal className="erconn-modal">
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} />
@ -351,9 +377,10 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
<div className="name-actions-section">
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
</div>
<If condition={!isLocal}>{this.renderAlias()}</If>
<If condition={!isLocal}>{this.renderAuthMode()}</If>
<If condition={!isLocal}>{this.renderConnectMode()}</If>
<If condition={!isLocal && !isImported}>{this.renderAlias()}</If>
<If condition={!isLocal && !isImported}>{this.renderAuthMode()}</If>
<If condition={!isLocal && !isImported}>{this.renderConnectMode()}</If>
<If condition={isImported}>{this.renderImportedRemoteEditWarning()}</If>
{this.renderShellPref()}
<If condition={!util.isBlank(this.remoteEdit?.errorstr)}>
<div className="settings-field settings-error">Error: {this.remoteEdit?.errorstr}</div>

View File

@ -1026,17 +1026,6 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
cancelInstallButton = <></>;
}
if (remote.sshconfigsrc == "sshconfig-import") {
updateAuthButton = (
<Button theme="secondary" disabled={true}>
Edit
<Tooltip
message={`Connections imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
icon={<i className="fa-sharp fa-regular fa-fw fa-ban" />}
>
<i className="fa-sharp fa-regular fa-fw fa-ban" />
</Tooltip>
</Button>
);
archiveButton = (
<Button theme="secondary" onClick={() => this.clickArchive()}>
Delete

View File

@ -209,17 +209,6 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
cancelInstallButton = <></>;
}
if (remote.sshconfigsrc == "sshconfig-import") {
updateAuthButton = (
<Button theme="secondary" disabled={true}>
Edit
<Tooltip
message={`Connections imported from an ssh config file cannot be edited inside waveterm. To edit these, you must edit the config file and import it again.`}
icon={<i className="fa-sharp fa-regular fa-fw fa-ban" />}
>
<i className="fa-sharp fa-regular fa-fw fa-ban" />
</Tooltip>
</Button>
);
archiveButton = (
<Button theme="secondary" onClick={() => this.clickArchive()}>
Delete

View File

@ -13,8 +13,6 @@ import { GlobalModel, GlobalCommandRunner, Cmd, getTermPtyData } from "../../mod
import { termHeightFromRows } from "../../util/textmeasure";
import type {
LineType,
RemoteType,
RemotePtrType,
RenderModeType,
RendererOpts,
RendererPluginType,
@ -24,11 +22,6 @@ import type {
} from "../../types/types";
import cn from "classnames";
import { ReactComponent as FavoritesIcon } from "../assets/icons/favourites.svg";
import { ReactComponent as PinIcon } from "../assets/icons/pin.svg";
import { ReactComponent as PlusIcon } from "../assets/icons/plus.svg";
import { ReactComponent as MinusIcon } from "../assets/icons/minus.svg";
import type { LineContainerModel } from "../../model/model";
import { renderCmdText } from "../common/common";
import { SimpleBlobRenderer } from "../../plugins/core/basicrenderer";
@ -44,12 +37,13 @@ import * as appconst from "../appconst";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CommentIcon } from "../assets/icons/line/comment.svg";
import { ReactComponent as QuestionIcon } from "../assets/icons/line/question.svg";
import { ReactComponent as RotateIcon } from "../assets/icons/line/rotate.svg";
import { ReactComponent as WarningIcon } from "../assets/icons/line/triangle-exclamation.svg";
import { ReactComponent as XmarkIcon } from "../assets/icons/line/xmark.svg";
import { ReactComponent as FillIcon } from "../assets/icons/line/fill.svg";
import { ReactComponent as GearIcon } from "../assets/icons/line/gear.svg";
import { RotateIcon } from "../common/icons/icons";
import "./lines.less";
dayjs.extend(localizedFormat);
@ -59,12 +53,12 @@ type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class SmallLineAvatar extends React.Component<{ line: LineType; cmd: Cmd; onRightClick?: (e: any) => void }, {}> {
render() {
let { line, cmd } = this.props;
let lineNumStr = (line.linenumtemp ? "~" : "#") + String(line.linenum);
let status = cmd != null ? cmd.getStatus() : "done";
let rtnstate = cmd != null ? cmd.getRtnState() : false;
let exitcode = cmd != null ? cmd.getExitCode() : 0;
let isComment = line.linetype == "text";
const { line, cmd } = this.props;
const lineNumStr = (line.linenumtemp ? "~" : "#") + String(line.linenum);
const status = cmd != null ? cmd.getStatus() : "done";
const rtnstate = cmd != null ? cmd.getRtnState() : false;
const exitcode = cmd != null ? cmd.getExitCode() : 0;
const isComment = line.linetype == "text";
let icon = null;
let iconTitle = null;
if (isComment) {
@ -142,7 +136,7 @@ class LineCmd extends React.Component<
}
checkStateDiffLoad(): void {
let { screen, line, staticRender, visible } = this.props;
const { screen, line, staticRender, visible } = this.props;
if (staticRender) {
return;
}
@ -153,7 +147,7 @@ class LineCmd extends React.Component<
}
return;
}
let cmd = screen.getCmd(line);
const cmd = screen.getCmd(line);
if (cmd == null || !cmd.getRtnState() || this.rtnStateDiffFetched) {
return;
}
@ -167,15 +161,15 @@ class LineCmd extends React.Component<
if (this.rtnStateDiffFetched) {
return;
}
let { line } = this.props;
const { line } = this.props;
this.rtnStateDiffFetched = true;
let usp = new URLSearchParams({
const usp = new URLSearchParams({
linenum: String(line.linenum),
screenid: line.screenid,
lineid: line.lineid,
});
let url = GlobalModel.getBaseHostPort() + "/api/rtnstate?" + usp.toString();
let fetchHeaders = GlobalModel.getFetchHeaders();
const url = GlobalModel.getBaseHostPort() + "/api/rtnstate?" + usp.toString();
const fetchHeaders = GlobalModel.getFetchHeaders();
fetch(url, { headers: fetchHeaders })
.then((resp) => {
if (!resp.ok) {
@ -233,7 +227,7 @@ class LineCmd extends React.Component<
</React.Fragment>
);
}
let isMultiLine = lineutil.isMultiLineCmdText(cmd.getCmdStr());
const isMultiLine = lineutil.isMultiLineCmdText(cmd.getCmdStr());
return (
<div key="meta2" className="meta meta-line2" ref={this.cmdTextRef}>
<div className="metapart-mono cmdtext">
@ -252,7 +246,7 @@ class LineCmd extends React.Component<
// TODO: this might not be necessary anymore because we're using this.lastHeight
getSnapshotBeforeUpdate(prevProps, prevState): { height: number } {
let elem = this.lineRef.current;
const elem = this.lineRef.current;
if (elem == null) {
return { height: 0 };
}
@ -266,25 +260,25 @@ class LineCmd extends React.Component<
}
checkCmdText() {
let metaElem = this.cmdTextRef.current;
const metaElem = this.cmdTextRef.current;
if (metaElem == null || metaElem.childNodes.length == 0) {
return;
}
let metaElemWidth = metaElem.offsetWidth;
const metaElemWidth = metaElem.offsetWidth;
if (metaElemWidth == 0) {
return;
}
let metaChild = metaElem.firstChild;
const metaChild = metaElem.firstChild;
if (metaChild == null) {
return;
}
let children = metaChild.childNodes;
const children = metaChild.childNodes;
let childWidth = 0;
for (let i = 0; i < children.length; i++) {
let ch = children[i];
childWidth += ch.offsetWidth;
}
let isOverflow = childWidth > metaElemWidth;
const isOverflow = childWidth > metaElemWidth;
if (isOverflow && isOverflow != this.isOverflow.get()) {
mobx.action(() => {
this.isOverflow.set(isOverflow);
@ -297,16 +291,16 @@ class LineCmd extends React.Component<
if (this.props.onHeightChange == null) {
return;
}
let { line } = this.props;
const { line } = this.props;
let curHeight = 0;
let elem = this.lineRef.current;
const elem = this.lineRef.current;
if (elem != null) {
curHeight = elem.offsetHeight;
}
if (this.lastHeight == curHeight) {
return;
}
let lastHeight = this.lastHeight;
const lastHeight = this.lastHeight;
this.lastHeight = curHeight;
this.props.onHeightChange(line.linenum, curHeight, lastHeight);
// console.log("line height change: ", line.linenum, lastHeight, "=>", curHeight);
@ -314,13 +308,13 @@ class LineCmd extends React.Component<
@boundMethod
handleClick() {
let { line, noSelect } = this.props;
const { line, noSelect } = this.props;
if (noSelect) {
return;
}
let sel = window.getSelection();
const sel = window.getSelection();
if (this.lineRef.current != null) {
let selText = sel.toString();
const selText = sel.toString();
if (sel.anchorNode != null && this.lineRef.current.contains(sel.anchorNode) && !isBlank(selText)) {
return;
}
@ -330,7 +324,7 @@ class LineCmd extends React.Component<
@boundMethod
clickStar() {
let { line } = this.props;
const { line } = this.props;
if (!line.star || line.star == 0) {
GlobalCommandRunner.lineStar(line.lineid, 1);
} else {
@ -340,7 +334,7 @@ class LineCmd extends React.Component<
@boundMethod
clickPin() {
let { line } = this.props;
const { line } = this.props;
if (!line.pinned) {
GlobalCommandRunner.linePin(line.lineid, true);
} else {
@ -350,19 +344,19 @@ class LineCmd extends React.Component<
@boundMethod
clickBookmark() {
let { line } = this.props;
const { line } = this.props;
GlobalCommandRunner.lineBookmark(line.lineid);
}
@boundMethod
clickDelete() {
let { line } = this.props;
const { line } = this.props;
GlobalCommandRunner.lineDelete(line.lineid, true);
}
@boundMethod
clickRestart() {
let { line } = this.props;
const { line } = this.props;
GlobalCommandRunner.lineRestart(line.lineid, true);
}
@ -375,7 +369,7 @@ class LineCmd extends React.Component<
@boundMethod
clickMoveToSidebar() {
let { line } = this.props;
const { line } = this.props;
GlobalCommandRunner.screenSidebarAddLine(line.lineid);
}
@ -390,20 +384,20 @@ class LineCmd extends React.Component<
}
getIsHidePrompt(): boolean {
let { line } = this.props;
const { line } = this.props;
let rendererPlugin: RendererPluginType = null;
let isNoneRenderer = line.renderer == "none";
const isNoneRenderer = line.renderer == "none";
if (!isBlank(line.renderer) && line.renderer != "terminal" && !isNoneRenderer) {
rendererPlugin = PluginModel.getRendererPluginByName(line.renderer);
}
let hidePrompt = rendererPlugin != null && rendererPlugin.hidePrompt;
const hidePrompt = rendererPlugin?.hidePrompt;
return hidePrompt;
}
getTerminalRendererHeight(cmd: Cmd): number {
let { screen, line, width, renderMode } = this.props;
const { screen, line, width } = this.props;
let height = 45 + 24; // height of zero height terminal
let usedRows = screen.getUsedRows(lineutil.getRendererContext(line), line, cmd, width);
const usedRows = screen.getUsedRows(lineutil.getRendererContext(line), line, cmd, width);
if (usedRows > 0) {
height = 48 + 24 + termHeightFromRows(usedRows, GlobalModel.termFontSize.get());
}
@ -412,7 +406,7 @@ class LineCmd extends React.Component<
@boundMethod
onAvatarRightClick(e: any): void {
let { line, noSelect } = this.props;
const { line, noSelect } = this.props;
if (noSelect) {
return;
}
@ -426,20 +420,20 @@ class LineCmd extends React.Component<
}
renderSimple() {
let { screen, line } = this.props;
let cmd = screen.getCmd(line);
const { screen, line } = this.props;
const cmd = screen.getCmd(line);
let height: number = 0;
if (isBlank(line.renderer) || line.renderer == "terminal") {
height = this.getTerminalRendererHeight(cmd);
} else {
// header is 16px tall with hide-prompt, 36px otherwise
let { screen, line, width } = this.props;
let hidePrompt = this.getIsHidePrompt();
let usedRows = screen.getUsedRows(lineutil.getRendererContext(line), line, cmd, width);
const { screen, line, width } = this.props;
const hidePrompt = this.getIsHidePrompt();
const usedRows = screen.getUsedRows(lineutil.getRendererContext(line), line, cmd, width);
height = (hidePrompt ? 16 + 6 : 36 + 6) + usedRows;
}
let formattedTime = lineutil.getLineDateTimeStr(line.ts);
let mainDivCn = cn("line", "line-cmd", "line-simple");
const formattedTime = lineutil.getLineDateTimeStr(line.ts);
const mainDivCn = cn("line", "line-cmd", "line-simple");
return (
<div
className={mainDivCn}
@ -479,15 +473,16 @@ class LineCmd extends React.Component<
if (restartTs != null && restartTs > 0) {
formattedTime = "restarted @ " + lineutil.getLineDateTimeStr(restartTs);
timeTitle = "original start time " + lineutil.getLineDateTimeStr(line.ts);
}
else {
} else {
formattedTime = lineutil.getLineDateTimeStr(line.ts);
}
let renderer = line.renderer;
return (
<div key="meta1" className="meta meta-line1">
<SmallLineAvatar line={line} cmd={cmd} />
<div title={timeTitle} className="ts">{formattedTime}</div>
<div title={timeTitle} className="ts">
{formattedTime}
</div>
<div>&nbsp;</div>
<If condition={!isBlank(renderer) && renderer != "terminal"}>
<div className="renderer">
@ -506,7 +501,7 @@ class LineCmd extends React.Component<
}
getRendererOpts(cmd: Cmd): RendererOpts {
let { screen } = this.props;
const { screen } = this.props;
return {
maxSize: screen.getMaxContentSize(),
idealSize: screen.getIdealContentSize(),
@ -516,9 +511,9 @@ class LineCmd extends React.Component<
}
makeRendererModelInitializeParams(): RendererModelInitializeParams {
let { screen, line } = this.props;
let context = lineutil.getRendererContext(line);
let cmd = screen.getCmd(line); // won't be null
const { screen, line } = this.props;
const context = lineutil.getRendererContext(line);
const cmd = screen.getCmd(line); // won't be null
let savedHeight = screen.getContentHeight(context);
if (savedHeight == null) {
if (line.contentheight != null && line.contentheight != -1) {
@ -527,7 +522,7 @@ class LineCmd extends React.Component<
savedHeight = 0;
}
}
let api = {
const api = {
saveHeight: (height: number) => {
screen.setContentHeight(lineutil.getRendererContext(line), height);
},
@ -581,12 +576,12 @@ class LineCmd extends React.Component<
};
render() {
let { screen, line, width, staticRender, visible } = this.props;
let isVisible = visible.get();
const { screen, line, width, staticRender, visible } = this.props;
const isVisible = visible.get();
if (staticRender || !isVisible) {
return this.renderSimple();
}
let cmd = screen.getCmd(line);
const cmd = screen.getCmd(line);
if (cmd == null) {
return (
<div
@ -600,19 +595,17 @@ class LineCmd extends React.Component<
</div>
);
}
let status = cmd.getStatus();
let lineNumStr = (line.linenumtemp ? "~" : "") + String(line.linenum);
let isSelected = mobx
const isSelected = mobx
.computed(() => screen.getSelectedLine() == line.linenum, {
name: "computed-isSelected",
})
.get();
let isPhysicalFocused = mobx
const isPhysicalFocused = mobx
.computed(() => screen.getIsFocused(line.linenum), {
name: "computed-getIsFocused",
})
.get();
let isFocused = mobx
const isFocused = mobx
.computed(
() => {
let screenFocusType = screen.getFocusType();
@ -621,7 +614,7 @@ class LineCmd extends React.Component<
{ name: "computed-isFocused" }
)
.get();
let shouldCmdFocus = mobx
const shouldCmdFocus = mobx
.computed(
() => {
let screenFocusType = screen.getFocusType();
@ -630,7 +623,7 @@ class LineCmd extends React.Component<
{ name: "computed-shouldCmdFocus" }
)
.get();
let isInSidebar = mobx
const isInSidebar = mobx
.computed(
() => {
return screen.isSidebarOpen() && screen.isLineIdInSidebar(line.lineid);
@ -638,11 +631,10 @@ class LineCmd extends React.Component<
{ name: "computed-isInSidebar" }
)
.get();
let isStatic = staticRender;
let isRunning = cmd.isRunning();
let isExpanded = this.isCmdExpanded.get();
let rsdiff = this.rtnStateDiff.get();
let mainDivCn = cn(
const isRunning = cmd.isRunning();
const isExpanded = this.isCmdExpanded.get();
const rsdiff = this.rtnStateDiff.get();
const mainDivCn = cn(
"line",
"line-cmd",
{ selected: isSelected },
@ -651,18 +643,18 @@ class LineCmd extends React.Component<
{ "has-rtnstate": cmd.getRtnState() }
);
let rendererPlugin: RendererPluginType = null;
let isNoneRenderer = line.renderer == "none";
const isNoneRenderer = line.renderer == "none";
if (!isBlank(line.renderer) && line.renderer != "terminal" && !isNoneRenderer) {
rendererPlugin = PluginModel.getRendererPluginByName(line.renderer);
}
let rendererType = lineutil.getRendererType(line);
let hidePrompt = rendererPlugin != null && rendererPlugin.hidePrompt;
let termFontSize = GlobalModel.termFontSize.get();
const rendererType = lineutil.getRendererType(line);
const hidePrompt = rendererPlugin?.hidePrompt;
const termFontSize = GlobalModel.termFontSize.get();
let rtnStateDiffSize = termFontSize - 2;
if (rtnStateDiffSize < 10) {
rtnStateDiffSize = Math.max(termFontSize, 10);
}
let containerType = screen.getContainerType();
const containerType = screen.getContainerType();
return (
<div
className={mainDivCn}
@ -681,7 +673,7 @@ class LineCmd extends React.Component<
<If condition={!hidePrompt}>{this.renderCmdText(cmd)}</If>
</div>
<div key="restart" title="Restart Command" className="line-icon" onClick={this.clickRestart}>
<i className="fa-sharp fa-regular fa-arrows-rotate"/>
<i className="fa-sharp fa-regular fa-arrows-rotate" />
</div>
<div key="delete" title="Delete Line (&#x2318;D)" className="line-icon" onClick={this.clickDelete}>
<i className="fa-sharp fa-regular fa-trash" />
@ -821,7 +813,7 @@ class Line extends React.Component<
{}
> {
render() {
let line = this.props.line;
const line = this.props.line;
if (line.archived) {
return null;
}
@ -847,7 +839,7 @@ class LineText extends React.Component<
> {
@boundMethod
clickHandler() {
let { line, noSelect } = this.props;
const { line, noSelect } = this.props;
if (noSelect) {
return;
}
@ -856,7 +848,7 @@ class LineText extends React.Component<
@boundMethod
onAvatarRightClick(e: any): void {
let { line, noSelect } = this.props;
const { line, noSelect } = this.props;
if (noSelect) {
return;
}
@ -870,19 +862,19 @@ class LineText extends React.Component<
}
render() {
let { screen, line, renderMode } = this.props;
let formattedTime = lineutil.getLineDateTimeStr(line.ts);
let isSelected = mobx
const { screen, line } = this.props;
const formattedTime = lineutil.getLineDateTimeStr(line.ts);
const isSelected = mobx
.computed(() => screen.getSelectedLine() == line.linenum, {
name: "computed-isSelected",
})
.get();
let isFocused = mobx
const isFocused = mobx
.computed(() => screen.getFocusType() == "cmd", {
name: "computed-isFocused",
})
.get();
let mainClass = cn("line", "line-text", "focus-parent");
const mainClass = cn("line", "line-text", "focus-parent");
return (
<div
className={mainClass}

View File

@ -27,6 +27,12 @@ let MagicLayout = {
ScreenSidebarWidthPadding: 5,
ScreenSidebarMinWidth: 200,
ScreenSidebarHeaderHeight: 28,
MainSidebarMinWidth: 75,
MainSidebarMaxWidth: 300,
MainSidebarSnapThreshold: 90,
MainSidebarDragResistance: 50,
MainSidebarDefaultWidth: 240,
};
let m = MagicLayout;

View File

@ -3,14 +3,13 @@
.main-sidebar {
padding: 0;
min-width: 20rem;
max-width: 20rem;
display: flex;
flex-direction: column;
position: relative;
font-size: 12.5px;
line-height: 20px;
backdrop-filter: blur(4px);
z-index: 20;
.title-bar-drag {
-webkit-app-region: drag;
@ -24,7 +23,6 @@
&.collapsed {
width: 6em;
min-width: 6em;
.arrow-container,
.collapse-button {
transform: rotate(180deg);
@ -34,7 +32,7 @@
margin-top: 26px;
.top,
.workspaces-item,
.workspaces,
.middle,
.bottom,
.separator {
@ -50,7 +48,7 @@
justify-content: center;
align-items: center;
.logo-container img {
.logo-container {
width: 45px;
}
@ -86,10 +84,14 @@
display: flex;
flex-direction: row;
.logo-container {
flex-shrink: 0;
width: 100px;
}
.spacer {
flex-grow: 1;
}
img {
width: 100px;
}
@ -150,7 +152,6 @@
margin-left: 6px;
border-radius: 4px;
opacity: 1;
transition: opacity 0.1s ease-in-out, visibility 0.1s step-end;
width: inherit;
max-width: inherit;
min-width: inherit;
@ -177,6 +178,7 @@
float: right;
margin-right: 6px;
letter-spacing: 6px;
margin-left: auto;
}
&:hover {
:not(.disabled) .hotkey {
@ -188,7 +190,7 @@
}
&:not(:hover) .status-indicator {
.positional-icon-visible;
.status-indicator-visible;
}
&.workspaces {

View File

@ -7,7 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import dayjs from "dayjs";
import type { RemoteType } from "../../types/types";
import type { ClientDataType, RemoteType } from "../../types/types";
import { If } from "tsx-control-statements/components";
import { compareLoose } from "semver";
@ -19,6 +19,7 @@ import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model";
import { isBlank, openLink } from "../../util/util";
import { ResizableSidebar } from "../common/common";
import * as constants from "../appconst";
import "./sidebar.less";
@ -26,8 +27,6 @@ import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "../common
dayjs.extend(localizedFormat);
type OV<V> = mobx.IObservableValue<V>;
class SideBarItem extends React.Component<{
frontIcon: React.ReactNode;
contents: React.ReactNode | string;
@ -59,16 +58,14 @@ class HotKeyIcon extends React.Component<{ hotkey: string }> {
}
}
@mobxReact.observer
class MainSideBar extends React.Component<{}, {}> {
collapsed: mobx.IObservableValue<boolean> = mobx.observable.box(false);
interface MainSideBarProps {
parentRef: React.RefObject<HTMLElement>;
clientData: ClientDataType;
}
@boundMethod
toggleCollapsed() {
mobx.action(() => {
this.collapsed.set(!this.collapsed.get());
})();
}
@mobxReact.observer
class MainSideBar extends React.Component<MainSideBarProps, {}> {
sidebarRef = React.createRef<HTMLDivElement>();
handleSessionClick(sessionId: string) {
GlobalCommandRunner.switchSession(sessionId);
@ -175,9 +172,9 @@ class MainSideBar extends React.Component<{}, {}> {
getSessions() {
if (!GlobalModel.sessionListLoaded.get()) return <div className="item">loading ...</div>;
let sessionList = [];
let activeSessionId = GlobalModel.activeSessionId.get();
for (let session of GlobalModel.sessionList) {
const sessionList: Session[] = [];
const activeSessionId = GlobalModel.activeSessionId.get();
for (const session of GlobalModel.sessionList) {
if (!session.archived.get() || session.sessionId == activeSessionId) {
sessionList.push(session);
}
@ -189,6 +186,7 @@ class MainSideBar extends React.Component<{}, {}> {
const sessionRunningCommands = sessionScreens.some((screen) => screen.numRunningCmds.get() > 0);
return (
<SideBarItem
key={session.sessionId}
className={`${isActive ? "active" : ""}`}
frontIcon={<span className="index">{index + 1}</span>}
contents={session.name.get()}
@ -207,98 +205,116 @@ class MainSideBar extends React.Component<{}, {}> {
}
render() {
let isCollapsed = this.collapsed.get();
let clientData = GlobalModel.clientData.get();
let clientData = this.props.clientData;
let needsUpdate = false;
if (!clientData?.clientopts.noreleasecheck && !isBlank(clientData?.releaseinfo?.latestversion)) {
needsUpdate = compareLoose(VERSION, clientData.releaseinfo.latestversion) < 0;
}
let mainSidebar = GlobalModel.mainSidebarModel;
let isCollapsed = mainSidebar.getCollapsed();
return (
<div className={cn("main-sidebar", { collapsed: isCollapsed }, { "is-dev": GlobalModel.isDev })}>
<div className="title-bar-drag" />
<div className="contents">
<div className="logo">
<If condition={isCollapsed}>
<div className="logo-container" onClick={this.toggleCollapsed}>
<img src="public/logos/wave-logo.png" />
<ResizableSidebar
className="main-sidebar"
position="left"
enableSnap={true}
parentRef={this.props.parentRef}
>
{(toggleCollapse) => (
<React.Fragment>
<div className="title-bar-drag" />
<div className="contents">
<div className="logo">
<If condition={isCollapsed}>
<div className="logo-container" onClick={toggleCollapse}>
<img src="public/logos/wave-logo.png" />
</div>
</If>
<If condition={!isCollapsed}>
<div className="logo-container">
<img src="public/logos/wave-dark.png" />
</div>
<div className="spacer" />
<div className="collapse-button" onClick={toggleCollapse}>
<LeftChevronIcon className="icon" />
</div>
</If>
</div>
</If>
<If condition={!isCollapsed}>
<div className="logo-container">
<img src="public/logos/wave-dark.png" />
<div className="separator" />
<div className="top">
<SideBarItem
key="history"
frontIcon={<i className="fa-sharp fa-regular fa-clock-rotate-left icon" />}
contents="History"
endIcons={[<HotKeyIcon key="hotkey" hotkey="H" />]}
onClick={this.handleHistoryClick}
/>
{/* <SideBarItem className="hoverEffect unselectable" frontIcon={<FavoritesIcon className="icon" />} contents="Favorites" endIcon={<span className="hotkey">&#x2318;B</span>} onClick={this.handleBookmarksClick}/> */}
<SideBarItem
key="connections"
frontIcon={<i className="fa-sharp fa-regular fa-globe icon " />}
contents="Connections"
onClick={this.handleConnectionsClick}
/>
</div>
<div className="spacer" />
<div className="collapse-button" onClick={this.toggleCollapsed}>
<LeftChevronIcon className="icon" />
</div>
</If>
</div>
<div className="separator" />
<div className="top">
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-clock-rotate-left icon" />}
contents="History"
endIcons={[<HotKeyIcon key="hotkey" hotkey="H" />]}
onClick={this.handleHistoryClick}
/>
{/* <SideBarItem className="hoverEffect unselectable" frontIcon={<FavoritesIcon className="icon" />} contents="Favorites" endIcon={<span className="hotkey">&#x2318;B</span>} onClick={this.handleBookmarksClick}/> */}
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-globe icon " />}
contents="Connections"
onClick={this.handleConnectionsClick}
/>
</div>
<div className="separator" />
<SideBarItem
className="workspaces"
frontIcon={<WorkspacesIcon className="icon" />}
contents="Workspaces"
endIcons={[
<CenteredIcon
key="add-workspace"
className="add-workspace hoverEffect"
onClick={this.handleNewSession}
>
<i className="fa-sharp fa-solid fa-plus"></i>
</CenteredIcon>,
]}
/>
<div className="middle hideScrollbarUntillHover">{this.getSessions()}</div>
<div className="bottom">
<If condition={needsUpdate}>
<div className="separator" />
<SideBarItem
className="updateBanner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Update Available"
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
key="workspaces"
className="workspaces"
frontIcon={<WorkspacesIcon className="icon" />}
contents="Workspaces"
endIcons={[
<CenteredIcon
key="add-workspace"
className="add-workspace hoverEffect"
onClick={this.handleNewSession}
>
<i className="fa-sharp fa-solid fa-plus"></i>
</CenteredIcon>,
]}
/>
</If>
<If condition={GlobalModel.isDev}>
<SideBarItem
frontIcon={<AppsIcon className="icon" />}
contents="Apps"
onClick={this.handlePluginsClick}
endIcons={[<HotKeyIcon key="hotkey" hotkey="A" />]}
/>
</If>
<SideBarItem
frontIcon={<SettingsIcon className="icon" />}
contents="Settings"
onClick={this.handleSettingsClick}
/>
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-circle-question icon" />}
contents="Documentation"
onClick={() => openLink("https://docs.waveterm.dev")}
/>
<SideBarItem
frontIcon={<i className="fa-brands fa-discord icon" />}
contents="Discord"
onClick={() => openLink("https://discord.gg/XfvZ334gwU")}
/>
</div>
</div>
</div>
<div className="middle hideScrollbarUntillHover">{this.getSessions()}</div>
<div className="bottom">
<If condition={needsUpdate}>
<SideBarItem
key="update-available"
className="updateBanner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Update Available"
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
/>
</If>
<If condition={GlobalModel.isDev}>
<SideBarItem
key="apps"
frontIcon={<AppsIcon className="icon" />}
contents="Apps"
onClick={this.handlePluginsClick}
endIcons={[<HotKeyIcon key="hotkey" hotkey="A" />]}
/>
</If>
<SideBarItem
key="settings"
frontIcon={<SettingsIcon className="icon" />}
contents="Settings"
onClick={this.handleSettingsClick}
/>
<SideBarItem
key="documentation"
frontIcon={<i className="fa-sharp fa-regular fa-circle-question icon" />}
contents="Documentation"
onClick={() => openLink("https://docs.waveterm.dev")}
/>
<SideBarItem
key="discord"
frontIcon={<i className="fa-brands fa-discord icon" />}
contents="Discord"
onClick={() => openLink("https://discord.gg/XfvZ334gwU")}
/>
</div>
</div>
</React.Fragment>
)}
</ResizableSidebar>
);
}
}

View File

@ -5,28 +5,25 @@ import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { If, Choose, When, Otherwise } from "tsx-control-statements/components";
import { If } from "tsx-control-statements/components";
import cn from "classnames";
import dayjs from "dayjs";
import type { RemoteType, RemoteInstanceType, RemotePtrType } from "../../../types/types";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Screen, ScreenLines } from "../../../model/model";
import { renderCmdText, Button } from "../../common/common";
import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { renderCmdText } from "../../common/common";
import { TextAreaInput } from "./textareainput";
import { InfoMsg } from "./infomsg";
import { HistoryInfo } from "./historyinfo";
import { Prompt } from "../../common/prompt/prompt";
import { ReactComponent as ExecIcon } from "../../assets/icons/exec.svg";
import { ReactComponent as RotateIcon } from "../../assets/icons/line/rotate.svg";
import "./cmdinput.less";
import { RotateIcon } from "../../common/icons/icons";
import { AIChat } from "./aichat";
import "./cmdinput.less";
dayjs.extend(localizedFormat);
const TDots = "⋮";
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class CmdInput extends React.Component<{}, {}> {
cmdInputRef: React.RefObject<any> = React.createRef();
@ -37,11 +34,11 @@ class CmdInput extends React.Component<{}, {}> {
}
updateCmdInputHeight() {
let elem = this.cmdInputRef.current;
const elem = this.cmdInputRef.current;
if (elem == null) {
return;
}
let height = elem.offsetHeight;
const height = elem.offsetHeight;
if (height == GlobalModel.inputModel.cmdInputHeight) {
return;
}
@ -50,7 +47,7 @@ class CmdInput extends React.Component<{}, {}> {
})();
}
componentDidUpdate(prevProps, prevState, snapshot: {}): void {
componentDidUpdate(): void {
this.updateCmdInputHeight();
}
@ -87,7 +84,7 @@ class CmdInput extends React.Component<{}, {}> {
e.preventDefault();
e.stopPropagation();
let inputModel = GlobalModel.inputModel;
const inputModel = GlobalModel.inputModel;
if (inputModel.historyShow.get()) {
inputModel.resetHistory();
} else {
@ -113,9 +110,9 @@ class CmdInput extends React.Component<{}, {}> {
}
render() {
let model = GlobalModel;
let inputModel = model.inputModel;
let screen = GlobalModel.getActiveScreen();
const model = GlobalModel;
const inputModel = model.inputModel;
const screen = GlobalModel.getActiveScreen();
let ri: RemoteInstanceType = null;
let rptr: RemotePtrType = null;
if (screen != null) {
@ -129,15 +126,13 @@ class CmdInput extends React.Component<{}, {}> {
feState = ri.festate;
}
feState = feState || {};
let infoShow = inputModel.infoShow.get();
let historyShow = !infoShow && inputModel.historyShow.get();
let aiChatShow = inputModel.aIChatShow.get();
let infoMsg = inputModel.infoMsg.get();
let hasInfo = infoMsg != null;
let focusVal = inputModel.physicalInputFocused.get();
let inputMode: string = inputModel.inputMode.get();
let textAreaInputKey = screen == null ? "null" : screen.screenId;
let win = GlobalModel.getScreenLinesById(screen.screenId);
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;
const win = GlobalModel.getScreenLinesById(screen.screenId);
let numRunningLines = 0;
if (win != null) {
numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get();
@ -232,11 +227,17 @@ class CmdInput extends React.Component<{}, {}> {
{focusVal && (
<div className="cmd-btn hoverEffect">
<If condition={historyShow}>
<div className="hint-elem" onMouseDown={this.clickHistoryHint}>close (esc)</div>
<div className="hint-elem" onMouseDown={this.clickHistoryHint}>
close (esc)
</div>
</If>
<If condition={!historyShow}>
<div className="hint-elem" onMouseDown={this.clickHistoryHint}>history (ctrl-r)</div>
<div className="hint-elem" onMouseDown={this.clickAIHint}>AI (ctrl-space)</div>
<div className="hint-elem" onMouseDown={this.clickHistoryHint}>
history (ctrl-r)
</div>
<div className="hint-elem" onMouseDown={this.clickAIHint}>
AI (ctrl-space)
</div>
</If>
</div>
)}

View File

@ -287,7 +287,7 @@
}
&:not(:hover) .status-indicator {
.positional-icon-visible;
.status-indicator-visible;
}
&:hover {

View File

@ -8,12 +8,12 @@
&.is-hidden {
display: none;
}
max-width: calc(100% - 20.5em);
background: @background-session;
border: 1px solid @base-border;
border-radius: 8px;
transition: width 0.2s ease;
// transition: width 0.2s ease;
margin-bottom: 0.5em;
margin-right: 0.5em;
.center-message {
display: flex;
@ -24,6 +24,3 @@
color: @text-secondary;
}
}
.collapsed + .session-view {
max-width: calc(100% - 6.7em);
}

View File

@ -27,20 +27,33 @@ class WorkspaceView extends React.Component<{}, {}> {
if (session == null) {
return (
<div className="session-view">
<div className="center-message"><div>(no active workspace)</div></div>
<div className="center-message">
<div>(no active workspace)</div>
</div>
</div>
);
}
let activeScreen = session.getActiveScreen();
let cmdInputHeight = model.inputModel.cmdInputHeight.get();
if (cmdInputHeight == 0) {
cmdInputHeight = MagicLayout.CmdInputHeight; // this is the base size of cmdInput (measured using devtools)
cmdInputHeight = MagicLayout.CmdInputHeight; // this is the base size of cmdInput (measured using devtools)
}
cmdInputHeight += MagicLayout.CmdInputBottom; // reference to .cmd-input, bottom: 12px
cmdInputHeight += MagicLayout.CmdInputBottom; // reference to .cmd-input, bottom: 12px
let isHidden = GlobalModel.activeMainView.get() != "session";
let mainSidebarModel = GlobalModel.mainSidebarModel;
// Has to calc manually because when tabs overflow, the width of the session view is increased for some reason causing inconsistent width.
// 6px is the right margin of session view.
let width = window.innerWidth - 6 - mainSidebarModel.getWidth();
return (
<div className={cn("session-view", { "is-hidden": isHidden })} data-sessionid={session.sessionId}>
<div
className={cn("session-view", { "is-hidden": isHidden })}
data-sessionid={session.sessionId}
style={{
width: `${width}px`,
}}
>
<ScreenTabs key={"tabs-" + session.sessionId} session={session} />
<ErrorBoundary>
<ScreenView key={"screenview-" + session.sessionId} session={session} screen={activeScreen} />

View File

@ -7,6 +7,7 @@ import { sprintf } from "sprintf-js";
import { v4 as uuidv4 } from "uuid";
import { boundMethod } from "autobind-decorator";
import { debounce } from "throttle-debounce";
import * as mobxReact from "mobx-react";
import {
handleJsonFetchResponse,
base64ToString,
@ -2619,6 +2620,77 @@ class ClientSettingsViewModel {
})();
}
}
class MainSidebarModel {
tempWidth: OV<number> = mobx.observable.box(null, {
name: "MainSidebarModel-tempWidth",
});
tempCollapsed: OV<boolean> = mobx.observable.box(null, {
name: "MainSidebarModel-tempCollapsed",
});
isDragging: OV<boolean> = mobx.observable.box(false, {
name: "MainSidebarModel-isDragging",
});
setTempWidthAndTempCollapsed(newWidth: number, newCollapsed: boolean): void {
const width = Math.max(MagicLayout.MainSidebarMinWidth, Math.min(newWidth, MagicLayout.MainSidebarMaxWidth));
mobx.action(() => {
this.tempWidth.set(width);
this.tempCollapsed.set(newCollapsed);
})();
}
/**
* Gets the intended width for the sidebar. If the sidebar is being dragged, returns the tempWidth. If the sidebar is collapsed, returns the default width.
* @param ignoreCollapse If true, returns the persisted width even if the sidebar is collapsed.
* @returns The intended width for the sidebar or the default width if the sidebar is collapsed. Can be overridden using ignoreCollapse.
*/
getWidth(ignoreCollapse: boolean = false): number {
const clientData = GlobalModel.clientData.get();
let width = clientData?.clientopts?.mainsidebar?.width ?? MagicLayout.MainSidebarDefaultWidth;
if (this.isDragging.get()) {
if (this.tempWidth.get() == null && width == null) {
return MagicLayout.MainSidebarDefaultWidth;
}
if (this.tempWidth.get() == null) {
return width;
}
return this.tempWidth.get();
}
// Set by CLI and collapsed
if (this.getCollapsed()) {
if (ignoreCollapse) {
return width;
} else {
return MagicLayout.MainSidebarMinWidth;
}
} else {
if (width <= MagicLayout.MainSidebarMinWidth) {
width = MagicLayout.MainSidebarDefaultWidth;
}
const snapPoint = MagicLayout.MainSidebarMinWidth + MagicLayout.MainSidebarSnapThreshold;
if (width < snapPoint || width > MagicLayout.MainSidebarMaxWidth) {
width = MagicLayout.MainSidebarDefaultWidth;
}
}
return width;
}
getCollapsed(): boolean {
const clientData = GlobalModel.clientData.get();
const collapsed = clientData?.clientopts?.mainsidebar?.collapsed;
if (this.isDragging.get()) {
if (this.tempCollapsed.get() == null && collapsed == null) {
return false;
}
if (this.tempCollapsed.get() == null) {
return collapsed;
}
return this.tempCollapsed.get();
}
return collapsed;
}
}
class BookmarksModel {
bookmarks: OArr<BookmarkType> = mobx.observable.array([], {
@ -3389,6 +3461,7 @@ class Model {
connectionViewModel: ConnectionsViewModel;
clientSettingsViewModel: ClientSettingsViewModel;
modalsModel: ModalsModel;
mainSidebarModel: MainSidebarModel;
clientData: OV<ClientDataType> = mobx.observable.box(null, {
name: "clientData",
});
@ -3415,6 +3488,7 @@ class Model {
this.remotesModalModel = new RemotesModalModel();
this.remotesModel = new RemotesModel();
this.modalsModel = new ModalsModel();
this.mainSidebarModel = new MainSidebarModel();
let isWaveSrvRunning = getApi().getWaveSrvStatus();
this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, {
name: "model-wavesrv-running",
@ -4102,15 +4176,15 @@ class Model {
if ("openaicmdinfochat" in update) {
this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat);
}
if ("screenstatusindicator" in update) {
this.getScreenById_single(update.screenstatusindicator.screenid)?.setStatusIndicator(
update.screenstatusindicator.status
);
if ("screenstatusindicators" in update) {
for (const indicator of update.screenstatusindicators) {
this.getScreenById_single(indicator.screenid)?.setStatusIndicator(indicator.status);
}
}
if ("screennumrunningcommands" in update) {
this.getScreenById_single(update.screennumrunningcommands.screenid)?.setNumRunningCmds(
update.screennumrunningcommands.num
);
for (const snc of update.screennumrunningcommands) {
this.getScreenById_single(snc.screenid)?.setNumRunningCmds(snc.num);
}
}
if ("userinputrequest" in update) {
let userInputRequest: UserInputRequest = update.userinputrequest;
@ -4990,6 +5064,11 @@ class CommandRunner {
return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false);
}
clientSetSidebar(width: number, collapsed: boolean): Promise<CommandRtnType> {
let kwargs = { nohist: "1", width: `${width}`, collapsed: collapsed ? "1" : "0" };
return GlobalModel.submitCommand("client", "setsidebar", null, kwargs, false);
}
editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
let kwargs = {
nohist: "1",

View File

@ -326,8 +326,8 @@ type ModelUpdateType = {
remoteview?: RemoteViewType;
openaicmdinfochat?: OpenAICmdInfoChatMessageType[];
alertmessage?: AlertMessageType;
screenstatusindicator?: ScreenStatusIndicatorUpdateType;
screennumrunningcommands?: ScreenNumRunningCommandsUpdateType;
screenstatusindicators?: ScreenStatusIndicatorUpdateType[];
screennumrunningcommands?: ScreenNumRunningCommandsUpdateType[];
userinputrequest?: UserInputRequest;
};
@ -525,6 +525,10 @@ type ClientOptsType = {
noreleasecheck: boolean;
acceptedtos: number;
confirmflags: ConfirmFlagsType;
mainsidebar: {
collapsed: boolean;
width: number;
};
};
type ReleaseInfoType = {

View File

@ -92,6 +92,7 @@ var TabIcons = []string{"square", "sparkle", "fire", "ghost", "cloud", "compass"
var RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"}
var ConfirmFlags = []string{"hideshellprompt"}
var SidebarNames = []string{"main"}
var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"}
var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
@ -218,6 +219,7 @@ func init() {
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
registerCmdFn("client:accepttos", ClientAcceptTosCommand)
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
registerCmdFn("client:setsidebar", ClientSetSidebarCommand)
registerCmdFn("sidebar:open", SidebarOpenCommand)
registerCmdFn("sidebar:close", SidebarCloseCommand)
@ -1418,9 +1420,6 @@ func RemoteSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ss
if err != nil {
return nil, err
}
if ids.Remote.RState.SSHConfigSrc == sstore.SSHConfigSrcTypeImport {
return nil, fmt.Errorf("/remote:new cannot update imported remote")
}
visualEdit := resolveBool(pk.Kwargs["visual"], false)
isSubmitted := resolveBool(pk.Kwargs["submit"], false)
editArgs, err := parseRemoteEditArgs(false, pk, ids.Remote.MShell.IsLocal())
@ -1540,6 +1539,7 @@ type HostInfoType struct {
SshKeyFile string
ConnectMode string
Ignore bool
ShellPref string
}
func createSshImportSummary(changeList map[string][]string) string {
@ -1635,6 +1635,13 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
connectMode = sstore.ConnectModeManual
}
shellPref := sstore.ShellTypePref_Detect
if cfgWaveOptions["shellpref"] == "bash" {
shellPref = "bash"
} else if cfgWaveOptions["shellpref"] == "zsh" {
shellPref = "zsh"
}
outHostInfo := new(HostInfoType)
outHostInfo.Host = hostName
outHostInfo.User = userName
@ -1643,6 +1650,7 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
outHostInfo.SshKeyFile = sshKeyFile
outHostInfo.ConnectMode = connectMode
outHostInfo.Ignore = shouldIgnore
outHostInfo.ShellPref = shellPref
return outHostInfo, nil
}
@ -1707,6 +1715,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
if hostInfo.SshKeyFile != "" {
editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile
}
editMap[sstore.RemoteField_ShellPref] = hostInfo.ShellPref
msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId)
if msh == nil {
remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName)
@ -1714,7 +1723,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
continue
}
if msh.Remote.ConnectMode == hostInfo.ConnectMode && msh.Remote.SSHOpts.SSHIdentity == hostInfo.SshKeyFile && msh.Remote.RemoteAlias == hostInfo.Host {
if msh.Remote.ConnectMode == hostInfo.ConnectMode && msh.Remote.SSHOpts.SSHIdentity == hostInfo.SshKeyFile && msh.Remote.RemoteAlias == hostInfo.Host && msh.Remote.ShellPref == hostInfo.ShellPref {
// silently skip this one. it didn't fail, but no changes were needed
continue
}
@ -1751,6 +1760,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
AutoInstall: true,
SSHOpts: sshOpts,
SSHConfigSrc: sstore.SSHConfigSrcTypeImport,
ShellPref: sstore.ShellTypePref_Detect,
}
err := remote.AddRemote(ctx, r, false)
if err != nil {
@ -4463,6 +4473,62 @@ func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
return update, nil
}
func ClientSetSidebarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
}
// Handle collapsed
collapsed, ok := pk.Kwargs["collapsed"]
if !ok {
return nil, fmt.Errorf("collapsed key not provided")
}
collapsedValue := resolveBool(collapsed, false)
// Handle width
var width int
if w, exists := pk.Kwargs["width"]; exists {
width, err = resolveNonNegInt(w, 0)
if err != nil {
return nil, fmt.Errorf("error resolving width: %v", err)
}
} else if clientData.ClientOpts.MainSidebar != nil {
width = clientData.ClientOpts.MainSidebar.Width
}
// Initialize SidebarCollapsed if it's nil
if clientData.ClientOpts.MainSidebar == nil {
clientData.ClientOpts.MainSidebar = new(sstore.SidebarValueType)
}
// Set the sidebar values
var sv sstore.SidebarValueType
sv.Collapsed = collapsedValue
if width != 0 {
sv.Width = width
}
clientData.ClientOpts.MainSidebar = &sv
// Update client data
err = sstore.SetClientOpts(ctx, clientData.ClientOpts)
if err != nil {
return nil, fmt.Errorf("error updating client data: %v", err)
}
// Retrieve updated client data
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
}
update := &sstore.ModelUpdate{
ClientData: clientData,
}
return update, nil
}
func validateOpenAIAPIToken(key string) error {
if len(key) > MaxOpenAIAPITokenLen {
return fmt.Errorf("invalid openai token, too long")

View File

@ -158,6 +158,7 @@ func (ws *WSState) ReplaceShell(shell *wsshell.WSShell) {
return
}
// returns all state required to display current UI
func (ws *WSState) handleConnection() error {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
@ -167,6 +168,8 @@ func (ws *WSState) handleConnection() error {
}
remotes := remote.GetAllRemoteRuntimeState()
update.Remotes = remotes
// restore status indicators
update.ScreenStatusIndicators, update.ScreenNumRunningCommands = sstore.GetCurrentIndicatorState()
update.Connect = true
err = ws.Shell.WriteJson(update)
if err != nil {

View File

@ -190,6 +190,22 @@ func ScreenMemSetIndicatorLevel(screenId string, level StatusIndicatorLevel) {
ScreenMemStore[screenId].StatusIndicator = StatusIndicatorLevel_None
}
func GetCurrentIndicatorState() ([]*ScreenStatusIndicatorType, []*ScreenNumRunningCommandsType) {
MemLock.Lock()
defer MemLock.Unlock()
indicators := []*ScreenStatusIndicatorType{}
numRunningCommands := []*ScreenNumRunningCommandsType{}
for screenId, screenMem := range ScreenMemStore {
if screenMem.StatusIndicator > 0 {
indicators = append(indicators, &ScreenStatusIndicatorType{ScreenId: screenId, Status: screenMem.StatusIndicator})
}
if screenMem.NumRunningCommands > 0 {
numRunningCommands = append(numRunningCommands, &ScreenNumRunningCommandsType{ScreenId: screenId, Num: screenMem.NumRunningCommands})
}
}
return indicators, numRunningCommands
}
// safe because we return a copy
func GetScreenMemState(screenId string) *ScreenMemState {
MemLock.Lock()

View File

@ -277,11 +277,17 @@ func (tdata *TelemetryData) Scan(val interface{}) error {
return quickScanJson(tdata, val)
}
type SidebarValueType struct {
Collapsed bool `json:"collapsed"`
Width int `json:"width"`
}
type ClientOptsType struct {
NoTelemetry bool `json:"notelemetry,omitempty"`
NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"`
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
NoTelemetry bool `json:"notelemetry,omitempty"`
NoReleaseCheck bool `json:"noreleasecheck,omitempty"`
AcceptedTos int64 `json:"acceptedtos,omitempty"`
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
MainSidebar *SidebarValueType `json:"mainsidebar,omitempty"`
}
type FeOptsType struct {
@ -1446,10 +1452,10 @@ func SetStatusIndicatorLevel_Update(ctx context.Context, update *ModelUpdate, sc
}
}
update.ScreenStatusIndicator = &ScreenStatusIndicatorType{
update.ScreenStatusIndicators = []*ScreenStatusIndicatorType{{
ScreenId: screenId,
Status: newStatus,
}
}}
return nil
}
@ -1479,10 +1485,10 @@ func ResetStatusIndicator(screenId string) error {
func IncrementNumRunningCmds_Update(update *ModelUpdate, screenId string, delta int) {
newNum := ScreenMemIncrementNumRunningCommands(screenId, delta)
log.Printf("IncrementNumRunningCmds_Update: screenId=%s, newNum=%d\n", screenId, newNum)
update.ScreenNumRunningCommands = &ScreenNumRunningCommandsType{
update.ScreenNumRunningCommands = []*ScreenNumRunningCommandsType{{
ScreenId: screenId,
Num: newNum,
}
}}
}
func IncrementNumRunningCmds(screenId string, delta int) {

View File

@ -66,8 +66,8 @@ type ModelUpdate struct {
SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"`
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
ScreenStatusIndicator *ScreenStatusIndicatorType `json:"screenstatusindicator,omitempty"`
ScreenNumRunningCommands *ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
ScreenStatusIndicators []*ScreenStatusIndicatorType `json:"screenstatusindicators,omitempty"`
ScreenNumRunningCommands []*ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
UserInputRequest *UserInputRequestType `json:"userinputrequest,omitempty"`
}