mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
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:
parent
61c9d21014
commit
07e0b53b17
@ -44,6 +44,10 @@
|
||||
--floating-logo-width: 40px;
|
||||
--floating-logo-height: var(--screentabs-height);
|
||||
|
||||
/* right sidebar triggers */
|
||||
--floating-right-sidebar-triggers-width-darwin: 50px;
|
||||
--floating-right-sidebar-triggers-width: 40px;
|
||||
|
||||
/* global colors */
|
||||
--app-bg-color: black;
|
||||
--app-accent-color: rgb(88, 193, 66);
|
||||
|
@ -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 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -16,10 +16,12 @@ import { BookmarksView } from "./bookmarks/bookmarks";
|
||||
import { HistoryView } from "./history/history";
|
||||
import { ConnectionsView } from "./connections/connections";
|
||||
import { ClientSettingsView } from "./clientsettings/clientsettings";
|
||||
import { MainSideBar } from "./sidebar/sidebar";
|
||||
import { DisconnectedModal, ClientStopModal } from "./common/modals";
|
||||
import { ModalsProvider } from "./common/modals/provider";
|
||||
import { ErrorBoundary } from "./common/error/errorboundary";
|
||||
import { MainSideBar } from "./sidebar/main";
|
||||
import { RightSideBar } from "./sidebar/right";
|
||||
import { DisconnectedModal, ClientStopModal } from "@/modals";
|
||||
import { ModalsProvider } from "@/modals/provider";
|
||||
import { Button } from "@/elements";
|
||||
import { ErrorBoundary } from "@/common/error/errorboundary";
|
||||
import cn from "classnames";
|
||||
import "./app.less";
|
||||
|
||||
@ -65,9 +67,17 @@ class App extends React.Component<{}, {}> {
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
openSidebar() {
|
||||
const width = GlobalModel.mainSidebarModel.getWidth(true);
|
||||
GlobalCommandRunner.clientSetSidebar(width, false);
|
||||
openMainSidebar() {
|
||||
const mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
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() {
|
||||
@ -110,23 +120,36 @@ class App extends React.Component<{}, {}> {
|
||||
}
|
||||
// used to force a full reload of the application
|
||||
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";
|
||||
return (
|
||||
<div
|
||||
key={"version-" + renderVersion}
|
||||
id="main"
|
||||
className={cn("platform-" + platform, { "sidebar-collapsed": sidebarCollapsed }, lightDarkClass)}
|
||||
className={cn(
|
||||
"platform-" + platform,
|
||||
{ "mainsidebar-collapsed": mainSidebarCollapsed, "rightsidebar-collapsed": rightSidebarCollapsed },
|
||||
lightDarkClass
|
||||
)}
|
||||
onContextMenu={this.handleContextMenu}
|
||||
>
|
||||
<If condition={sidebarCollapsed}>
|
||||
<If condition={mainSidebarCollapsed}>
|
||||
<div key="logo-button" className="logo-button-container">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<MainSideBar parentRef={this.mainContentRef} clientData={clientData} />
|
||||
<ErrorBoundary>
|
||||
@ -137,6 +160,7 @@ class App extends React.Component<{}, {}> {
|
||||
<ConnectionsView model={remotesModel} />
|
||||
<ClientSettingsView model={remotesModel} />
|
||||
</ErrorBoundary>
|
||||
<RightSideBar parentRef={this.mainContentRef} clientData={clientData} />
|
||||
</div>
|
||||
<ModalsProvider />
|
||||
</div>
|
||||
|
@ -5,7 +5,6 @@
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
|
||||
display: flex;
|
||||
padding: 6px 16px;
|
||||
align-items: center;
|
||||
@ -15,7 +14,8 @@
|
||||
line-height: 1.5;
|
||||
|
||||
&.primary {
|
||||
background: none;
|
||||
color: var(--form-element-text-color);
|
||||
background: var(--form-element-primary-color);
|
||||
|
||||
i {
|
||||
fill: var(--form-element-primary-color);
|
||||
@ -31,11 +31,12 @@
|
||||
}
|
||||
|
||||
&.outlined {
|
||||
background: none;
|
||||
border: 1px solid var(--form-element-primary-color);
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
// Styles for .ghost are already defined above
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@ -46,6 +47,9 @@
|
||||
&.secondary {
|
||||
color: var(--form-element-text-color);
|
||||
background: none;
|
||||
color: var(--form-element-text-color);
|
||||
background: var(--form-element-secondary-color);
|
||||
box-shadow: none;
|
||||
|
||||
&.solid {
|
||||
color: var(--form-element-text-color);
|
||||
@ -54,10 +58,12 @@
|
||||
}
|
||||
|
||||
&.outlined {
|
||||
background: none;
|
||||
border: 1px solid var(--form-element-secondary-color);
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
background: none;
|
||||
padding: 6px 10px;
|
||||
|
||||
i {
|
||||
|
@ -1,24 +1,15 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import cn from "classnames";
|
||||
|
||||
import "./button.less";
|
||||
|
||||
type ButtonVariantType = "outlined" | "solid" | "ghost";
|
||||
type ButtonThemeType = "primary" | "secondary";
|
||||
|
||||
interface ButtonProps {
|
||||
theme?: ButtonThemeType;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: ButtonVariantType;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
color?: string;
|
||||
style?: React.CSSProperties;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
@ -27,10 +18,8 @@ interface ButtonProps {
|
||||
|
||||
class Button extends React.Component<ButtonProps> {
|
||||
static defaultProps = {
|
||||
theme: "primary",
|
||||
variant: "solid",
|
||||
color: "",
|
||||
style: {},
|
||||
className: "primary",
|
||||
};
|
||||
|
||||
@boundMethod
|
||||
@ -41,31 +30,11 @@ class Button extends React.Component<ButtonProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
theme,
|
||||
children,
|
||||
disabled,
|
||||
variant,
|
||||
color,
|
||||
style,
|
||||
autoFocus,
|
||||
termInline,
|
||||
className,
|
||||
} = this.props;
|
||||
const { leftIcon, rightIcon, children, disabled, style, autoFocus, termInline, className } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"wave-button",
|
||||
theme,
|
||||
variant,
|
||||
color,
|
||||
{ disabled: disabled },
|
||||
{ "term-inline": termInline },
|
||||
className
|
||||
)}
|
||||
className={cn("wave-button", { disabled }, { "term-inline": termInline }, className)}
|
||||
onClick={this.handleClick}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
|
@ -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 };
|
@ -3,7 +3,6 @@ export { Checkbox } from "./checkbox";
|
||||
export { CmdStrCode } from "./cmdstrcode";
|
||||
export { renderCmdText } from "./cmdtext";
|
||||
export { Dropdown } from "./dropdown";
|
||||
export { IconButton } from "./iconbutton";
|
||||
export { InlineSettingsTextEdit } from "./inlinesettingstextedit";
|
||||
export { InputDecoration } from "./inputdecoration";
|
||||
export { LinkButton } from "./linkbutton";
|
||||
|
@ -6,7 +6,6 @@ import * as mobx from "mobx";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Button } from "./button";
|
||||
import { IconButton } from "./iconbutton";
|
||||
|
||||
import "./modal.less";
|
||||
|
||||
@ -19,9 +18,9 @@ const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, title }) => (
|
||||
<div className="wave-modal-header">
|
||||
{<div className="wave-modal-title">{title}</div>}
|
||||
<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>
|
||||
</IconButton>
|
||||
</Button>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
@ -36,11 +35,15 @@ interface ModalFooterProps {
|
||||
const ModalFooter: React.FC<ModalFooterProps> = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }) => (
|
||||
<div className="wave-modal-footer">
|
||||
{onCancel && (
|
||||
<Button theme="secondary" onClick={onCancel}>
|
||||
<Button className="secondary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
{onOk && <Button onClick={onOk}>{okLabel}</Button>}
|
||||
{onOk && (
|
||||
<Button className="primary" onClick={onOk}>
|
||||
{okLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import cn from "classnames";
|
||||
import { GlobalModel, GlobalCommandRunner } from "@/models";
|
||||
import { GlobalCommandRunner, SidebarModel } from "@/models";
|
||||
import { MagicLayout } from "@/app/magiclayout";
|
||||
|
||||
import "./resizablesidebar.less";
|
||||
@ -14,6 +14,7 @@ import "./resizablesidebar.less";
|
||||
interface ResizableSidebarProps {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
position: "left" | "right";
|
||||
model: SidebarModel;
|
||||
enableSnap?: boolean;
|
||||
className?: string;
|
||||
children?: (toggleCollapsed: () => void) => React.ReactNode;
|
||||
@ -32,7 +33,7 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
|
||||
startResizing(event: React.MouseEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const { parentRef, position } = this.props;
|
||||
const { parentRef, position, model } = this.props;
|
||||
const parentRect = parentRef.current?.getBoundingClientRect();
|
||||
|
||||
if (!parentRect) return;
|
||||
@ -43,17 +44,16 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
|
||||
this.startX = event.clientX - parentRect.left;
|
||||
}
|
||||
|
||||
const mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
const collapsed = mainSidebarModel.getCollapsed();
|
||||
const collapsed = model.getCollapsed();
|
||||
|
||||
this.resizeStartWidth = mainSidebarModel.getWidth();
|
||||
this.resizeStartWidth = model.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);
|
||||
model.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed);
|
||||
model.isDragging.set(true);
|
||||
})();
|
||||
}
|
||||
|
||||
@ -61,11 +61,10 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
|
||||
onMouseMove(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const { parentRef, enableSnap, position } = this.props;
|
||||
const { parentRef, enableSnap, position, model } = this.props;
|
||||
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;
|
||||
|
||||
@ -100,34 +99,27 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
|
||||
|
||||
if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") {
|
||||
newWidth = snapPoint;
|
||||
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
|
||||
model.setTempWidthAndTempCollapsed(newWidth, false);
|
||||
} else if (newWidth + dragResistance < snapPoint && dragDirection == "-") {
|
||||
newWidth = minWidth;
|
||||
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true);
|
||||
model.setTempWidthAndTempCollapsed(newWidth, true);
|
||||
} else if (newWidth > snapPoint) {
|
||||
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
|
||||
model.setTempWidthAndTempCollapsed(newWidth, false);
|
||||
}
|
||||
} else {
|
||||
if (newWidth <= MagicLayout.MainSidebarMinWidth) {
|
||||
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true);
|
||||
model.setTempWidthAndTempCollapsed(newWidth, true);
|
||||
} else {
|
||||
mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false);
|
||||
model.setTempWidthAndTempCollapsed(newWidth, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
stopResizing() {
|
||||
let mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
const { model } = this.props;
|
||||
|
||||
GlobalCommandRunner.clientSetSidebar(
|
||||
mainSidebarModel.tempWidth.get(),
|
||||
mainSidebarModel.tempCollapsed.get()
|
||||
).finally(() => {
|
||||
mobx.action(() => {
|
||||
mainSidebarModel.isDragging.set(false);
|
||||
})();
|
||||
});
|
||||
model.saveState(model.tempWidth.get(), model.tempCollapsed.get());
|
||||
|
||||
document.removeEventListener("mousemove", this.onMouseMove);
|
||||
document.removeEventListener("mouseup", this.stopResizing);
|
||||
@ -136,19 +128,18 @@ class ResizableSidebar extends React.Component<ResizableSidebarProps> {
|
||||
|
||||
@boundMethod
|
||||
toggleCollapsed() {
|
||||
const mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
const { model } = this.props;
|
||||
|
||||
const tempCollapsed = mainSidebarModel.getCollapsed();
|
||||
const width = mainSidebarModel.getWidth(true);
|
||||
mainSidebarModel.setTempWidthAndTempCollapsed(width, !tempCollapsed);
|
||||
GlobalCommandRunner.clientSetSidebar(width, !tempCollapsed);
|
||||
const tempCollapsed = model.getCollapsed();
|
||||
const width = model.getWidth(true);
|
||||
model.setTempWidthAndTempCollapsed(width, !tempCollapsed);
|
||||
model.saveState(width, !tempCollapsed);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, children } = this.props;
|
||||
const mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
const width = mainSidebarModel.getWidth();
|
||||
const isCollapsed = mainSidebarModel.getCollapsed();
|
||||
const { className, children, model } = this.props;
|
||||
const width = model.getWidth();
|
||||
const isCollapsed = model.getCollapsed();
|
||||
|
||||
return (
|
||||
<div className={cn("sidebar", className, { collapsed: isCollapsed })} style={{ width, minWidth: width }}>
|
||||
|
@ -54,7 +54,7 @@ class AlertModal extends React.Component<{}, {}> {
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<If condition={isConfirm}>
|
||||
<Button theme="secondary" onClick={this.closeModal}>
|
||||
<Button className="secondary" onClick={this.closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button autoFocus={true} onClick={this.handleOK}>
|
||||
|
@ -31,7 +31,7 @@ class ClientStopModal extends React.Component<{}, {}> {
|
||||
</If>
|
||||
<div>
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="secondary"
|
||||
onClick={this.refreshClient}
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-rotate"></i>}
|
||||
>
|
||||
|
@ -75,7 +75,7 @@ class DisconnectedModal extends React.Component<{}, {}> {
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="secondary"
|
||||
onClick={this.tryReconnect}
|
||||
leftIcon={
|
||||
<span className="icon">
|
||||
@ -86,7 +86,7 @@ class DisconnectedModal extends React.Component<{}, {}> {
|
||||
Try Reconnect
|
||||
</Button>
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="secondary"
|
||||
onClick={this.restartServer}
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-triangle-exclamation"></i>}
|
||||
>
|
||||
|
@ -162,42 +162,42 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
renderHeaderBtns(remote: RemoteType): React.ReactNode {
|
||||
let buttons: React.ReactNode[] = [];
|
||||
const disconnectButton = (
|
||||
<Button theme="secondary" onClick={() => this.disconnectRemote(remote.remoteid)}>
|
||||
<Button className="secondary" onClick={() => this.disconnectRemote(remote.remoteid)}>
|
||||
Disconnect Now
|
||||
</Button>
|
||||
);
|
||||
const connectButton = (
|
||||
<Button theme="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
|
||||
<Button className="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
|
||||
Connect Now
|
||||
</Button>
|
||||
);
|
||||
const tryReconnectButton = (
|
||||
<Button theme="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
|
||||
<Button className="secondary" onClick={() => this.connectRemote(remote.remoteid)}>
|
||||
Try Reconnect
|
||||
</Button>
|
||||
);
|
||||
let updateAuthButton = (
|
||||
<Button theme="secondary" onClick={() => this.openEditModal()}>
|
||||
<Button className="secondary" onClick={() => this.openEditModal()}>
|
||||
Edit
|
||||
</Button>
|
||||
);
|
||||
let cancelInstallButton = (
|
||||
<Button theme="secondary" onClick={() => this.cancelInstall(remote.remoteid)}>
|
||||
<Button className="secondary" onClick={() => this.cancelInstall(remote.remoteid)}>
|
||||
Cancel Install
|
||||
</Button>
|
||||
);
|
||||
let installNowButton = (
|
||||
<Button theme="secondary" onClick={() => this.installRemote(remote.remoteid)}>
|
||||
<Button className="secondary" onClick={() => this.installRemote(remote.remoteid)}>
|
||||
Install Now
|
||||
</Button>
|
||||
);
|
||||
let archiveButton = (
|
||||
<Button theme="secondary" onClick={() => this.clickArchive()}>
|
||||
<Button className="secondary" onClick={() => this.clickArchive()}>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
const reinstallButton = (
|
||||
<Button theme="secondary" onClick={this.clickReinstall}>
|
||||
<Button className="secondary" onClick={this.clickReinstall}>
|
||||
Reinstall
|
||||
</Button>
|
||||
);
|
||||
@ -207,7 +207,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
}
|
||||
if (remote.sshconfigsrc == "sshconfig-import") {
|
||||
archiveButton = (
|
||||
<Button theme="secondary" onClick={() => this.clickArchive()}>
|
||||
<Button className="secondary" onClick={() => this.clickArchive()}>
|
||||
Delete
|
||||
<Tooltip
|
||||
message={
|
||||
@ -383,7 +383,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
|
||||
</div>
|
||||
<div className="wave-modal-footer">
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="secondary"
|
||||
disabled={selectedRemoteStatus == "connecting"}
|
||||
onClick={this.handleClose}
|
||||
>
|
||||
|
@ -180,14 +180,14 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
|
||||
</table>
|
||||
<footer>
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="secondary"
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-plus"></i>}
|
||||
onClick={this.handleAddConnection}
|
||||
>
|
||||
New Connection
|
||||
</Button>
|
||||
<Button
|
||||
theme="secondary"
|
||||
className="secondary"
|
||||
leftIcon={<i className="fa-sharp fa-solid fa-fw fa-file-import"></i>}
|
||||
onClick={this.handleImportSshConfig}
|
||||
>
|
||||
|
@ -16,11 +16,17 @@ let MagicLayout = {
|
||||
ScreenSidebarMinWidth: 200,
|
||||
ScreenSidebarHeaderHeight: 26,
|
||||
|
||||
MainSidebarMinWidth: 75,
|
||||
MainSidebarMinWidth: 0,
|
||||
MainSidebarMaxWidth: 300,
|
||||
MainSidebarSnapThreshold: 90,
|
||||
MainSidebarSnapThreshold: 165,
|
||||
MainSidebarDragResistance: 50,
|
||||
MainSidebarDefaultWidth: 240,
|
||||
|
||||
RightSidebarMinWidth: 0,
|
||||
RightSidebarMaxWidth: 300,
|
||||
RightSidebarSnapThreshold: 90,
|
||||
RightSidebarDragResistance: 50,
|
||||
RightSidebarDefaultWidth: 240,
|
||||
};
|
||||
|
||||
let m = MagicLayout;
|
||||
|
@ -21,7 +21,7 @@ import { isBlank, openLink } from "@/util/util";
|
||||
import { ResizableSidebar } from "@/common/elements";
|
||||
import * as appconst from "@/app/appconst";
|
||||
|
||||
import "./sidebar.less";
|
||||
import "./main.less";
|
||||
import { ActionsIcon, CenteredIcon, FrontIcon, StatusIndicator } from "@/common/icons/icons";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
@ -251,6 +251,7 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
|
||||
render() {
|
||||
return (
|
||||
<ResizableSidebar
|
||||
model={GlobalModel.mainSidebarModel}
|
||||
className="main-sidebar"
|
||||
position="left"
|
||||
enableSnap={true}
|
28
src/app/sidebar/right.less
Normal file
28
src/app/sidebar/right.less
Normal 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
46
src/app/sidebar/right.tsx
Normal 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 };
|
@ -325,7 +325,7 @@ class ScreenSidebar extends React.Component<{ screen: Screen; width: string }, {
|
||||
<br />
|
||||
</div>
|
||||
<div onClick={this.sidebarClose} className="close-button-container">
|
||||
<Button theme="secondary" onClick={this.sidebarClose}>
|
||||
<Button className="secondary" onClick={this.sidebarClose}>
|
||||
Close Sidebar
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -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.
|
||||
// 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);
|
||||
}
|
||||
|
||||
#main:not(.platform-darwin).sidebar-collapsed .screen-tabs-container {
|
||||
#main:not(.platform-darwin).mainsidebar-collapsed .screen-tabs-container {
|
||||
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);
|
||||
}
|
||||
|
@ -39,16 +39,12 @@ class WorkspaceView extends React.Component<{}, {}> {
|
||||
let isHidden = GlobalModel.activeMainView.get() != "session";
|
||||
let mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
|
||||
// Has to calc manually because when tabs overflow, the width of the session view is increased for some reason causing inconsistent width.
|
||||
// 6px is the right margin of session view.
|
||||
let width = window.innerWidth - 6 - mainSidebarModel.getWidth();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mainview", "session-view", { "is-hidden": isHidden })}
|
||||
data-sessionid={session.sessionId}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
width: `${window.innerWidth - mainSidebarModel.getWidth()}px`,
|
||||
}}
|
||||
>
|
||||
<ScreenTabs key={"tabs-" + session.sessionId} session={session} />
|
||||
|
@ -399,9 +399,14 @@ class CommandRunner {
|
||||
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" };
|
||||
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) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
export type { SidebarModel } from "./sidebar";
|
||||
export * from "./global";
|
||||
export * from "./model";
|
||||
export { BookmarksModel } from "./bookmarks";
|
||||
@ -6,6 +7,7 @@ export { Cmd } from "./cmd";
|
||||
export { ConnectionsViewModel } from "./connectionsview";
|
||||
export { InputModel } from "./input";
|
||||
export { MainSidebarModel } from "./mainsidebar";
|
||||
export { RightSidebarModel } from "./rightsidebar";
|
||||
export { ModalsModel } from "./modals";
|
||||
export { PluginsModel } from "./plugins";
|
||||
export { RemotesModel } from "./remotes";
|
||||
|
@ -4,6 +4,7 @@
|
||||
import * as mobx from "mobx";
|
||||
import { MagicLayout } from "@/app/magiclayout";
|
||||
import { Model } from "./model";
|
||||
import { GlobalCommandRunner } from "@/models";
|
||||
|
||||
class MainSidebarModel {
|
||||
globalModel: Model = null;
|
||||
@ -80,6 +81,14 @@ class MainSidebarModel {
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
saveState(width: number, collapsed: boolean): void {
|
||||
GlobalCommandRunner.clientSetMainSidebar(width, collapsed).finally(() => {
|
||||
mobx.action(() => {
|
||||
this.isDragging.set(false);
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { MainSidebarModel };
|
||||
|
@ -13,7 +13,6 @@ import {
|
||||
isModKeyPress,
|
||||
isBlank,
|
||||
} from "@/util/util";
|
||||
import { loadFonts } from "@/util/fontutil";
|
||||
import { loadTheme } from "@/util/themeutil";
|
||||
import { WSControl } from "./ws";
|
||||
import { cmdStatusIsRunning } from "@/app/line/lineutil";
|
||||
@ -31,6 +30,7 @@ import { ClientSettingsViewModel } from "./clientsettingsview";
|
||||
import { RemotesModel } from "./remotes";
|
||||
import { ModalsModel } from "./modals";
|
||||
import { MainSidebarModel } from "./mainsidebar";
|
||||
import { RightSidebarModel } from "./rightsidebar";
|
||||
import { Screen } from "./screen";
|
||||
import { Cmd } from "./cmd";
|
||||
import { GlobalCommandRunner } from "./global";
|
||||
@ -117,6 +117,7 @@ class Model {
|
||||
clientSettingsViewModel: ClientSettingsViewModel;
|
||||
modalsModel: ModalsModel;
|
||||
mainSidebarModel: MainSidebarModel;
|
||||
rightSidebarModel: RightSidebarModel;
|
||||
clientData: OV<ClientDataType> = mobx.observable.box(null, {
|
||||
name: "clientData",
|
||||
});
|
||||
@ -156,6 +157,7 @@ class Model {
|
||||
this.remotesModel = new RemotesModel(this);
|
||||
this.modalsModel = new ModalsModel();
|
||||
this.mainSidebarModel = new MainSidebarModel(this);
|
||||
this.rightSidebarModel = new RightSidebarModel(this);
|
||||
const isWaveSrvRunning = getApi().getWaveSrvStatus();
|
||||
this.waveSrvRunning = mobx.observable.box(isWaveSrvRunning, {
|
||||
name: "model-wavesrv-running",
|
||||
|
100
src/models/rightsidebar.ts
Normal file
100
src/models/rightsidebar.ts
Normal 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
13
src/models/sidebar.ts
Normal 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;
|
||||
}
|
@ -433,7 +433,7 @@ class SourceCodeRenderer extends React.Component<
|
||||
return (
|
||||
<>
|
||||
<If condition={isPreviewerAvailable}>
|
||||
<Button theme="primary" termInline={true}>
|
||||
<Button className="primary" termInline={true}>
|
||||
<div onClick={this.togglePreview} className={`preview`}>
|
||||
{`${showPreview ? "hide" : "show"} preview (`}
|
||||
{renderCmdText("P")}
|
||||
@ -449,7 +449,7 @@ class SourceCodeRenderer extends React.Component<
|
||||
))}
|
||||
</select>
|
||||
<If condition={allowEditing}>
|
||||
<Button theme="primary" termInline={true}>
|
||||
<Button className="primary" termInline={true}>
|
||||
<div onClick={() => this.doSave()}>
|
||||
{`save (`}
|
||||
{renderCmdText("S")}
|
||||
|
4
src/types/custom.d.ts
vendored
4
src/types/custom.d.ts
vendored
@ -579,6 +579,10 @@ declare global {
|
||||
collapsed: boolean;
|
||||
width: number;
|
||||
};
|
||||
rightsidebar: {
|
||||
collapsed: boolean;
|
||||
width: number;
|
||||
};
|
||||
globalshortcut: string;
|
||||
globalshortcutenabled: boolean;
|
||||
};
|
||||
|
@ -234,7 +234,8 @@ func init() {
|
||||
registerCmdFn("client:notifyupdatewriter", ClientNotifyUpdateWriterCommand)
|
||||
registerCmdFn("client:accepttos", ClientAcceptTosCommand)
|
||||
registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand)
|
||||
registerCmdFn("client:setsidebar", ClientSetSidebarCommand)
|
||||
registerCmdFn("client:setmainsidebar", ClientSetMainSidebarCommand)
|
||||
registerCmdFn("client:setrightsidebar", ClientSetRightSidebarCommand)
|
||||
registerCmdFn("client:setglobalshortcut", ClientSetGlobalShortcut)
|
||||
|
||||
registerCmdFn("sidebar:open", SidebarOpenCommand)
|
||||
@ -5271,7 +5272,7 @@ func ClientSetGlobalShortcut(ctx context.Context, pk *scpacket.FeCommandPacketTy
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(key) > MaxOpenAIAPITokenLen {
|
||||
return fmt.Errorf("invalid openai token, too long")
|
||||
|
@ -294,6 +294,7 @@ type ClientOptsType struct {
|
||||
AcceptedTos int64 `json:"acceptedtos,omitempty"`
|
||||
ConfirmFlags map[string]bool `json:"confirmflags,omitempty"`
|
||||
MainSidebar *SidebarValueType `json:"mainsidebar,omitempty"`
|
||||
RightSidebar *SidebarValueType `json:"rightsidebar,omitempty"`
|
||||
GlobalShortcut string `json:"globalshortcut,omitempty"`
|
||||
GlobalShortcutEnabled bool `json:"globalshortcutenabled,omitempty"`
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user