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 @mobxReact.observer
class App extends React.Component<{}, {}> { class App extends React.Component<{}, {}> {
dcWait: OV<boolean> = mobx.observable.box(false, { name: "dcWait" }); dcWait: OV<boolean> = mobx.observable.box(false, { name: "dcWait" });
mainContentRef: React.RefObject<HTMLDivElement> = React.createRef();
constructor(props: any) { constructor(props: any) {
super(props); super(props);
@ -75,6 +76,13 @@ class App extends React.Component<{}, {}> {
let hasClientStop = GlobalModel.getHasClientStop(); let hasClientStop = GlobalModel.getHasClientStop();
let dcWait = this.dcWait.get(); let dcWait = this.dcWait.get();
let platform = GlobalModel.getPlatform(); 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 (disconnected || hasClientStop) {
if (!dcWait) { if (!dcWait) {
@ -82,8 +90,8 @@ class App extends React.Component<{}, {}> {
} }
return ( return (
<div id="main" className={"platform-" + platform} onContextMenu={this.handleContextMenu}> <div id="main" className={"platform-" + platform} onContextMenu={this.handleContextMenu}>
<div className="main-content"> <div ref={this.mainContentRef} className="main-content">
<MainSideBar /> <MainSideBar parentRef={this.mainContentRef} clientData={clientData} />
<div className="session-view" /> <div className="session-view" />
</div> </div>
<If condition={dcWait}> <If condition={dcWait}>
@ -102,8 +110,8 @@ class App extends React.Component<{}, {}> {
} }
return ( return (
<div id="main" className={"platform-" + platform} onContextMenu={this.handleContextMenu}> <div id="main" className={"platform-" + platform} onContextMenu={this.handleContextMenu}>
<div className="main-content"> <div ref={this.mainContentRef} className="main-content">
<MainSideBar /> <MainSideBar parentRef={this.mainContentRef} clientData={clientData} />
<ErrorBoundary> <ErrorBoundary>
<PluginsView /> <PluginsView />
<WorkspaceView /> <WorkspaceView />

View File

@ -9,11 +9,12 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import cn from "classnames"; import cn from "classnames";
import { If } from "tsx-control-statements/components"; import { If } from "tsx-control-statements/components";
import { RemoteType, StatusIndicatorLevel } from "../../types/types"; import { RemoteType } from "../../types/types";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { GlobalModel } from "../../model/model"; import { GlobalModel, GlobalCommandRunner } from "../../model/model";
import * as appconst from "../appconst"; import * as appconst from "../appconst";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil"; import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil";
import { MagicLayout } from "../magiclayout";
import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg"; import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg";
import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.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 { export {
CmdStrCode, CmdStrCode,
Toggle, Toggle,
@ -1286,5 +1447,6 @@ export {
LinkButton, LinkButton,
Status, Status,
Modal, Modal,
ResizableSidebar,
ShowWaveShellInstallPrompt, 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 { .status-indicator {
#spinner, #spinner,
#indicator { #indicator {
visibility: hidden; visibility: hidden;
} }
.spin #spinner { .spin #spinner {
visibility: visible;
stroke: @term-white; stroke: @term-white;
} }
&.error #indicator { &.error #indicator {

View File

@ -2,17 +2,27 @@ import React from "react";
import { StatusIndicatorLevel } from "../../../types/types"; import { StatusIndicatorLevel } from "../../../types/types";
import cn from "classnames"; import cn from "classnames";
import { ReactComponent as SpinnerIndicator } from "../../assets/icons/spinner-indicator.svg"; 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 { interface PositionalIconProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
onClick?: React.MouseEventHandler<HTMLDivElement>; onClick?: React.MouseEventHandler<HTMLDivElement>;
divRef?: React.RefObject<HTMLDivElement>;
} }
export class FrontIcon extends React.Component<PositionalIconProps> { export class FrontIcon extends React.Component<PositionalIconProps> {
render() { render() {
return ( 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 className="positional-icon-inner">{this.props.children}</div>
</div> </div>
); );
@ -22,7 +32,11 @@ export class FrontIcon extends React.Component<PositionalIconProps> {
export class CenteredIcon extends React.Component<PositionalIconProps> { export class CenteredIcon extends React.Component<PositionalIconProps> {
render() { render() {
return ( 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 className="positional-icon-inner">{this.props.children}</div>
</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 { interface StatusIndicatorProps {
/**
* The level of the status indicator. This will determine the color of the status indicator.
*/
level: StatusIndicatorLevel; level: StatusIndicatorLevel;
className?: string; className?: string;
/**
* If true, a spinner will be shown around the status indicator.
*/
runningCommands?: boolean; 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> { 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() { render() {
const { level, className, runningCommands } = this.props; const { level, className, runningCommands } = this.props;
const spinnerVisible = this.spinnerVisible.get();
let statusIndicator = null; let statusIndicator = null;
if (level != StatusIndicatorLevel.None || runningCommands) { if (level != StatusIndicatorLevel.None || spinnerVisible) {
let levelClass = null; let indicatorLevelClass = null;
switch (level) { switch (level) {
case StatusIndicatorLevel.Output: case StatusIndicatorLevel.Output:
levelClass = "output"; indicatorLevelClass = "output";
break; break;
case StatusIndicatorLevel.Success: case StatusIndicatorLevel.Success:
levelClass = "success"; indicatorLevelClass = "success";
break; break;
case StatusIndicatorLevel.Error: case StatusIndicatorLevel.Error:
levelClass = "error"; indicatorLevelClass = "error";
break; break;
} }
const spinnerVisibleClass = spinnerVisible ? "spinner-visible" : null;
statusIndicator = ( statusIndicator = (
<CenteredIcon className={cn(className, levelClass, "status-indicator")}> <CenteredIcon
<SpinnerIndicator className={runningCommands ? "spin" : null} /> divRef={this.iconRef}
className={cn(className, indicatorLevelClass, spinnerVisibleClass, "status-indicator")}
>
<SpinnerIndicator className={spinnerVisible ? "spin" : null} />
</CenteredIcon> </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%; width: 100%;
} }
.import-edit-warning {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.name-actions-section { .name-actions-section {
margin-bottom: 10px; margin-bottom: 10px;
display: flex; display: flex;

View File

@ -58,6 +58,10 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
return this.selectedRemote?.local; return this.selectedRemote?.local;
} }
isImportedRemote(): boolean {
return this.selectedRemote?.sshconfigsrc == "sshconfig-import";
}
componentDidMount(): void { componentDidMount(): void {
mobx.action(() => { mobx.action(() => {
this.tempAlias.set(this.selectedRemote?.remotealias); 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() { renderAuthMode() {
let authMode = this.tempAuthMode.get(); let authMode = this.tempAuthMode.get();
return ( return (
@ -344,6 +369,7 @@ class EditRemoteConnModal extends React.Component<{}, {}> {
return null; return null;
} }
let isLocal = this.isLocalRemote(); let isLocal = this.isLocalRemote();
let isImported = this.isImportedRemote();
return ( return (
<Modal className="erconn-modal"> <Modal className="erconn-modal">
<Modal.Header title="Edit Connection" onClose={this.model.closeModal} /> <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-actions-section">
<div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div> <div className="name text-primary">{util.getRemoteName(this.selectedRemote)}</div>
</div> </div>
<If condition={!isLocal}>{this.renderAlias()}</If> <If condition={!isLocal && !isImported}>{this.renderAlias()}</If>
<If condition={!isLocal}>{this.renderAuthMode()}</If> <If condition={!isLocal && !isImported}>{this.renderAuthMode()}</If>
<If condition={!isLocal}>{this.renderConnectMode()}</If> <If condition={!isLocal && !isImported}>{this.renderConnectMode()}</If>
<If condition={isImported}>{this.renderImportedRemoteEditWarning()}</If>
{this.renderShellPref()} {this.renderShellPref()}
<If condition={!util.isBlank(this.remoteEdit?.errorstr)}> <If condition={!util.isBlank(this.remoteEdit?.errorstr)}>
<div className="settings-field settings-error">Error: {this.remoteEdit?.errorstr}</div> <div className="settings-field settings-error">Error: {this.remoteEdit?.errorstr}</div>

View File

@ -1026,17 +1026,6 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
cancelInstallButton = <></>; cancelInstallButton = <></>;
} }
if (remote.sshconfigsrc == "sshconfig-import") { 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 = ( archiveButton = (
<Button theme="secondary" onClick={() => this.clickArchive()}> <Button theme="secondary" onClick={() => this.clickArchive()}>
Delete Delete

View File

@ -209,17 +209,6 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
cancelInstallButton = <></>; cancelInstallButton = <></>;
} }
if (remote.sshconfigsrc == "sshconfig-import") { 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 = ( archiveButton = (
<Button theme="secondary" onClick={() => this.clickArchive()}> <Button theme="secondary" onClick={() => this.clickArchive()}>
Delete Delete

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import cn from "classnames"; import cn from "classnames";
import dayjs from "dayjs"; 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 { If } from "tsx-control-statements/components";
import { compareLoose } from "semver"; import { compareLoose } from "semver";
@ -19,6 +19,7 @@ import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model"; import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model";
import { isBlank, openLink } from "../../util/util"; import { isBlank, openLink } from "../../util/util";
import { ResizableSidebar } from "../common/common";
import * as constants from "../appconst"; import * as constants from "../appconst";
import "./sidebar.less"; import "./sidebar.less";
@ -26,8 +27,6 @@ import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "../common
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
type OV<V> = mobx.IObservableValue<V>;
class SideBarItem extends React.Component<{ class SideBarItem extends React.Component<{
frontIcon: React.ReactNode; frontIcon: React.ReactNode;
contents: React.ReactNode | string; contents: React.ReactNode | string;
@ -59,16 +58,14 @@ class HotKeyIcon extends React.Component<{ hotkey: string }> {
} }
} }
@mobxReact.observer interface MainSideBarProps {
class MainSideBar extends React.Component<{}, {}> { parentRef: React.RefObject<HTMLElement>;
collapsed: mobx.IObservableValue<boolean> = mobx.observable.box(false); clientData: ClientDataType;
}
@boundMethod @mobxReact.observer
toggleCollapsed() { class MainSideBar extends React.Component<MainSideBarProps, {}> {
mobx.action(() => { sidebarRef = React.createRef<HTMLDivElement>();
this.collapsed.set(!this.collapsed.get());
})();
}
handleSessionClick(sessionId: string) { handleSessionClick(sessionId: string) {
GlobalCommandRunner.switchSession(sessionId); GlobalCommandRunner.switchSession(sessionId);
@ -175,9 +172,9 @@ class MainSideBar extends React.Component<{}, {}> {
getSessions() { getSessions() {
if (!GlobalModel.sessionListLoaded.get()) return <div className="item">loading ...</div>; if (!GlobalModel.sessionListLoaded.get()) return <div className="item">loading ...</div>;
let sessionList = []; const sessionList: Session[] = [];
let activeSessionId = GlobalModel.activeSessionId.get(); const activeSessionId = GlobalModel.activeSessionId.get();
for (let session of GlobalModel.sessionList) { for (const session of GlobalModel.sessionList) {
if (!session.archived.get() || session.sessionId == activeSessionId) { if (!session.archived.get() || session.sessionId == activeSessionId) {
sessionList.push(session); sessionList.push(session);
} }
@ -189,6 +186,7 @@ class MainSideBar extends React.Component<{}, {}> {
const sessionRunningCommands = sessionScreens.some((screen) => screen.numRunningCmds.get() > 0); const sessionRunningCommands = sessionScreens.some((screen) => screen.numRunningCmds.get() > 0);
return ( return (
<SideBarItem <SideBarItem
key={session.sessionId}
className={`${isActive ? "active" : ""}`} className={`${isActive ? "active" : ""}`}
frontIcon={<span className="index">{index + 1}</span>} frontIcon={<span className="index">{index + 1}</span>}
contents={session.name.get()} contents={session.name.get()}
@ -207,98 +205,116 @@ class MainSideBar extends React.Component<{}, {}> {
} }
render() { render() {
let isCollapsed = this.collapsed.get(); let clientData = this.props.clientData;
let clientData = GlobalModel.clientData.get();
let needsUpdate = false; let needsUpdate = false;
if (!clientData?.clientopts.noreleasecheck && !isBlank(clientData?.releaseinfo?.latestversion)) { if (!clientData?.clientopts.noreleasecheck && !isBlank(clientData?.releaseinfo?.latestversion)) {
needsUpdate = compareLoose(VERSION, clientData.releaseinfo.latestversion) < 0; needsUpdate = compareLoose(VERSION, clientData.releaseinfo.latestversion) < 0;
} }
let mainSidebar = GlobalModel.mainSidebarModel;
let isCollapsed = mainSidebar.getCollapsed();
return ( return (
<div className={cn("main-sidebar", { collapsed: isCollapsed }, { "is-dev": GlobalModel.isDev })}> <ResizableSidebar
<div className="title-bar-drag" /> className="main-sidebar"
<div className="contents"> position="left"
<div className="logo"> enableSnap={true}
<If condition={isCollapsed}> parentRef={this.props.parentRef}
<div className="logo-container" onClick={this.toggleCollapsed}> >
<img src="public/logos/wave-logo.png" /> {(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> </div>
</If> <div className="separator" />
<If condition={!isCollapsed}> <div className="top">
<div className="logo-container"> <SideBarItem
<img src="public/logos/wave-dark.png" /> 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>
<div className="spacer" /> <div className="separator" />
<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}>
<SideBarItem <SideBarItem
className="updateBanner" key="workspaces"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />} className="workspaces"
contents="Update Available" frontIcon={<WorkspacesIcon className="icon" />}
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")} 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> <div className="middle hideScrollbarUntillHover">{this.getSessions()}</div>
<If condition={GlobalModel.isDev}> <div className="bottom">
<SideBarItem <If condition={needsUpdate}>
frontIcon={<AppsIcon className="icon" />} <SideBarItem
contents="Apps" key="update-available"
onClick={this.handlePluginsClick} className="updateBanner"
endIcons={[<HotKeyIcon key="hotkey" hotkey="A" />]} frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
/> contents="Update Available"
</If> onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
<SideBarItem />
frontIcon={<SettingsIcon className="icon" />} </If>
contents="Settings" <If condition={GlobalModel.isDev}>
onClick={this.handleSettingsClick} <SideBarItem
/> key="apps"
<SideBarItem frontIcon={<AppsIcon className="icon" />}
frontIcon={<i className="fa-sharp fa-regular fa-circle-question icon" />} contents="Apps"
contents="Documentation" onClick={this.handlePluginsClick}
onClick={() => openLink("https://docs.waveterm.dev")} endIcons={[<HotKeyIcon key="hotkey" hotkey="A" />]}
/> />
<SideBarItem </If>
frontIcon={<i className="fa-brands fa-discord icon" />} <SideBarItem
contents="Discord" key="settings"
onClick={() => openLink("https://discord.gg/XfvZ334gwU")} frontIcon={<SettingsIcon className="icon" />}
/> contents="Settings"
</div> onClick={this.handleSettingsClick}
</div> />
</div> <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 mobxReact from "mobx-react";
import * as mobx from "mobx"; import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { If, Choose, When, Otherwise } from "tsx-control-statements/components"; import { If } from "tsx-control-statements/components";
import cn from "classnames"; import cn from "classnames";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { RemoteType, RemoteInstanceType, RemotePtrType } from "../../../types/types"; import type { RemoteType, RemoteInstanceType, RemotePtrType } from "../../../types/types";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Screen, ScreenLines } from "../../../model/model"; import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { renderCmdText, Button } from "../../common/common"; import { renderCmdText } from "../../common/common";
import { TextAreaInput } from "./textareainput"; import { TextAreaInput } from "./textareainput";
import { InfoMsg } from "./infomsg"; import { InfoMsg } from "./infomsg";
import { HistoryInfo } from "./historyinfo"; import { HistoryInfo } from "./historyinfo";
import { Prompt } from "../../common/prompt/prompt"; import { Prompt } from "../../common/prompt/prompt";
import { ReactComponent as ExecIcon } from "../../assets/icons/exec.svg"; import { ReactComponent as ExecIcon } from "../../assets/icons/exec.svg";
import { ReactComponent as RotateIcon } from "../../assets/icons/line/rotate.svg"; import { RotateIcon } from "../../common/icons/icons";
import "./cmdinput.less";
import { AIChat } from "./aichat"; import { AIChat } from "./aichat";
import "./cmdinput.less";
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
const TDots = "⋮";
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer @mobxReact.observer
class CmdInput extends React.Component<{}, {}> { class CmdInput extends React.Component<{}, {}> {
cmdInputRef: React.RefObject<any> = React.createRef(); cmdInputRef: React.RefObject<any> = React.createRef();
@ -37,11 +34,11 @@ class CmdInput extends React.Component<{}, {}> {
} }
updateCmdInputHeight() { updateCmdInputHeight() {
let elem = this.cmdInputRef.current; const elem = this.cmdInputRef.current;
if (elem == null) { if (elem == null) {
return; return;
} }
let height = elem.offsetHeight; const height = elem.offsetHeight;
if (height == GlobalModel.inputModel.cmdInputHeight) { if (height == GlobalModel.inputModel.cmdInputHeight) {
return; return;
} }
@ -50,7 +47,7 @@ class CmdInput extends React.Component<{}, {}> {
})(); })();
} }
componentDidUpdate(prevProps, prevState, snapshot: {}): void { componentDidUpdate(): void {
this.updateCmdInputHeight(); this.updateCmdInputHeight();
} }
@ -87,7 +84,7 @@ class CmdInput extends React.Component<{}, {}> {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
let inputModel = GlobalModel.inputModel; const inputModel = GlobalModel.inputModel;
if (inputModel.historyShow.get()) { if (inputModel.historyShow.get()) {
inputModel.resetHistory(); inputModel.resetHistory();
} else { } else {
@ -113,9 +110,9 @@ class CmdInput extends React.Component<{}, {}> {
} }
render() { render() {
let model = GlobalModel; const model = GlobalModel;
let inputModel = model.inputModel; const inputModel = model.inputModel;
let screen = GlobalModel.getActiveScreen(); const screen = GlobalModel.getActiveScreen();
let ri: RemoteInstanceType = null; let ri: RemoteInstanceType = null;
let rptr: RemotePtrType = null; let rptr: RemotePtrType = null;
if (screen != null) { if (screen != null) {
@ -129,15 +126,13 @@ class CmdInput extends React.Component<{}, {}> {
feState = ri.festate; feState = ri.festate;
} }
feState = feState || {}; feState = feState || {};
let infoShow = inputModel.infoShow.get(); const infoShow = inputModel.infoShow.get();
let historyShow = !infoShow && inputModel.historyShow.get(); const historyShow = !infoShow && inputModel.historyShow.get();
let aiChatShow = inputModel.aIChatShow.get(); const aiChatShow = inputModel.aIChatShow.get();
let infoMsg = inputModel.infoMsg.get(); const focusVal = inputModel.physicalInputFocused.get();
let hasInfo = infoMsg != null; const inputMode: string = inputModel.inputMode.get();
let focusVal = inputModel.physicalInputFocused.get(); const textAreaInputKey = screen == null ? "null" : screen.screenId;
let inputMode: string = inputModel.inputMode.get(); const win = GlobalModel.getScreenLinesById(screen.screenId);
let textAreaInputKey = screen == null ? "null" : screen.screenId;
let win = GlobalModel.getScreenLinesById(screen.screenId);
let numRunningLines = 0; let numRunningLines = 0;
if (win != null) { if (win != null) {
numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get(); numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get();
@ -232,11 +227,17 @@ class CmdInput extends React.Component<{}, {}> {
{focusVal && ( {focusVal && (
<div className="cmd-btn hoverEffect"> <div className="cmd-btn hoverEffect">
<If condition={historyShow}> <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>
<If condition={!historyShow}> <If condition={!historyShow}>
<div className="hint-elem" onMouseDown={this.clickHistoryHint}>history (ctrl-r)</div> <div className="hint-elem" onMouseDown={this.clickHistoryHint}>
<div className="hint-elem" onMouseDown={this.clickAIHint}>AI (ctrl-space)</div> history (ctrl-r)
</div>
<div className="hint-elem" onMouseDown={this.clickAIHint}>
AI (ctrl-space)
</div>
</If> </If>
</div> </div>
)} )}

View File

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

View File

@ -8,12 +8,12 @@
&.is-hidden { &.is-hidden {
display: none; display: none;
} }
max-width: calc(100% - 20.5em);
background: @background-session; background: @background-session;
border: 1px solid @base-border; border: 1px solid @base-border;
border-radius: 8px; border-radius: 8px;
transition: width 0.2s ease; // transition: width 0.2s ease;
margin-bottom: 0.5em; margin-bottom: 0.5em;
margin-right: 0.5em;
.center-message { .center-message {
display: flex; display: flex;
@ -24,6 +24,3 @@
color: @text-secondary; 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) { if (session == null) {
return ( return (
<div className="session-view"> <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> </div>
); );
} }
let activeScreen = session.getActiveScreen(); let activeScreen = session.getActiveScreen();
let cmdInputHeight = model.inputModel.cmdInputHeight.get(); let cmdInputHeight = model.inputModel.cmdInputHeight.get();
if (cmdInputHeight == 0) { 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 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 ( 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} /> <ScreenTabs key={"tabs-" + session.sessionId} session={session} />
<ErrorBoundary> <ErrorBoundary>
<ScreenView key={"screenview-" + session.sessionId} session={session} screen={activeScreen} /> <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 { v4 as uuidv4 } from "uuid";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import { debounce } from "throttle-debounce"; import { debounce } from "throttle-debounce";
import * as mobxReact from "mobx-react";
import { import {
handleJsonFetchResponse, handleJsonFetchResponse,
base64ToString, 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 { class BookmarksModel {
bookmarks: OArr<BookmarkType> = mobx.observable.array([], { bookmarks: OArr<BookmarkType> = mobx.observable.array([], {
@ -3389,6 +3461,7 @@ class Model {
connectionViewModel: ConnectionsViewModel; connectionViewModel: ConnectionsViewModel;
clientSettingsViewModel: ClientSettingsViewModel; clientSettingsViewModel: ClientSettingsViewModel;
modalsModel: ModalsModel; modalsModel: ModalsModel;
mainSidebarModel: MainSidebarModel;
clientData: OV<ClientDataType> = mobx.observable.box(null, { clientData: OV<ClientDataType> = mobx.observable.box(null, {
name: "clientData", name: "clientData",
}); });
@ -3415,6 +3488,7 @@ class Model {
this.remotesModalModel = new RemotesModalModel(); this.remotesModalModel = new RemotesModalModel();
this.remotesModel = new RemotesModel(); this.remotesModel = new RemotesModel();
this.modalsModel = new ModalsModel(); this.modalsModel = new ModalsModel();
this.mainSidebarModel = new MainSidebarModel();
let isWaveSrvRunning = getApi().getWaveSrvStatus(); let isWaveSrvRunning = getApi().getWaveSrvStatus();
this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, { this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, {
name: "model-wavesrv-running", name: "model-wavesrv-running",
@ -4102,15 +4176,15 @@ class Model {
if ("openaicmdinfochat" in update) { if ("openaicmdinfochat" in update) {
this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat); this.inputModel.setOpenAICmdInfoChat(update.openaicmdinfochat);
} }
if ("screenstatusindicator" in update) { if ("screenstatusindicators" in update) {
this.getScreenById_single(update.screenstatusindicator.screenid)?.setStatusIndicator( for (const indicator of update.screenstatusindicators) {
update.screenstatusindicator.status this.getScreenById_single(indicator.screenid)?.setStatusIndicator(indicator.status);
); }
} }
if ("screennumrunningcommands" in update) { if ("screennumrunningcommands" in update) {
this.getScreenById_single(update.screennumrunningcommands.screenid)?.setNumRunningCmds( for (const snc of update.screennumrunningcommands) {
update.screennumrunningcommands.num this.getScreenById_single(snc.screenid)?.setNumRunningCmds(snc.num);
); }
} }
if ("userinputrequest" in update) { if ("userinputrequest" in update) {
let userInputRequest: UserInputRequest = update.userinputrequest; let userInputRequest: UserInputRequest = update.userinputrequest;
@ -4990,6 +5064,11 @@ class CommandRunner {
return GlobalModel.submitCommand("client", "setconfirmflag", [flag, valueStr], kwargs, false); 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) { editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
let kwargs = { let kwargs = {
nohist: "1", nohist: "1",

View File

@ -326,8 +326,8 @@ type ModelUpdateType = {
remoteview?: RemoteViewType; remoteview?: RemoteViewType;
openaicmdinfochat?: OpenAICmdInfoChatMessageType[]; openaicmdinfochat?: OpenAICmdInfoChatMessageType[];
alertmessage?: AlertMessageType; alertmessage?: AlertMessageType;
screenstatusindicator?: ScreenStatusIndicatorUpdateType; screenstatusindicators?: ScreenStatusIndicatorUpdateType[];
screennumrunningcommands?: ScreenNumRunningCommandsUpdateType; screennumrunningcommands?: ScreenNumRunningCommandsUpdateType[];
userinputrequest?: UserInputRequest; userinputrequest?: UserInputRequest;
}; };
@ -525,6 +525,10 @@ type ClientOptsType = {
noreleasecheck: boolean; noreleasecheck: boolean;
acceptedtos: number; acceptedtos: number;
confirmflags: ConfirmFlagsType; confirmflags: ConfirmFlagsType;
mainsidebar: {
collapsed: boolean;
width: number;
};
}; };
type ReleaseInfoType = { 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 RemoteColorNames = []string{"red", "green", "yellow", "blue", "magenta", "cyan", "white", "orange"}
var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"} var RemoteSetArgs = []string{"alias", "connectmode", "key", "password", "autoinstall", "color"}
var ConfirmFlags = []string{"hideshellprompt"} var ConfirmFlags = []string{"hideshellprompt"}
var SidebarNames = []string{"main"}
var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"} var ScreenCmds = []string{"run", "comment", "cd", "cr", "clear", "sw", "reset", "signal", "chat"}
var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"} var NoHistCmds = []string{"_compgen", "line", "history", "_killserver"}
@ -218,6 +219,7 @@ func init() {
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand) registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
registerCmdFn("client:accepttos", ClientAcceptTosCommand) registerCmdFn("client:accepttos", ClientAcceptTosCommand)
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand) registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
registerCmdFn("client:setsidebar", ClientSetSidebarCommand)
registerCmdFn("sidebar:open", SidebarOpenCommand) registerCmdFn("sidebar:open", SidebarOpenCommand)
registerCmdFn("sidebar:close", SidebarCloseCommand) registerCmdFn("sidebar:close", SidebarCloseCommand)
@ -1418,9 +1420,6 @@ func RemoteSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ss
if err != nil { if err != nil {
return nil, err 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) visualEdit := resolveBool(pk.Kwargs["visual"], false)
isSubmitted := resolveBool(pk.Kwargs["submit"], false) isSubmitted := resolveBool(pk.Kwargs["submit"], false)
editArgs, err := parseRemoteEditArgs(false, pk, ids.Remote.MShell.IsLocal()) editArgs, err := parseRemoteEditArgs(false, pk, ids.Remote.MShell.IsLocal())
@ -1540,6 +1539,7 @@ type HostInfoType struct {
SshKeyFile string SshKeyFile string
ConnectMode string ConnectMode string
Ignore bool Ignore bool
ShellPref string
} }
func createSshImportSummary(changeList map[string][]string) string { func createSshImportSummary(changeList map[string][]string) string {
@ -1635,6 +1635,13 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
connectMode = sstore.ConnectModeManual connectMode = sstore.ConnectModeManual
} }
shellPref := sstore.ShellTypePref_Detect
if cfgWaveOptions["shellpref"] == "bash" {
shellPref = "bash"
} else if cfgWaveOptions["shellpref"] == "zsh" {
shellPref = "zsh"
}
outHostInfo := new(HostInfoType) outHostInfo := new(HostInfoType)
outHostInfo.Host = hostName outHostInfo.Host = hostName
outHostInfo.User = userName outHostInfo.User = userName
@ -1643,6 +1650,7 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
outHostInfo.SshKeyFile = sshKeyFile outHostInfo.SshKeyFile = sshKeyFile
outHostInfo.ConnectMode = connectMode outHostInfo.ConnectMode = connectMode
outHostInfo.Ignore = shouldIgnore outHostInfo.Ignore = shouldIgnore
outHostInfo.ShellPref = shellPref
return outHostInfo, nil return outHostInfo, nil
} }
@ -1707,6 +1715,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
if hostInfo.SshKeyFile != "" { if hostInfo.SshKeyFile != "" {
editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile editMap[sstore.RemoteField_SSHKey] = hostInfo.SshKeyFile
} }
editMap[sstore.RemoteField_ShellPref] = hostInfo.ShellPref
msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId) msh := remote.GetRemoteById(previouslyImportedRemote.RemoteId)
if msh == nil { if msh == nil {
remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName) remoteChangeList["updateErr"] = append(remoteChangeList["updateErr"], hostInfo.CanonicalName)
@ -1714,7 +1723,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
continue 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 // silently skip this one. it didn't fail, but no changes were needed
continue continue
} }
@ -1751,6 +1760,7 @@ func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
AutoInstall: true, AutoInstall: true,
SSHOpts: sshOpts, SSHOpts: sshOpts,
SSHConfigSrc: sstore.SSHConfigSrcTypeImport, SSHConfigSrc: sstore.SSHConfigSrcTypeImport,
ShellPref: sstore.ShellTypePref_Detect,
} }
err := remote.AddRemote(ctx, r, false) err := remote.AddRemote(ctx, r, false)
if err != nil { if err != nil {
@ -4463,6 +4473,62 @@ func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketT
return update, nil 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 { func validateOpenAIAPIToken(key string) error {
if len(key) > MaxOpenAIAPITokenLen { if len(key) > MaxOpenAIAPITokenLen {
return fmt.Errorf("invalid openai token, too long") return fmt.Errorf("invalid openai token, too long")

View File

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

View File

@ -190,6 +190,22 @@ func ScreenMemSetIndicatorLevel(screenId string, level StatusIndicatorLevel) {
ScreenMemStore[screenId].StatusIndicator = StatusIndicatorLevel_None 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 // safe because we return a copy
func GetScreenMemState(screenId string) *ScreenMemState { func GetScreenMemState(screenId string) *ScreenMemState {
MemLock.Lock() MemLock.Lock()

View File

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

View File

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