Rght sidebar (#461)

* init

* model

* pass model

* increase snap threshold

* move saving of state to model. backend implementation

* fix wrong clientdata prop

* right header space for right sidebar toggles

* button component refactor

* set default classname to primary

* remove debugging code

* hide trigger for now

* disable right sidebar in production (only show on dev for now)
This commit is contained in:
Red J Adaya 2024-03-15 13:59:55 +08:00 committed by GitHub
parent 61c9d21014
commit 07e0b53b17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 414 additions and 142 deletions

View File

@ -44,6 +44,10 @@
--floating-logo-width: 40px; --floating-logo-width: 40px;
--floating-logo-height: var(--screentabs-height); --floating-logo-height: var(--screentabs-height);
/* right sidebar triggers */
--floating-right-sidebar-triggers-width-darwin: 50px;
--floating-right-sidebar-triggers-width: 40px;
/* global colors */ /* global colors */
--app-bg-color: black; --app-bg-color: black;
--app-accent-color: rgb(88, 193, 66); --app-accent-color: rgb(88, 193, 66);

View File

@ -302,6 +302,25 @@ a.a-block {
} }
} }
.right-sidebar-triggers {
position: absolute;
z-index: 25;
right: 0px;
display: flex;
flex-direction: row;
align-items: center;
pointer-events: none;
height: 38px;
padding: 0 5px;
.right-sidebar-trigger {
cursor: pointer;
user-select: none;
-webkit-app-region: no-drag;
pointer-events: all;
}
}
.copied-indicator { .copied-indicator {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -16,10 +16,12 @@ import { BookmarksView } from "./bookmarks/bookmarks";
import { HistoryView } from "./history/history"; import { HistoryView } from "./history/history";
import { ConnectionsView } from "./connections/connections"; import { ConnectionsView } from "./connections/connections";
import { ClientSettingsView } from "./clientsettings/clientsettings"; import { ClientSettingsView } from "./clientsettings/clientsettings";
import { MainSideBar } from "./sidebar/sidebar"; import { MainSideBar } from "./sidebar/main";
import { DisconnectedModal, ClientStopModal } from "./common/modals"; import { RightSideBar } from "./sidebar/right";
import { ModalsProvider } from "./common/modals/provider"; import { DisconnectedModal, ClientStopModal } from "@/modals";
import { ErrorBoundary } from "./common/error/errorboundary"; import { ModalsProvider } from "@/modals/provider";
import { Button } from "@/elements";
import { ErrorBoundary } from "@/common/error/errorboundary";
import cn from "classnames"; import cn from "classnames";
import "./app.less"; import "./app.less";
@ -65,9 +67,17 @@ class App extends React.Component<{}, {}> {
} }
@boundMethod @boundMethod
openSidebar() { openMainSidebar() {
const width = GlobalModel.mainSidebarModel.getWidth(true); const mainSidebarModel = GlobalModel.mainSidebarModel;
GlobalCommandRunner.clientSetSidebar(width, false); const width = mainSidebarModel.getWidth(true);
mainSidebarModel.saveState(width, false);
}
@boundMethod
openRightSidebar() {
const rightSidebarModel = GlobalModel.rightSidebarModel;
const width = rightSidebarModel.getWidth(true);
rightSidebarModel.saveState(width, false);
} }
render() { render() {
@ -110,23 +120,36 @@ class App extends React.Component<{}, {}> {
} }
// used to force a full reload of the application // used to force a full reload of the application
const renderVersion = GlobalModel.renderVersion.get(); const renderVersion = GlobalModel.renderVersion.get();
const sidebarCollapsed = GlobalModel.mainSidebarModel.getCollapsed(); const mainSidebarCollapsed = GlobalModel.mainSidebarModel.getCollapsed();
const rightSidebarCollapsed = GlobalModel.rightSidebarModel.getCollapsed();
const activeMainView = GlobalModel.activeMainView.get();
const lightDarkClass = GlobalModel.isThemeDark() ? "is-dark" : "is-light"; const lightDarkClass = GlobalModel.isThemeDark() ? "is-dark" : "is-light";
return ( return (
<div <div
key={"version-" + renderVersion} key={"version-" + renderVersion}
id="main" id="main"
className={cn("platform-" + platform, { "sidebar-collapsed": sidebarCollapsed }, lightDarkClass)} className={cn(
"platform-" + platform,
{ "mainsidebar-collapsed": mainSidebarCollapsed, "rightsidebar-collapsed": rightSidebarCollapsed },
lightDarkClass
)}
onContextMenu={this.handleContextMenu} onContextMenu={this.handleContextMenu}
> >
<If condition={sidebarCollapsed}> <If condition={mainSidebarCollapsed}>
<div key="logo-button" className="logo-button-container"> <div key="logo-button" className="logo-button-container">
<div className="logo-button-spacer" /> <div className="logo-button-spacer" />
<div className="logo-button" onClick={this.openSidebar}> <div className="logo-button" onClick={this.openMainSidebar}>
<img src="public/logos/wave-logo.png" alt="logo" /> <img src="public/logos/wave-logo.png" alt="logo" />
</div> </div>
</div> </div>
</If> </If>
<If condition={GlobalModel.isDev && rightSidebarCollapsed && activeMainView == "session"}>
<div className="right-sidebar-triggers">
<Button className="secondary ghost right-sidebar-trigger" onClick={this.openRightSidebar}>
<i className="fa-sharp fa-regular fa-lightbulb"></i>
</Button>
</div>
</If>
<div ref={this.mainContentRef} className="main-content"> <div ref={this.mainContentRef} className="main-content">
<MainSideBar parentRef={this.mainContentRef} clientData={clientData} /> <MainSideBar parentRef={this.mainContentRef} clientData={clientData} />
<ErrorBoundary> <ErrorBoundary>
@ -137,6 +160,7 @@ class App extends React.Component<{}, {}> {
<ConnectionsView model={remotesModel} /> <ConnectionsView model={remotesModel} />
<ClientSettingsView model={remotesModel} /> <ClientSettingsView model={remotesModel} />
</ErrorBoundary> </ErrorBoundary>
<RightSideBar parentRef={this.mainContentRef} clientData={clientData} />
</div> </div>
<ModalsProvider /> <ModalsProvider />
</div> </div>

View File

@ -5,7 +5,6 @@
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
outline: inherit; outline: inherit;
display: flex; display: flex;
padding: 6px 16px; padding: 6px 16px;
align-items: center; align-items: center;
@ -15,7 +14,8 @@
line-height: 1.5; line-height: 1.5;
&.primary { &.primary {
background: none; color: var(--form-element-text-color);
background: var(--form-element-primary-color);
i { i {
fill: var(--form-element-primary-color); fill: var(--form-element-primary-color);
@ -31,11 +31,12 @@
} }
&.outlined { &.outlined {
background: none;
border: 1px solid var(--form-element-primary-color); border: 1px solid var(--form-element-primary-color);
} }
&.ghost { &.ghost {
// Styles for .ghost are already defined above background: none;
} }
&:hover { &:hover {
@ -46,6 +47,9 @@
&.secondary { &.secondary {
color: var(--form-element-text-color); color: var(--form-element-text-color);
background: none; background: none;
color: var(--form-element-text-color);
background: var(--form-element-secondary-color);
box-shadow: none;
&.solid { &.solid {
color: var(--form-element-text-color); color: var(--form-element-text-color);
@ -54,10 +58,12 @@
} }
&.outlined { &.outlined {
background: none;
border: 1px solid var(--form-element-secondary-color); border: 1px solid var(--form-element-secondary-color);
} }
&.ghost { &.ghost {
background: none;
padding: 6px 10px; padding: 6px 10px;
i { i {

View File

@ -1,24 +1,15 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react"; import * as React from "react";
import { boundMethod } from "autobind-decorator"; import { boundMethod } from "autobind-decorator";
import cn from "classnames"; import cn from "classnames";
import "./button.less"; import "./button.less";
type ButtonVariantType = "outlined" | "solid" | "ghost";
type ButtonThemeType = "primary" | "secondary";
interface ButtonProps { interface ButtonProps {
theme?: ButtonThemeType;
children: React.ReactNode; children: React.ReactNode;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
variant?: ButtonVariantType;
leftIcon?: React.ReactNode; leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode; rightIcon?: React.ReactNode;
color?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
autoFocus?: boolean; autoFocus?: boolean;
className?: string; className?: string;
@ -27,10 +18,8 @@ interface ButtonProps {
class Button extends React.Component<ButtonProps> { class Button extends React.Component<ButtonProps> {
static defaultProps = { static defaultProps = {
theme: "primary",
variant: "solid",
color: "",
style: {}, style: {},
className: "primary",
}; };
@boundMethod @boundMethod
@ -41,31 +30,11 @@ class Button extends React.Component<ButtonProps> {
} }
render() { render() {
const { const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className } = this.props;
leftIcon,
rightIcon,
theme,
children,
disabled,
variant,
color,
style,
autoFocus,
termInline,
className,
} = this.props;
return ( return (
<button <button
className={cn( className={cn("wave-button", { disabled }, { "term-inline": termInline }, className)}
"wave-button",
theme,
variant,
color,
{ disabled: disabled },
{ "term-inline": termInline },
className
)}
onClick={this.handleClick} onClick={this.handleClick}
disabled={disabled} disabled={disabled}
style={style} style={style}

View File

@ -1,21 +0,0 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { Button } from "./button";
class IconButton extends Button {
render() {
const { children, theme, variant = "solid", ...rest } = this.props;
const className = `wave-button icon-button ${theme} ${variant}`;
return (
<button {...rest} className={className}>
{children}
</button>
);
}
}
export default IconButton;
export { IconButton };

View File

@ -3,7 +3,6 @@ export { Checkbox } from "./checkbox";
export { CmdStrCode } from "./cmdstrcode"; export { CmdStrCode } from "./cmdstrcode";
export { renderCmdText } from "./cmdtext"; export { renderCmdText } from "./cmdtext";
export { Dropdown } from "./dropdown"; export { Dropdown } from "./dropdown";
export { IconButton } from "./iconbutton";
export { InlineSettingsTextEdit } from "./inlinesettingstextedit"; export { InlineSettingsTextEdit } from "./inlinesettingstextedit";
export { InputDecoration } from "./inputdecoration"; export { InputDecoration } from "./inputdecoration";
export { LinkButton } from "./linkbutton"; export { LinkButton } from "./linkbutton";

View File

@ -6,7 +6,6 @@ import * as mobx from "mobx";
import { If } from "tsx-control-statements/components"; import { If } from "tsx-control-statements/components";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { Button } from "./button"; import { Button } from "./button";
import { IconButton } from "./iconbutton";
import "./modal.less"; import "./modal.less";
@ -19,9 +18,9 @@ const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, title }) => (
<div className="wave-modal-header"> <div className="wave-modal-header">
{<div className="wave-modal-title">{title}</div>} {<div className="wave-modal-title">{title}</div>}
<If condition={onClose}> <If condition={onClose}>
<IconButton theme="secondary" variant="ghost" onClick={onClose}> <Button className="secondary ghost" onClick={onClose}>
<i className="fa-sharp fa-solid fa-xmark"></i> <i className="fa-sharp fa-solid fa-xmark"></i>
</IconButton> </Button>
</If> </If>
</div> </div>
); );
@ -36,11 +35,15 @@ interface ModalFooterProps {
const ModalFooter: React.FC<ModalFooterProps> = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }) => ( const ModalFooter: React.FC<ModalFooterProps> = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }) => (
<div className="wave-modal-footer"> <div className="wave-modal-footer">
{onCancel && ( {onCancel && (
<Button theme="secondary" onClick={onCancel}> <Button className="secondary" onClick={onCancel}>
{cancelLabel} {cancelLabel}
</Button> </Button>
)} )}
{onOk && <Button onClick={onOk}>{okLabel}</Button>} {onOk && (
<Button className="primary" onClick={onOk}>
{okLabel}
</Button>
)}
</div> </div>
); );

View File

@ -6,7 +6,7 @@ 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 cn from "classnames"; import cn from "classnames";
import { GlobalModel, GlobalCommandRunner } from "@/models"; import { GlobalCommandRunner, SidebarModel } from "@/models";
import { MagicLayout } from "@/app/magiclayout"; import { MagicLayout } from "@/app/magiclayout";
import "./resizablesidebar.less"; import "./resizablesidebar.less";
@ -14,6 +14,7 @@ import "./resizablesidebar.less";
interface ResizableSidebarProps { interface ResizableSidebarProps {
parentRef: React.RefObject<HTMLElement>; parentRef: React.RefObject<HTMLElement>;
position: "left" | "right"; position: "left" | "right";
model: SidebarModel;
enableSnap?: boolean; enableSnap?: boolean;
className?: string; className?: string;
children?: (toggleCollapsed: () => void) => React.ReactNode; children?: (toggleCollapsed: () => void) => React.ReactNode;
@ -32,7 +33,7 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
startResizing(event: React.MouseEvent<HTMLDivElement>) { startResizing(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault(); event.preventDefault();
const { parentRef, position } = this.props; const { parentRef, position, model } = this.props;
const parentRect = parentRef.current?.getBoundingClientRect(); const parentRect = parentRef.current?.getBoundingClientRect();
if (!parentRect) return; if (!parentRect) return;
@ -43,17 +44,16 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
this.startX = event.clientX - parentRect.left; this.startX = event.clientX - parentRect.left;
} }
const mainSidebarModel = GlobalModel.mainSidebarModel; const collapsed = model.getCollapsed();
const collapsed = mainSidebarModel.getCollapsed();
this.resizeStartWidth = mainSidebarModel.getWidth(); this.resizeStartWidth = model.getWidth();
document.addEventListener("mousemove", this.onMouseMove); document.addEventListener("mousemove", this.onMouseMove);
document.addEventListener("mouseup", this.stopResizing); document.addEventListener("mouseup", this.stopResizing);
document.body.style.cursor = "col-resize"; document.body.style.cursor = "col-resize";
mobx.action(() => { mobx.action(() => {
mainSidebarModel.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed); model.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed);
mainSidebarModel.isDragging.set(true); model.isDragging.set(true);
})(); })();
} }
@ -61,11 +61,10 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
onMouseMove(event: MouseEvent) { onMouseMove(event: MouseEvent) {
event.preventDefault(); event.preventDefault();
const { parentRef, enableSnap, position } = this.props; const { parentRef, enableSnap, position, model } = this.props;
const parentRect = parentRef.current?.getBoundingClientRect(); const parentRect = parentRef.current?.getBoundingClientRect();
const mainSidebarModel = GlobalModel.mainSidebarModel;
if (!mainSidebarModel.isDragging.get() || !parentRect) return; if (!model.isDragging.get() || !parentRect) return;
let delta: number, newWidth: number; let delta: number, newWidth: number;
@ -100,34 +99,27 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") { if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") {
newWidth = snapPoint; newWidth = snapPoint;
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); model.setTempWidthAndTempCollapsed(newWidth, false);
} else if (newWidth + dragResistance < snapPoint && dragDirection == "-") { } else if (newWidth + dragResistance < snapPoint && dragDirection == "-") {
newWidth = minWidth; newWidth = minWidth;
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true); model.setTempWidthAndTempCollapsed(newWidth, true);
} else if (newWidth > snapPoint) { } else if (newWidth > snapPoint) {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); model.setTempWidthAndTempCollapsed(newWidth, false);
} }
} else { } else {
if (newWidth <= MagicLayout.MainSidebarMinWidth) { if (newWidth <= MagicLayout.MainSidebarMinWidth) {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true); model.setTempWidthAndTempCollapsed(newWidth, true);
} else { } else {
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); model.setTempWidthAndTempCollapsed(newWidth, false);
} }
} }
} }
@boundMethod @boundMethod
stopResizing() { stopResizing() {
let mainSidebarModel = GlobalModel.mainSidebarModel; const { model } = this.props;
GlobalCommandRunner.clientSetSidebar( model.saveState(model.tempWidth.get(), model.tempCollapsed.get());
mainSidebarModel.tempWidth.get(),
mainSidebarModel.tempCollapsed.get()
).finally(() => {
mobx.action(() => {
mainSidebarModel.isDragging.set(false);
})();
});
document.removeEventListener("mousemove", this.onMouseMove); document.removeEventListener("mousemove", this.onMouseMove);
document.removeEventListener("mouseup", this.stopResizing); document.removeEventListener("mouseup", this.stopResizing);
@ -136,19 +128,18 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
@boundMethod @boundMethod
toggleCollapsed() { toggleCollapsed() {
const mainSidebarModel = GlobalModel.mainSidebarModel; const { model } = this.props;
const tempCollapsed = mainSidebarModel.getCollapsed(); const tempCollapsed = model.getCollapsed();
const width = mainSidebarModel.getWidth(true); const width = model.getWidth(true);
mainSidebarModel.setTempWidthAndTempCollapsed(width, !tempCollapsed); model.setTempWidthAndTempCollapsed(width, !tempCollapsed);
GlobalCommandRunner.clientSetSidebar(width, !tempCollapsed); model.saveState(width, !tempCollapsed);
} }
render() { render() {
const { className, children } = this.props; const { className, children, model } = this.props;
const mainSidebarModel = GlobalModel.mainSidebarModel; const width = model.getWidth();
const width = mainSidebarModel.getWidth(); const isCollapsed = model.getCollapsed();
const isCollapsed = mainSidebarModel.getCollapsed();
return ( return (
<div className={cn("sidebar", className, { collapsed: isCollapsed })} style={{ width, minWidth: width }}> <div className={cn("sidebar", className, { collapsed: isCollapsed })} style={{ width, minWidth: width }}>

View File

@ -54,7 +54,7 @@ class AlertModal extends React.Component<{}, {}> {
</div> </div>
<div className="wave-modal-footer"> <div className="wave-modal-footer">
<If condition={isConfirm}> <If condition={isConfirm}>
<Button theme="secondary" onClick={this.closeModal}> <Button className="secondary" onClick={this.closeModal}>
Cancel Cancel
</Button> </Button>
<Button autoFocus={true} onClick={this.handleOK}> <Button autoFocus={true} onClick={this.handleOK}>

View File

@ -31,7 +31,7 @@ class ClientStopModal extends React.Component<{}, {}> {
</If> </If>
<div> <div>
<Button <Button
theme="secondary" className="secondary"
onClick={this.refreshClient} onClick={this.refreshClient}
leftIcon={<i className="fa-sharp fa-solid fa-rotate"></i>} leftIcon={<i className="fa-sharp fa-solid fa-rotate"></i>}
> >

View File

@ -75,7 +75,7 @@ class DisconnectedModal extends React.Component<{}, {}> {
</div> </div>
<div className="wave-modal-footer"> <div className="wave-modal-footer">
<Button <Button
theme="secondary" className="secondary"
onClick={this.tryReconnect} onClick={this.tryReconnect}
leftIcon={ leftIcon={
<span className="icon"> <span className="icon">
@ -86,7 +86,7 @@ class DisconnectedModal extends React.Component<{}, {}> {
Try Reconnect Try Reconnect
</Button> </Button>
<Button <Button
theme="secondary" className="secondary"
onClick={this.restartServer} onClick={this.restartServer}
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>} leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
> >

View File

@ -162,42 +162,42 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
renderHeaderBtns(remote: RemoteType): React.ReactNode { renderHeaderBtns(remote: RemoteType): React.ReactNode {
let buttons: React.ReactNode[] = []; let buttons: React.ReactNode[] = [];
const disconnectButton = ( const disconnectButton = (
<Button theme="secondary" onClick={() => this.disconnectRemote(remote.remoteid)}> <Button className="secondary" onClick={() => this.disconnectRemote(remote.remoteid)}>
Disconnect Now Disconnect Now
</Button> </Button>
); );
const connectButton = ( const connectButton = (
<Button theme="secondary" onClick={() => this.connectRemote(remote.remoteid)}> <Button className="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
Connect Now Connect Now
</Button> </Button>
); );
const tryReconnectButton = ( const tryReconnectButton = (
<Button theme="secondary" onClick={() => this.connectRemote(remote.remoteid)}> <Button className="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
Try Reconnect Try Reconnect
</Button> </Button>
); );
let updateAuthButton = ( let updateAuthButton = (
<Button theme="secondary" onClick={() => this.openEditModal()}> <Button className="secondary" onClick={() => this.openEditModal()}>
Edit Edit
</Button> </Button>
); );
let cancelInstallButton = ( let cancelInstallButton = (
<Button theme="secondary" onClick={() => this.cancelInstall(remote.remoteid)}> <Button className="secondary" onClick={() => this.cancelInstall(remote.remoteid)}>
Cancel Install Cancel Install
</Button> </Button>
); );
let installNowButton = ( let installNowButton = (
<Button theme="secondary" onClick={() => this.installRemote(remote.remoteid)}> <Button className="secondary" onClick={() => this.installRemote(remote.remoteid)}>
Install Now Install Now
</Button> </Button>
); );
let archiveButton = ( let archiveButton = (
<Button theme="secondary" onClick={() => this.clickArchive()}> <Button className="secondary" onClick={() => this.clickArchive()}>
Delete Delete
</Button> </Button>
); );
const reinstallButton = ( const reinstallButton = (
<Button theme="secondary" onClick={this.clickReinstall}> <Button className="secondary" onClick={this.clickReinstall}>
Reinstall Reinstall
</Button> </Button>
); );
@ -207,7 +207,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
} }
if (remote.sshconfigsrc == "sshconfig-import") { if (remote.sshconfigsrc == "sshconfig-import") {
archiveButton = ( archiveButton = (
<Button theme="secondary" onClick={() => this.clickArchive()}> <Button className="secondary" onClick={() => this.clickArchive()}>
Delete Delete
<Tooltip <Tooltip
message={ message={
@ -383,7 +383,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
</div> </div>
<div className="wave-modal-footer"> <div className="wave-modal-footer">
<Button <Button
theme="secondary" className="secondary"
disabled={selectedRemoteStatus == "connecting"} disabled={selectedRemoteStatus == "connecting"}
onClick={this.handleClose} onClick={this.handleClose}
> >

View File

@ -180,14 +180,14 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
</table> </table>
<footer> <footer>
<Button <Button
theme="secondary" className="secondary"
leftIcon={<i className="fa-sharp fa-solid fa-plus"></i>} leftIcon={<i className="fa-sharp fa-solid fa-plus"></i>}
onClick={this.handleAddConnection} onClick={this.handleAddConnection}
> >
New Connection New Connection
</Button> </Button>
<Button <Button
theme="secondary" className="secondary"
leftIcon={<i className="fa-sharp fa-solid fa-fw fa-file-import"></i>} leftIcon={<i className="fa-sharp fa-solid fa-fw fa-file-import"></i>}
onClick={this.handleImportSshConfig} onClick={this.handleImportSshConfig}
> >

View File

@ -16,11 +16,17 @@ let MagicLayout = {
ScreenSidebarMinWidth: 200, ScreenSidebarMinWidth: 200,
ScreenSidebarHeaderHeight: 26, ScreenSidebarHeaderHeight: 26,
MainSidebarMinWidth: 75, MainSidebarMinWidth: 0,
MainSidebarMaxWidth: 300, MainSidebarMaxWidth: 300,
MainSidebarSnapThreshold: 90, MainSidebarSnapThreshold: 165,
MainSidebarDragResistance: 50, MainSidebarDragResistance: 50,
MainSidebarDefaultWidth: 240, MainSidebarDefaultWidth: 240,
RightSidebarMinWidth: 0,
RightSidebarMaxWidth: 300,
RightSidebarSnapThreshold: 90,
RightSidebarDragResistance: 50,
RightSidebarDefaultWidth: 240,
}; };
let m = MagicLayout; let m = MagicLayout;

View File

@ -21,7 +21,7 @@ import { isBlank, openLink } from "@/util/util";
import { ResizableSidebar } from "@/common/elements"; import { ResizableSidebar } from "@/common/elements";
import * as appconst from "@/app/appconst"; import * as appconst from "@/app/appconst";
import "./sidebar.less"; import "./main.less";
import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "@/common/icons/icons"; import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "@/common/icons/icons";
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -251,6 +251,7 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
render() { render() {
return ( return (
<ResizableSidebar <ResizableSidebar
model={GlobalModel.mainSidebarModel}
className="main-sidebar" className="main-sidebar"
position="left" position="left"
enableSnap={true} enableSnap={true}

View File

@ -0,0 +1,28 @@
@import "@/common/icons/icons.less";
.right-sidebar {
padding: 0;
display: flex;
flex-direction: column;
position: relative;
line-height: 20px;
backdrop-filter: blur(4px);
z-index: 20;
font-size: var(--sidebar-font-size);
font-family: var(--base-font-family);
font-weight: var(--sidebar-font-weight);
border-left: 1px solid var(--app-border-color);
.header {
height: 39px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 5px;
border-bottom: 1px solid var(--app-border-color);
}
&.collapsed {
display: none;
}
}

46
src/app/sidebar/right.tsx Normal file
View File

@ -0,0 +1,46 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel } from "@/models";
import { ResizableSidebar, Button } from "@/elements";
import "./right.less";
dayjs.extend(localizedFormat);
interface RightSideBarProps {
parentRef: React.RefObject<HTMLElement>;
clientData: ClientDataType;
}
@mobxReact.observer
class RightSideBar extends React.Component<RightSideBarProps, {}> {
render() {
return (
<ResizableSidebar
model={GlobalModel.rightSidebarModel}
className="right-sidebar"
position="right"
enableSnap={true}
parentRef={this.props.parentRef}
>
{(toggleCollapse) => (
<React.Fragment>
<div className="header">
<Button className="secondary ghost" onClick={toggleCollapse}>
<i className="fa-sharp fa-regular fa-xmark"></i>
</Button>
</div>
</React.Fragment>
)}
</ResizableSidebar>
);
}
}
export { RightSideBar };

View File

@ -325,7 +325,7 @@ class ScreenSidebar extends React.Component<{ screen: Screen; width: string }, {
<br /> <br />
</div> </div>
<div onClick={this.sidebarClose} className="close-button-container"> <div onClick={this.sidebarClose} className="close-button-container">
<Button theme="secondary" onClick={this.sidebarClose}> <Button className="secondary" onClick={this.sidebarClose}>
Close Sidebar Close Sidebar
</Button> </Button>
</div> </div>

View File

@ -189,10 +189,19 @@
// This ensures the tab bar does not collide with the floating logo. The floating logo sits above the sidebar when it is not collapsed, so no additional margin is needed in that case. // This ensures the tab bar does not collide with the floating logo. The floating logo sits above the sidebar when it is not collapsed, so no additional margin is needed in that case.
// More margin is given on macOS to account for the traffic light buttons // More margin is given on macOS to account for the traffic light buttons
#main.platform-darwin.sidebar-collapsed .screen-tabs-container { #main.platform-darwin.mainsidebar-collapsed .screen-tabs-container {
margin-left: var(--floating-logo-width-darwin); margin-left: var(--floating-logo-width-darwin);
} }
#main:not(.platform-darwin).sidebar-collapsed .screen-tabs-container { #main:not(.platform-darwin).mainsidebar-collapsed .screen-tabs-container {
margin-left: var(--floating-logo-width); margin-left: var(--floating-logo-width);
} }
// This ensures the tab bar does not collide with the right sidebar triggers.
#main.platform-darwin.rightsidebar-collapsed .screen-tabs-container {
margin-right: var(--floating-right-sidebar-triggers-width-darwin);
}
#main:not(.platform-darwin).rightsidebar-collapsed .screen-tabs-container {
margin-left: var(--floating-right-sidebar-triggers-width);
}

View File

@ -39,16 +39,12 @@ class WorkspaceView extends React.Component<{}, {}> {
let isHidden = GlobalModel.activeMainView.get() != "session"; let isHidden = GlobalModel.activeMainView.get() != "session";
let mainSidebarModel = GlobalModel.mainSidebarModel; 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 <div
className={cn("mainview", "session-view", { "is-hidden": isHidden })} className={cn("mainview", "session-view", { "is-hidden": isHidden })}
data-sessionid={session.sessionId} data-sessionid={session.sessionId}
style={{ style={{
width: `${width}px`, width: `${window.innerWidth - mainSidebarModel.getWidth()}px`,
}} }}
> >
<ScreenTabs key={"tabs-" + session.sessionId} session={session} /> <ScreenTabs key={"tabs-" + session.sessionId} session={session} />

View File

@ -399,9 +399,14 @@ 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> { clientSetMainSidebar(width: number, collapsed: boolean): Promise<CommandRtnType> {
let kwargs = { nohist: "1", width: `${width}`, collapsed: collapsed ? "1" : "0" }; let kwargs = { nohist: "1", width: `${width}`, collapsed: collapsed ? "1" : "0" };
return GlobalModel.submitCommand("client", "setsidebar", null, kwargs, false); return GlobalModel.submitCommand("client", "setmainsidebar", null, kwargs, false);
}
clientSetRightSidebar(width: number, collapsed: boolean): Promise<CommandRtnType> {
let kwargs = { nohist: "1", width: `${width}`, collapsed: collapsed ? "1" : "0" };
return GlobalModel.submitCommand("client", "setrightsidebar", null, kwargs, false);
} }
editBookmark(bookmarkId: string, desc: string, cmdstr: string) { editBookmark(bookmarkId: string, desc: string, cmdstr: string) {

View File

@ -1,3 +1,4 @@
export type { SidebarModel } from "./sidebar";
export * from "./global"; export * from "./global";
export * from "./model"; export * from "./model";
export { BookmarksModel } from "./bookmarks"; export { BookmarksModel } from "./bookmarks";
@ -6,6 +7,7 @@ export { Cmd } from "./cmd";
export { ConnectionsViewModel } from "./connectionsview"; export { ConnectionsViewModel } from "./connectionsview";
export { InputModel } from "./input"; export { InputModel } from "./input";
export { MainSidebarModel } from "./mainsidebar"; export { MainSidebarModel } from "./mainsidebar";
export { RightSidebarModel } from "./rightsidebar";
export { ModalsModel } from "./modals"; export { ModalsModel } from "./modals";
export { PluginsModel } from "./plugins"; export { PluginsModel } from "./plugins";
export { RemotesModel } from "./remotes"; export { RemotesModel } from "./remotes";

View File

@ -4,6 +4,7 @@
import * as mobx from "mobx"; import * as mobx from "mobx";
import { MagicLayout } from "@/app/magiclayout"; import { MagicLayout } from "@/app/magiclayout";
import { Model } from "./model"; import { Model } from "./model";
import { GlobalCommandRunner } from "@/models";
class MainSidebarModel { class MainSidebarModel {
globalModel: Model = null; globalModel: Model = null;
@ -80,6 +81,14 @@ class MainSidebarModel {
} }
return collapsed; return collapsed;
} }
saveState(width: number, collapsed: boolean): void {
GlobalCommandRunner.clientSetMainSidebar(width, collapsed).finally(() => {
mobx.action(() => {
this.isDragging.set(false);
})();
});
}
} }
export { MainSidebarModel }; export { MainSidebarModel };

View File

@ -13,7 +13,6 @@ import {
isModKeyPress, isModKeyPress,
isBlank, isBlank,
} from "@/util/util"; } from "@/util/util";
import { loadFonts } from "@/util/fontutil";
import { loadTheme } from "@/util/themeutil"; import { loadTheme } from "@/util/themeutil";
import { WSControl } from "./ws"; import { WSControl } from "./ws";
import { cmdStatusIsRunning } from "@/app/line/lineutil"; import { cmdStatusIsRunning } from "@/app/line/lineutil";
@ -31,6 +30,7 @@ import { ClientSettingsViewModel } from "./clientsettingsview";
import { RemotesModel } from "./remotes"; import { RemotesModel } from "./remotes";
import { ModalsModel } from "./modals"; import { ModalsModel } from "./modals";
import { MainSidebarModel } from "./mainsidebar"; import { MainSidebarModel } from "./mainsidebar";
import { RightSidebarModel } from "./rightsidebar";
import { Screen } from "./screen"; import { Screen } from "./screen";
import { Cmd } from "./cmd"; import { Cmd } from "./cmd";
import { GlobalCommandRunner } from "./global"; import { GlobalCommandRunner } from "./global";
@ -117,6 +117,7 @@ class Model {
clientSettingsViewModel: ClientSettingsViewModel; clientSettingsViewModel: ClientSettingsViewModel;
modalsModel: ModalsModel; modalsModel: ModalsModel;
mainSidebarModel: MainSidebarModel; mainSidebarModel: MainSidebarModel;
rightSidebarModel: RightSidebarModel;
clientData: OV<ClientDataType> = mobx.observable.box(null, { clientData: OV<ClientDataType> = mobx.observable.box(null, {
name: "clientData", name: "clientData",
}); });
@ -156,6 +157,7 @@ class Model {
this.remotesModel = new RemotesModel(this); this.remotesModel = new RemotesModel(this);
this.modalsModel = new ModalsModel(); this.modalsModel = new ModalsModel();
this.mainSidebarModel = new MainSidebarModel(this); this.mainSidebarModel = new MainSidebarModel(this);
this.rightSidebarModel = new RightSidebarModel(this);
const isWaveSrvRunning = getApi().getWaveSrvStatus(); const isWaveSrvRunning = getApi().getWaveSrvStatus();
this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, { this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, {
name: "model-wavesrv-running", name: "model-wavesrv-running",

100
src/models/rightsidebar.ts Normal file
View File

@ -0,0 +1,100 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as mobx from "mobx";
import { MagicLayout } from "@/app/magiclayout";
import { Model } from "./model";
import { GlobalCommandRunner } from "@/models";
interface SidebarModel {}
class RightSidebarModel implements SidebarModel {
globalModel: Model = null;
tempWidth: OV<number> = mobx.observable.box(null, {
name: "RightSidebarModel-tempWidth",
});
tempCollapsed: OV<boolean> = mobx.observable.box(null, {
name: "RightSidebarModel-tempCollapsed",
});
isDragging: OV<boolean> = mobx.observable.box(false, {
name: "RightSidebarModel-isDragging",
});
constructor(globalModel: Model) {
this.globalModel = globalModel;
}
setTempWidthAndTempCollapsed(newWidth: number, newCollapsed: boolean): void {
const width = Math.max(MagicLayout.RightSidebarMinWidth, Math.min(newWidth, MagicLayout.RightSidebarMaxWidth));
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 = this.globalModel.clientData.get();
let width = clientData?.clientopts?.rightsidebar?.width ?? MagicLayout.RightSidebarDefaultWidth;
if (this.isDragging.get()) {
if (this.tempWidth.get() == null && width == null) {
return MagicLayout.RightSidebarDefaultWidth;
}
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.RightSidebarMinWidth;
}
} else {
if (width <= MagicLayout.RightSidebarMinWidth) {
width = MagicLayout.RightSidebarDefaultWidth;
}
const snapPoint = MagicLayout.RightSidebarMinWidth + MagicLayout.RightSidebarSnapThreshold;
if (width < snapPoint || width > MagicLayout.RightSidebarMaxWidth) {
width = MagicLayout.RightSidebarDefaultWidth;
}
}
return width;
}
getCollapsed(): boolean {
// disable right sidebar in production
if (!this.globalModel.isDev) {
return true;
}
const clientData = this.globalModel.clientData.get();
const collapsed = clientData?.clientopts?.rightsidebar?.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;
}
saveState(width: number, collapsed: boolean): void {
GlobalCommandRunner.clientSetRightSidebar(width, collapsed).finally(() => {
mobx.action(() => {
this.isDragging.set(false);
})();
});
}
}
export { RightSidebarModel };

13
src/models/sidebar.ts Normal file
View File

@ -0,0 +1,13 @@
import { Model } from "./model";
export interface SidebarModel {
readonly globalModel: Model;
readonly tempWidth: OV<number>;
readonly tempCollapsed: OV<boolean>;
readonly isDragging: OV<boolean>;
setTempWidthAndTempCollapsed(newWidth: number, newCollapsed: boolean): void;
getWidth(ignoreCollapse?: boolean): number;
getCollapsed(): boolean;
saveState(width: number, collapsed: boolean): void;
}

View File

@ -433,7 +433,7 @@ class SourceCodeRenderer extends React.Component<
return ( return (
<> <>
<If condition={isPreviewerAvailable}> <If condition={isPreviewerAvailable}>
<Button theme="primary" termInline={true}> <Button className="primary" termInline={true}>
<div onClick={this.togglePreview} className={`preview`}> <div onClick={this.togglePreview} className={`preview`}>
{`${showPreview ? "hide" : "show"} preview (`} {`${showPreview ? "hide" : "show"} preview (`}
{renderCmdText("P")} {renderCmdText("P")}
@ -449,7 +449,7 @@ class SourceCodeRenderer extends React.Component<
))} ))}
</select> </select>
<If condition={allowEditing}> <If condition={allowEditing}>
<Button theme="primary" termInline={true}> <Button className="primary" termInline={true}>
<div onClick={() => this.doSave()}> <div onClick={() => this.doSave()}>
{`save (`} {`save (`}
{renderCmdText("S")} {renderCmdText("S")}

View File

@ -579,6 +579,10 @@ declare global {
collapsed: boolean; collapsed: boolean;
width: number; width: number;
}; };
rightsidebar: {
collapsed: boolean;
width: number;
};
globalshortcut: string; globalshortcut: string;
globalshortcutenabled: boolean; globalshortcutenabled: boolean;
}; };

View File

@ -234,7 +234,8 @@ 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("client:setmainsidebar", ClientSetMainSidebarCommand)
registerCmdFn("client:setrightsidebar", ClientSetRightSidebarCommand)
registerCmdFn("client:setglobalshortcut", ClientSetGlobalShortcut) registerCmdFn("client:setglobalshortcut", ClientSetGlobalShortcut)
registerCmdFn("sidebar:open", SidebarOpenCommand) registerCmdFn("sidebar:open", SidebarOpenCommand)
@ -5271,7 +5272,7 @@ func ClientSetGlobalShortcut(ctx context.Context, pk *scpacket.FeCommandPacketTy
return update, nil return update, nil
} }
func ClientSetSidebarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) { func ClientSetMainSidebarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
clientData, err := sstore.EnsureClientData(ctx) clientData, err := sstore.EnsureClientData(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err) return nil, fmt.Errorf("cannot retrieve client data: %v", err)
@ -5326,6 +5327,61 @@ func ClientSetSidebarCommand(ctx context.Context, pk *scpacket.FeCommandPacketTy
return update, nil return update, nil
} }
func ClientSetRightSidebarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.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.RightSidebar != nil {
width = clientData.ClientOpts.RightSidebar.Width
}
// Initialize SidebarCollapsed if it's nil
if clientData.ClientOpts.RightSidebar == nil {
clientData.ClientOpts.RightSidebar = new(sstore.SidebarValueType)
}
// Set the sidebar values
var sv sstore.SidebarValueType
sv.Collapsed = collapsedValue
if width != 0 {
sv.Width = width
}
clientData.ClientOpts.RightSidebar = &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 := scbus.MakeUpdatePacket()
update.AddUpdate(*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

@ -294,6 +294,7 @@ type ClientOptsType struct {
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"` MainSidebar *SidebarValueType `json:"mainsidebar,omitempty"`
RightSidebar *SidebarValueType `json:"rightsidebar,omitempty"`
GlobalShortcut string `json:"globalshortcut,omitempty"` GlobalShortcut string `json:"globalshortcut,omitempty"`
GlobalShortcutEnabled bool `json:"globalshortcutenabled,omitempty"` GlobalShortcutEnabled bool `json:"globalshortcutenabled,omitempty"`
} }