Add status indicators to workspace items in the sidebar (#245)

* save work

* refactor end-icon and actions-icon into separate components

* reverting change part 1

* fix

* separate out workspace and tab formatting more

* save work

* Got it working!

* fix scrollbar but hide it so that the formatting doesn't jump when hovering

* revert some changes, replace some svgs with fontawesome

* remove listitem

* remove log
This commit is contained in:
Evan Simkowitz
2024-01-25 13:31:20 -08:00
committed by GitHub
parent 018bb14b6a
commit 34ec4ff39f
11 changed files with 310 additions and 284 deletions
+13 -9
View File
@@ -99,9 +99,6 @@ body a {
body code {
font-family: @terminal-font;
}
body code {
background-color: transparent;
}
@@ -123,11 +120,19 @@ svg.icon {
}
.hideScrollbarUntillHover {
overflow: hidden;
&:hover,
&:focus,
&:focus-within {
overflow: auto;
overflow: scroll;
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track {
display: none;
}
&::-webkit-scrollbar-corner {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
}
@@ -647,7 +652,6 @@ a.a-block {
margin-right: 10px;
border-radius: 8px;
border: 1px solid rgba(241, 246, 243, 0.08);
background: rgba(13, 13, 13, 0.85);
.header {
margin: 24px 18px;
+27 -8
View File
@@ -609,7 +609,6 @@
.wave-dropdown {
position: relative;
background-color: transparent;
height: 44px;
min-width: 150px;
width: 100%;
@@ -715,9 +714,7 @@
top: 100%;
left: 0;
right: 0;
z-index: 0;
margin-top: 2px;
padding: 0;
max-height: 200px;
overflow-y: auto;
padding: 6px;
@@ -775,7 +772,6 @@
min-width: 412px;
gap: 6px;
border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15));
border-radius: 6px;
background: var(--element-hover-2, rgba(255, 255, 255, 0.06));
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5),
0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset;
@@ -931,7 +927,6 @@
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
@@ -1157,10 +1152,34 @@
}
}
.status-indicator {
position: relative;
top: 1px;
.front-icon {
margin-right: 5px;
.svg-icon svg {
width: 14px;
height: 14px;
}
font-size: 16px;
}
.positional-icon-inner {
& > div,i {
text-align: center;
align-items: center;
vertical-align: middle;
width: 20px;
margin: auto auto;
}
}
.actions {
.icon {
font-size: 15px;
padding-top: 2.5px;
margin-bottom: -2.5px;
}
}
.status-indicator {
&.error {
color: @term-red;
}
+54 -6
View File
@@ -117,7 +117,7 @@ class Checkbox extends React.Component<
constructor(props) {
super(props);
this.state = {
checkedInternal: this.props.checked !== undefined ? this.props.checked : Boolean(this.props.defaultChecked),
checkedInternal: this.props.checked ?? Boolean(this.props.defaultChecked),
};
this.generatedId = `checkbox-${Checkbox.idCounter++}`;
}
@@ -287,15 +287,15 @@ class Button extends React.Component<ButtonProps> {
}
render() {
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style } = this.props;
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } = this.props;
return (
<button
className={cn("wave-button", theme, variant, color, { disabled: disabled })}
className={cn("wave-button", theme, variant, color, { disabled: disabled }, className)}
onClick={this.handleClick}
disabled={disabled}
style={style}
autoFocus={this.props.autoFocus}
autoFocus={autoFocus}
>
{leftIcon && <span className="icon-left">{leftIcon}</span>}
{children}
@@ -868,7 +868,7 @@ class Markdown extends React.Component<
if (codeSelect) {
return <CodeBlockMarkdown codeSelectSelectedIndex={codeSelectIndex}>{props.children}</CodeBlockMarkdown>;
} else {
let clickHandler = (e: React.MouseEvent<HTMLElement>) => {
const clickHandler = (e: React.MouseEvent<HTMLElement>) => {
let blockText = (e.target as HTMLElement).innerText;
if (blockText) {
blockText = blockText.replace(/\n$/, ""); // remove trailing newline
@@ -896,7 +896,9 @@ class Markdown extends React.Component<
};
return (
<div className={cn("markdown content", this.props.extraClassName)} style={this.props.style}>
<ReactMarkdown children={text} remarkPlugins={[remarkGfm]} components={markdownComponents} />
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{text}
</ReactMarkdown>
</div>
);
}
@@ -1239,6 +1241,49 @@ class Modal extends React.Component<ModalProps> {
}
}
interface PositionalIconProps {
children?: React.ReactNode;
}
class FrontIcon extends React.Component<PositionalIconProps> {
render() {
return (
<div className="front-icon positional-icon">
<div className="positional-icon-inner">
{this.props.children}
</div>
</div>
);
}
}
class EndIcon extends React.Component<PositionalIconProps> {
render() {
return (
<div className="end-icon positional-icon">
<div className="positional-icon-inner">
{this.props.children}
</div>
</div>
);
}
}
interface ActionsIconProps {
onClick: React.MouseEventHandler<HTMLDivElement>;
}
class ActionsIcon extends React.Component<ActionsIconProps> {
render() {
return (
<div onClick={this.props.onClick} title="Actions" className="actions">
<div className="icon hoverEffect fa-sharp fa-solid fa-1x fa-ellipsis-vertical"></div>
</div>
);
}
}
interface StatusIndicatorProps {
level: StatusIndicatorLevel;
className?: string;
@@ -1313,6 +1358,9 @@ export {
LinkButton,
Status,
Modal,
FrontIcon,
EndIcon,
ActionsIcon,
StatusIndicator,
ShowWaveShellInstallPrompt,
};
+39 -19
View File
@@ -23,14 +23,19 @@
&.collapsed {
width: 6em;
min-width: 6em;
.arrow-container, .collapse-button {
.arrow-container,
.collapse-button {
transform: rotate(180deg);
margin-top: 20px;
}
.contents {
margin-top: 26px;
.top, .workspaces-item, .middle, .bottom, .separator {
.top,
.workspaces-item,
.middle,
.bottom,
.separator {
pointer-events: none;
opacity: 0;
visibility: hidden;
@@ -82,7 +87,7 @@
.spacer {
flex-grow: 1;
}
img {
width: 100px;
}
@@ -90,7 +95,7 @@
.collapse-button {
transition: transform 0.3s ease-in-out;
margin-right: 14px;
svg {
margin-top: 3px;
width: 1.5em;
@@ -148,12 +153,16 @@
margin: 0 6px;
border-radius: 4px;
opacity: 1;
visibility: visible;
transition: opacity 0.1s ease-in-out, visibility 0.1s step-end;
.sessionName {
width: 12rem;
display: inline-block;
vertical-align: middle;
width: inherit;
max-width: inherit;
min-width: inherit;
display: flex;
flex-direction: row;
align-items: center;
.item-contents {
flex-grow: 1;
}
.icon {
margin: -2px 8px 0px 4px;
@@ -161,7 +170,6 @@
height: 16px;
display: inline-block;
vertical-align: middle;
border-radius: 50%;
}
.actions.icon {
margin-left: 8px;
@@ -169,20 +177,25 @@
.hotkey {
float: right;
margin-right: 6px;
visibility: hidden;
display: none;
letter-spacing: 6px;
}
.disabled .hotkey {
display: none;
}
&:hover .hotkey {
visibility: visible;
}
.actions {
visibility: hidden;
display: none;
}
&:hover .actions {
visibility: visible;
&:hover {
.hotkey {
display: block;
}
.actions {
display: block;
}
.status-indicator {
display: none;
}
}
.add_workspace {
float: right;
@@ -190,13 +203,20 @@
height: 1.5rem;
padding: 2px;
margin-right: 6px;
border-radius: 50%;
transition: transform 0.3s ease-in-out;
vertical-align: middle;
svg {
fill: @base-color;
}
}
.front-icon {
font-size: 15px;
}
.fa-discord {
font-size: 13px;
}
}
.menu-label {
+83 -84
View File
@@ -12,27 +12,45 @@ import { If } from "tsx-control-statements/components";
import { compareLoose } from "semver";
import { ReactComponent as LeftChevronIcon } from "../assets/icons/chevron_left.svg";
import { ReactComponent as HelpIcon } from "../assets/icons/help.svg";
import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg";
import { ReactComponent as DiscordIcon } from "../assets/icons/discord.svg";
import { ReactComponent as HistoryIcon } from "../assets/icons/history.svg";
import { ReactComponent as AppsIcon } from "../assets/icons/apps.svg";
import { ReactComponent as ConnectionsIcon } from "../assets/icons/connections.svg";
import { ReactComponent as WorkspacesIcon } from "../assets/icons/workspaces.svg";
import { ReactComponent as AddIcon } from "../assets/icons/add.svg";
import { ReactComponent as ActionsIcon } from "../assets/icons/tab/actions.svg";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model";
import { sortAndFilterRemotes, isBlank, openLink } from "../../util/util";
import { isBlank, openLink } from "../../util/util";
import * as constants from "../appconst";
import "./sidebar.less";
import { ActionsIcon, EndIcon, FrontIcon, StatusIndicator } from "../common/common";
dayjs.extend(localizedFormat);
type OV<V> = mobx.IObservableValue<V>;
class SideBarItem extends React.Component<{
frontIcon: React.ReactNode;
contents: React.ReactNode | string;
endIcon?: React.ReactNode[];
className?: string;
key?: React.Key;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}> {
render() {
return (
<div
key={this.props.key}
className={cn("item", "unselectable", "hoverEffect", this.props.className)}
onClick={this.props.onClick}
>
<FrontIcon>{this.props.frontIcon}</FrontIcon>
<div className="item-contents truncate">{this.props.contents}</div>
<EndIcon>{this.props.endIcon}</EndIcon>
</div>
);
}
}
@mobxReact.observer
class MainSideBar extends React.Component<{}, {}> {
collapsed: mobx.IObservableValue<boolean> = mobx.observable.box(false);
@@ -99,7 +117,6 @@ class MainSideBar extends React.Component<{}, {}> {
@boundMethod
handlePlaybookClick(): void {
console.log("playbook click");
return;
}
@boundMethod
@@ -159,42 +176,27 @@ class MainSideBar extends React.Component<{}, {}> {
}
return sessionList.map((session, index) => {
const isActive = GlobalModel.activeMainView.get() == "session" && activeSessionId == session.sessionId;
const sessionScreens = GlobalModel.getSessionScreens(session.sessionId);
const sessionIndicator = Math.max(...sessionScreens.map((screen) => screen.statusIndicator.get()));
return (
<div
<SideBarItem
key={index}
className={`item hoverEffect ${isActive ? "active" : ""}`}
className={`${isActive ? "active" : ""}`}
frontIcon={<span className="index">{index + 1}</span>}
contents={session.name.get()}
endIcon={[
<StatusIndicator level={sessionIndicator} />,
<ActionsIcon
onClick={(e) => this.openSessionSettings(e, session)}
/>,
]}
onClick={() => this.handleSessionClick(session.sessionId)}
>
<span className="index">{index + 1}</span>
<span className="truncate sessionName">{session.name.get()}</span>
<ActionsIcon
className="icon hoverEffect actions"
onClick={(e) => this.openSessionSettings(e, session)}
/>
</div>
/>
);
});
}
render() {
let model = GlobalModel;
let activeSessionId = model.activeSessionId.get();
let activeScreen = model.getActiveScreen();
let activeRemoteId: string = null;
if (activeScreen != null) {
let rptr = activeScreen.curRemote.get();
if (rptr != null && !isBlank(rptr.remoteid)) {
activeRemoteId = rptr.remoteid;
}
}
let remotes = model.remotes ?? [];
remotes = sortAndFilterRemotes(remotes);
let sessionList = [];
for (let session of model.sessionList) {
if (!session.archived.get() || session.sessionId == activeSessionId) {
sessionList.push(session);
}
}
let isCollapsed = this.collapsed.get();
let clientData = GlobalModel.clientData.get();
let needsUpdate = false;
@@ -223,65 +225,62 @@ class MainSideBar extends React.Component<{}, {}> {
</div>
<div className="separator" />
<div className="top">
<div className="item hoverEffect unselectable" onClick={this.handleHistoryClick}>
<HistoryIcon className="icon" />
History
<span className="hotkey">&#x2318;H</span>
</div>
{/* <div className="item hoverEffect unselectable" onClick={this.handleBookmarksClick}>
<FavoritesIcon className="icon" />
Favorites
<span className="hotkey">&#x2318;B</span>
</div> */}
<div className="item hoverEffect unselectable" onClick={this.handleConnectionsClick}>
<ConnectionsIcon className="icon" />
Connections
</div>
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-clock-rotate-left icon" />}
contents="History"
endIcon={[<span className="hotkey">&#x2318;H</span>]}
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" />
<div className="item workspaces-item unselectable">
<WorkspacesIcon className="icon" />
Workspaces
<div className="add_workspace hoverEffect" onClick={this.handleNewSession}>
<AddIcon />
</div>
</div>
<SideBarItem
frontIcon={<WorkspacesIcon className="icon" />}
contents="Workspaces"
endIcon={[
<div className="add_workspace hoverEffect" onClick={this.handleNewSession}>
<AddIcon />
</div>,
]}
/>
<div className="middle hideScrollbarUntillHover">{this.getSessions()}</div>
<div className="bottom">
<If condition={needsUpdate}>
<div
className="item hoverEffect unselectable updateBanner"
<SideBarItem
className="updateBanner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Update Available"
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
>
<i className="fa-sharp fa-regular fa-circle-up icon" />
Update Available
</div>
/>
</If>
<If condition={GlobalModel.isDev}>
<div className="item hoverEffect unselectable" onClick={this.handlePluginsClick}>
<AppsIcon className="icon" />
Apps
<span className="hotkey">&#x2318;A</span>
</div>
<SideBarItem
frontIcon={<AppsIcon className="icon" />}
contents="Apps"
onClick={this.handlePluginsClick}
endIcon={[<span className="hotkey">&#x2318;A</span>]}
/>
</If>
<div className="item hoverEffect unselectable" onClick={this.handleSettingsClick}>
<SettingsIcon className="icon" />
Settings
</div>
<div
className="item hoverEffect unselectable"
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-gear icon"/>}
contents="Settings"
onClick={this.handleSettingsClick}
/>
<SideBarItem
frontIcon={<i className="fa-sharp fa-regular fa-circle-question icon" />}
contents="Documentation"
onClick={() => openLink("https://docs.waveterm.dev")}
>
<HelpIcon className="icon" />
Documentation
</div>
<div
className="item hoverEffect unselectable"
/>
<SideBarItem
frontIcon={<i className="fa-brands fa-discord icon" />}
contents="Discord"
onClick={() => openLink("https://discord.gg/XfvZ334gwU")}
>
<DiscordIcon className="icon discord" />
Discord
</div>
/>
</div>
</div>
</div>
+6 -14
View File
@@ -7,7 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model";
import { StatusIndicator, renderCmdText } from "../../common/common";
import { ActionsIcon, EndIcon, StatusIndicator, renderCmdText } from "../../common/common";
import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg";
import * as constants from "../../appconst";
import { Reorder } from "framer-motion";
@@ -81,12 +81,6 @@ class ScreenTab extends React.Component<
if (index + 1 <= 9) {
tabIndex = <div className="tab-index">{renderCmdText(String(index + 1))}</div>;
}
let settings = (
<div onClick={(e) => this.openScreenSettings(e, screen)} title="Actions" className="tab-gear">
<div className="icon hoverEffect fa-sharp fa-solid fa-ellipsis-vertical"></div>
</div>
);
let archived = screen.archived.get() ? (
<i title="archived" className="fa-sharp fa-solid fa-box-archive" />
) : null;
@@ -123,13 +117,11 @@ class ScreenTab extends React.Component<
{webShared}
{screen.name.get()}
</div>
<div className="end-icon">
<div className="end-icon-inner">
<StatusIndicator level={statusIndicatorLevel}/>
{tabIndex}
{settings}
</div>
</div>
<EndIcon>
<StatusIndicator level={statusIndicatorLevel}/>
{tabIndex}
<ActionsIcon onClick={(e) => this.openScreenSettings(e, screen)} />
</EndIcon>
</Reorder.Item>
);
}
+5 -33
View File
@@ -27,10 +27,6 @@
rgba(88, 193, 66, 0) 86.79%
);
}
.icon i {
color: @tab-green;
}
}
&.color-orange {
@@ -242,14 +238,7 @@
.screen-tabs-container-inner {
overflow-x: scroll;
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track {
display: none;
}
&:hover::-webkit-scrollbar-thumb {
display: block;
}
}
.screen-tabs {
@@ -295,38 +284,22 @@
// Only one of these will be visible at a time
.end-icon {
// This makes the calculations below easier since we don't need to account for the right margin on the parent tab.
// This adjusts the position of the icon to account for the default 8px margin on the parent. We want the positional calculations for this icon to assume it is flush with the edge of the screen tab.
margin: 0 -8px 0 0;
.end-icon-inner {
& > div {
text-align: center;
align-items: center;
& > * {
margin: auto auto;
}
width: 20px;
}
}
.status-indicator {
display: block;
// The status indicator is a little shorter than the text; this raises it up a bit so it's more centered vertically
padding-bottom: 1px;
margin-top: -1px;
}
.tab-gear {
.actions {
display: none;
.icon {
border-radius: 50%;
}
}
.tab-index {
display: none;
font-size: 0.9em;
font-size: 12.5px;
}
}
&:hover {
.tab-gear {
.actions {
display: block;
}
}
@@ -357,7 +330,6 @@
height: 37px;
.icon {
height: 2rem;
height: 2rem;
border-radius: 50%;
padding: 0.4em;
+1 -2
View File
@@ -9,7 +9,6 @@ import { boundMethod } from "autobind-decorator";
import { For } from "tsx-control-statements/components";
import { GlobalModel, GlobalCommandRunner, Session, Screen } from "../../../model/model";
import { ReactComponent as AddIcon } from "../../assets/icons/add.svg";
import * as constants from "../../appconst";
import { Reorder } from "framer-motion";
import { ScreenTab } from "./tab";
@@ -181,7 +180,7 @@ class ScreenTabs extends React.Component<
return (
<div className="screen-tabs-container">
{/* Inner container ensures that hovering over the scrollbar doesn't trigger the hover effect on the tabs. This prevents weird flickering of the icons when the mouse is moved over the scrollbar. */}
<div className="screen-tabs-container-inner">
<div className="screen-tabs-container-inner hideScrollbarUntillHover">
<Reorder.Group
className="screen-tabs"
ref={this.tabsRef}
+65 -108
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -2974,6 +2974,7 @@ func SessionCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto
if err != nil {
return nil, err
}
update := &sstore.ModelUpdate{
ActiveSessionId: ritem.Id,
Info: &sstore.InfoMsgType{
@@ -2981,6 +2982,22 @@ func SessionCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto
TimeoutMs: 2000,
},
}
// Reset the status indicator for the new active screen
session, err := sstore.GetSessionById(ctx, ritem.Id)
if err != nil {
return nil, fmt.Errorf("cannot get session: %w", err)
}
if session == nil {
return nil, fmt.Errorf("session not found")
}
err = sstore.ResetStatusIndicator_Update(update, session.ActiveScreenId)
if err != nil {
log.Printf("error resetting status indicator: %v\n", err)
}
log.Printf("session command update: %v\n", update)
return update, nil
}
-1
View File
@@ -1474,7 +1474,6 @@ func SetReleaseInfo(ctx context.Context, releaseInfo ReleaseInfoType) error {
// Sets the in-memory status indicator for the given screenId to the given value and adds it to the ModelUpdate. By default, the active screen will be ignored when updating status. To force a status update for the active screen, set force=true.
func SetStatusIndicatorLevel_Update(ctx context.Context, update *ModelUpdate, screenId string, level StatusIndicatorLevel, force bool) error {
var newStatus StatusIndicatorLevel
if force {
// Force the update and set the new status to the given level, regardless of the current status or the active screen
ScreenMemSetIndicatorLevel(screenId, level)