Add indicator to tabs and workspaces to show when commands are running (#254)

* save

* not working yet but close

* logic is working, just need to do styling

* save work

* save

* save work

* ta da!

* fix line height

* format files

* remove running commands on hangup.  also don't allow numrunning to be less than 0

* remove < 0 check (safer without for concurrency)
This commit is contained in:
Evan Simkowitz
2024-01-28 13:47:36 -08:00
committed by GitHub
parent 32499f0421
commit dd3d52e47d
19 changed files with 202 additions and 82 deletions
+4 -4
View File
@@ -121,7 +121,7 @@ svg.icon {
.hideScrollbarUntillHover {
overflow: scroll;
&::-webkit-scrollbar-thumb,
&::-webkit-scrollbar-track {
display: none;
@@ -630,13 +630,13 @@ a.a-block {
.spin {
animation: infiniteRotate 2s linear infinite;
@keyframes infiniteRotate {
from {
transform:rotate(0deg);
transform: rotate(0deg);
}
to {
transform:rotate(360deg);
transform: rotate(360deg);
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="440" height="440" version="1.1" viewBox="0 0 116.42 116.42" xmlns="http://www.w3.org/2000/svg">
<path id="spinner" transform="matrix(1 -.0017046 0 1 0 0)" d="m111.01 58.273a52.939 52.939 0 0 1-52.939 52.939" fill="none" stroke="#000" stroke-width="9.0869"/>
<circle id="indicator" cx="58.208" cy="58.208" r="39.688" stroke-width=".26657"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

+1 -1
View File
@@ -405,7 +405,7 @@
padding: 6px 6px 6px 10px;
border-radius: 4px;
}
pre.selected {
outline: 2px solid @term-green;
}
+2 -1
View File
@@ -288,7 +288,8 @@ class Button extends React.Component<ButtonProps> {
}
render() {
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } = this.props;
const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } =
this.props;
return (
<button
+25 -7
View File
@@ -30,7 +30,10 @@
line-height: inherit;
align-items: center;
justify-content: center;
& > div,i,.svg-icon,span {
& > div,
i,
.svg-icon,
span {
width: 20px;
display: flex;
text-align: center;
@@ -46,13 +49,28 @@
}
.status-indicator {
.error {
color: @term-red;
svg {
width: 15px;
height: 15px;
}
.success {
color: @term-green;
#spinner,
#indicator {
visibility: hidden;
}
.output {
color: @term-white;
.spin #spinner {
visibility: visible;
stroke: @term-white;
}
&.error #indicator {
visibility: visible;
fill: @term-red;
}
&.success #indicator {
visibility: visible;
fill: @term-green;
}
&.output #indicator {
visibility: visible;
fill: @term-white;
}
}
+11 -9
View File
@@ -1,6 +1,7 @@
import React from "react";
import { StatusIndicatorLevel } from "../../../types/types";
import cn from "classnames";
import { ReactComponent as SpinnerIndicator } from "../../assets/icons/spinner-indicator.svg";
interface PositionalIconProps {
children?: React.ReactNode;
@@ -45,28 +46,29 @@ export class ActionsIcon extends React.Component<ActionsIconProps> {
interface StatusIndicatorProps {
level: StatusIndicatorLevel;
className?: string;
runningCommands?: boolean;
}
export class StatusIndicator extends React.Component<StatusIndicatorProps> {
render() {
const statusIndicatorLevel = this.props.level;
const { level, className, runningCommands } = this.props;
let statusIndicator = null;
if (statusIndicatorLevel != StatusIndicatorLevel.None) {
let statusIndicatorClass = null;
switch (statusIndicatorLevel) {
if (level != StatusIndicatorLevel.None || runningCommands) {
let levelClass = null;
switch (level) {
case StatusIndicatorLevel.Output:
statusIndicatorClass = "output";
levelClass = "output";
break;
case StatusIndicatorLevel.Success:
statusIndicatorClass = "success";
levelClass = "success";
break;
case StatusIndicatorLevel.Error:
statusIndicatorClass = "error";
levelClass = "error";
break;
}
statusIndicator = (
<CenteredIcon className={cn(this.props.className, "status-indicator")}>
<div className={cn(statusIndicatorClass, "fa-sharp", "fa-solid", "fa-circle-small")}></div>
<CenteredIcon className={cn(className, levelClass, "status-indicator")}>
<SpinnerIndicator className={runningCommands ? "spin" : null} />
</CenteredIcon>
);
}
+4
View File
@@ -206,6 +206,10 @@
font-size: 15px;
}
.end-icons {
line-height: normal;
}
.fa-discord {
font-size: 13px;
}
+6 -1
View File
@@ -187,13 +187,18 @@ class MainSideBar extends React.Component<{}, {}> {
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()));
const sessionRunningCommands = sessionScreens.some((screen) => screen.numRunningCmds.get() > 0);
return (
<SideBarItem
className={`${isActive ? "active" : ""}`}
frontIcon={<span className="index">{index + 1}</span>}
contents={session.name.get()}
endIcons={[
<StatusIndicator key="statusindicator" level={sessionIndicator} />,
<StatusIndicator
key="statusindicator"
level={sessionIndicator}
runningCommands={sessionRunningCommands}
/>,
<ActionsIcon key="actions" onClick={(e) => this.openSessionSettings(e, session)} />,
]}
onClick={() => this.handleSessionClick(session.sessionId)}
+9 -11
View File
@@ -82,7 +82,7 @@
.cmd-input-filter {
opacity: 0.5;
&:hover {
opacity: 1.0;
opacity: 1;
}
.avatar {
@@ -198,14 +198,14 @@
opacity: 0.5;
&:hover {
opacity: 1.0;
opacity: 1;
}
}
}
}
}
.cmd-aichat {
.cmd-aichat {
display: flex;
justify-content: flex-end;
flex-flow: column nowrap;
@@ -219,7 +219,7 @@
flex-shrink: 1;
flex-direction: column-reverse;
}
.chat-textarea {
color: @term-bright-white;
background-color: @textarea-background;
@@ -242,29 +242,27 @@
}
.chat-msg {
margin-top:5px;
margin-bottom:5px;
margin-top: 5px;
margin-bottom: 5px;
}
.chat-msg-assistant {
color: @term-white;
}
.chat-msg-user {
.chat-msg-user {
.msg-text {
font-family: @markdown-font;
font-size: 14px;
white-space: pre-wrap;
}
}
.chat-msg-error {
color: @term-bright-red;
font-family: @markdown-font;
font-size: 14px;
}
.grow-spacer {
flex: 1 0 10px;
+2 -1
View File
@@ -95,6 +95,7 @@ class ScreenTab extends React.Component<
) : null;
const statusIndicatorLevel = screen.statusIndicator.get();
const runningCommands = screen.numRunningCmds.get() > 0;
return (
<Reorder.Item
@@ -121,7 +122,7 @@ class ScreenTab extends React.Component<
{screen.name.get()}
</div>
<div className="end-icons">
<StatusIndicator level={statusIndicatorLevel} />
<StatusIndicator level={statusIndicatorLevel} runningCommands={runningCommands} />
{tabIndex}
<ActionsIcon onClick={(e) => this.openScreenSettings(e, screen)} />
</div>
+2 -2
View File
@@ -279,14 +279,14 @@
.end-icons {
// 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;
line-height: normal;
.tab-index {
font-size: 12.5px;
}
}
}
&:not(:hover) .status-indicator{
&:not(:hover) .status-indicator {
.positional-icon-visible;
}
+38 -3
View File
@@ -373,6 +373,7 @@ class Screen {
webShareOpts: OV<WebShareOpts>;
filterRunning: OV<boolean>;
statusIndicator: OV<StatusIndicatorLevel>;
numRunningCmds: OV<number>;
constructor(sdata: ScreenDataType) {
this.sessionId = sdata.sessionid;
@@ -416,6 +417,9 @@ class Screen {
this.statusIndicator = mobx.observable.box(StatusIndicatorLevel.None, {
name: "screen-status-indicator",
});
this.numRunningCmds = mobx.observable.box(0, {
name: "screen-num-running-cmds",
});
}
dispose() {}
@@ -811,6 +815,16 @@ class Screen {
})();
}
/**
* Set the number of running commands for the screen.
* @param numRunning The number of running commands.
*/
setNumRunningCmds(numRunning: number): void {
mobx.action(() => {
this.numRunningCmds.set(numRunning);
})();
}
termCustomKeyHandlerInternal(e: any, termWrap: TermWrap): void {
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
if (checkKeyPressed(waveEvent, "ArrowUp")) {
@@ -1049,21 +1063,37 @@ class ScreenLines {
return this.cmds[lineId];
}
getRunningCmdLines(): LineType[] {
/**
* Get all running cmds in the screen.
* @param returnFirst If true, return the first running cmd found.
* @returns An array of running cmds, or the first running cmd if returnFirst is true.
*/
getRunningCmdLines(returnFirst?: boolean): LineType[] {
let rtn: LineType[] = [];
for (const line of this.lines) {
let cmd = this.getCmd(line.lineid);
const cmd = this.getCmd(line.lineid);
if (cmd == null) {
continue;
}
let status = cmd.getStatus();
const status = cmd.getStatus();
if (cmdStatusIsRunning(status)) {
if (returnFirst) {
return [line];
}
rtn.push(line);
}
}
return rtn;
}
/**
* Check if there are any running cmds in the screen.
* @returns True if there are any running cmds.
*/
hasRunningCmdLines(): boolean {
return this.getRunningCmdLines(true).length > 0;
}
updateCmd(cmd: CmdDataType): void {
if (cmd.remove) {
throw new Error("cannot remove cmd with updateCmd call [" + cmd.lineid + "]");
@@ -4075,6 +4105,11 @@ class Model {
update.screenstatusindicator.status
);
}
if ("screennumrunningcommands" in update) {
this.getScreenById_single(update.screennumrunningcommands.screenid)?.setNumRunningCmds(
update.screennumrunningcommands.num
);
}
}
updateRemotes(remotes: RemoteType[]): void {
+8 -4
View File
@@ -297,7 +297,12 @@ enum StatusIndicatorLevel {
type ScreenStatusIndicatorUpdateType = {
screenid: string;
status: StatusIndicatorLevel;
}
};
type ScreenNumRunningCommandsUpdateType = {
screenid: string;
num: number;
};
type ModelUpdateType = {
interactive: boolean;
@@ -322,6 +327,7 @@ type ModelUpdateType = {
openaicmdinfochat?: OpenAICmdInfoChatMessageType[];
alertmessage?: AlertMessageType;
screenstatusindicator?: ScreenStatusIndicatorUpdateType;
screennumrunningcommands?: ScreenNumRunningCommandsUpdateType;
};
type HistoryViewDataType = {
@@ -803,6 +809,4 @@ export type {
ScreenStatusIndicatorUpdateType,
};
export {
StatusIndicatorLevel,
};
export { StatusIndicatorLevel };
+2 -3
View File
@@ -3004,11 +3004,10 @@ func SessionCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (ssto
}
err = sstore.ResetStatusIndicator_Update(update, session.ActiveScreenId)
if err != nil {
log.Printf("error resetting status indicator: %v\n", err)
// this is not a fatal error, just log it
log.Printf("error resetting status indicator after session command: %v\n", err)
}
log.Printf("session command update: %v\n", update)
return update, nil
}
+15 -1
View File
@@ -1885,6 +1885,8 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
RemotePtr: remotePtr,
RunPacket: runPacket,
})
go pushNumRunningCmdsUpdate(&runPacket.CK, 1)
return cmd, func() { removeCmdWait(runPacket.CK) }, nil
}
@@ -1988,6 +1990,7 @@ func (msh *MShellProc) notifyHangups_nolock() {
}
update := &sstore.ModelUpdate{Cmd: cmd}
sstore.MainBus.SendScreenUpdate(ck.GetGroupId(), update)
go pushNumRunningCmdsUpdate(&ck, -1)
}
msh.RunningCmds = make(map[base.CommandKey]RunCmdType)
msh.PendingStateCmds = make(map[pendingStateKey]base.CommandKey)
@@ -2061,6 +2064,8 @@ func (msh *MShellProc) handleCmdDonePacket(donePk *packet.CmdDonePacketType) {
// fall-through (nothing to do)
}
}
go pushNumRunningCmdsUpdate(&donePk.CK, -1)
sstore.MainBus.SendUpdate(update)
return
}
@@ -2094,6 +2099,7 @@ func (msh *MShellProc) handleCmdFinalPacket(finalPk *packet.CmdFinalPacketType)
if screen != nil {
update.Screens = []*sstore.ScreenType{screen}
}
go pushNumRunningCmdsUpdate(&finalPk.CK, -1)
sstore.MainBus.SendUpdate(update)
}
@@ -2463,5 +2469,13 @@ func (msh *MShellProc) GetDisplayName() string {
// Identify the screen for a given CommandKey and push the given status indicator update for that screen
func pushStatusIndicatorUpdate(ck *base.CommandKey, level sstore.StatusIndicatorLevel) {
screenId := ck.GetGroupId()
sstore.SetStatusIndicatorLevel(context.Background(), screenId, level, false)
err := sstore.SetStatusIndicatorLevel(context.Background(), screenId, level, false)
if err != nil {
log.Printf("error setting status indicator level: %v\n", err)
}
}
func pushNumRunningCmdsUpdate(ck *base.CommandKey, delta int) {
screenId := ck.GetGroupId()
sstore.IncrementNumRunningCmds(screenId, delta)
}
+11 -2
View File
@@ -944,7 +944,12 @@ func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.C
} else {
indicator = StatusIndicatorLevel_Error
}
SetStatusIndicatorLevel_Update(ctx, update, screenId, indicator, false)
err := SetStatusIndicatorLevel_Update(ctx, update, screenId, indicator, false)
if err != nil {
// This is not a fatal error, so just log it
log.Printf("error setting status indicator level after done packet: %v\n", err)
}
return update, nil
}
@@ -1102,7 +1107,11 @@ func SwitchScreenById(ctx context.Context, sessionId string, screenId string) (*
update.OpenAICmdInfoChat = ScreenMemGetCmdInfoChat(screenId).Messages
// Clear any previous status indicator for this screen
ResetStatusIndicator_Update(update, screenId)
err := ResetStatusIndicator_Update(update, screenId)
if err != nil {
// This is not a fatal error, so just log it
log.Printf("error resetting status indicator when switching screens: %v\n", err)
}
}
return update, nil
}
+4 -2
View File
@@ -153,13 +153,15 @@ func ScreenMemSetCmdInputText(screenId string, sp utilfn.StrWithPos, seqNum int)
ScreenMemStore[screenId].CmdInputSeqNum = seqNum
}
func ScreenMemSetNumRunningCommands(screenId string, num int) {
func ScreenMemIncrementNumRunningCommands(screenId string, delta int) int {
MemLock.Lock()
defer MemLock.Unlock()
if ScreenMemStore[screenId] == nil {
ScreenMemStore[screenId] = &ScreenMemState{}
}
ScreenMemStore[screenId].NumRunningCommands = num
newNum := ScreenMemStore[screenId].NumRunningCommands + delta
ScreenMemStore[screenId].NumRunningCommands = newNum
return newNum
}
// If the new indicator level is higher than the current indicator, update the current indicator. Returns the new indicator level.
+21 -5
View File
@@ -1514,14 +1514,14 @@ func SetStatusIndicatorLevel_Update(ctx context.Context, update *ModelUpdate, sc
}
// Sets the in-memory status indicator for the given screenId to the given value and pushes the new value to the FE
func SetStatusIndicatorLevel(ctx context.Context, screenId string, level StatusIndicatorLevel, force bool) {
func SetStatusIndicatorLevel(ctx context.Context, screenId string, level StatusIndicatorLevel, force bool) error {
update := &ModelUpdate{}
err := SetStatusIndicatorLevel_Update(ctx, update, screenId, level, false)
if err != nil {
log.Printf("error setting status indicator level: %v\n", err)
return
return err
}
MainBus.SendUpdate(update)
return nil
}
// Resets the in-memory status indicator for the given screenId to StatusIndicatorLevel_None and adds it to the ModelUpdate
@@ -1531,7 +1531,23 @@ func ResetStatusIndicator_Update(update *ModelUpdate, screenId string) error {
}
// Resets the in-memory status indicator for the given screenId to StatusIndicatorLevel_None and pushes the new value to the FE
func ResetStatusIndicator(screenId string) {
func ResetStatusIndicator(screenId string) error {
// We do not need to set context when resetting the status indicator because we will not need to call the DB
SetStatusIndicatorLevel(context.TODO(), screenId, StatusIndicatorLevel_None, true)
return SetStatusIndicatorLevel(context.TODO(), screenId, StatusIndicatorLevel_None, true)
}
func IncrementNumRunningCmds_Update(update *ModelUpdate, screenId string, delta int) {
newNum := ScreenMemIncrementNumRunningCommands(screenId, delta)
log.Printf("IncrementNumRunningCmds_Update: screenId=%s, newNum=%d\n", screenId, newNum)
update.ScreenNumRunningCommands = &ScreenNumRunningCommandsType{
ScreenId: screenId,
Num: newNum,
}
}
func IncrementNumRunningCmds(screenId string, delta int) {
log.Printf("IncrementNumRunningCmds: screenId=%s, delta=%d\n", screenId, delta)
update := &ModelUpdate{}
IncrementNumRunningCmds_Update(update, screenId, delta)
MainBus.SendUpdate(update)
}
+31 -25
View File
@@ -39,31 +39,32 @@ func (*PtyDataUpdate) UpdateType() string {
func (pdu *PtyDataUpdate) Clean() {}
type ModelUpdate struct {
Sessions []*SessionType `json:"sessions,omitempty"`
ActiveSessionId string `json:"activesessionid,omitempty"`
Screens []*ScreenType `json:"screens,omitempty"`
ScreenLines *ScreenLinesType `json:"screenlines,omitempty"`
Line *LineType `json:"line,omitempty"`
Lines []*LineType `json:"lines,omitempty"`
Cmd *CmdType `json:"cmd,omitempty"`
CmdLine *utilfn.StrWithPos `json:"cmdline,omitempty"`
Info *InfoMsgType `json:"info,omitempty"`
ClearInfo bool `json:"clearinfo,omitempty"`
Remotes []RemoteRuntimeState `json:"remotes,omitempty"`
History *HistoryInfoType `json:"history,omitempty"`
Interactive bool `json:"interactive"`
Connect bool `json:"connect,omitempty"`
MainView string `json:"mainview,omitempty"`
Bookmarks []*BookmarkType `json:"bookmarks,omitempty"`
SelectedBookmark string `json:"selectedbookmark,omitempty"`
HistoryViewData *HistoryViewData `json:"historyviewdata,omitempty"`
ClientData *ClientData `json:"clientdata,omitempty"`
RemoteView *RemoteViewType `json:"remoteview,omitempty"`
ScreenTombstones []*ScreenTombstoneType `json:"screentombstones,omitempty"`
SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"`
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
ScreenStatusIndicator *ScreenStatusIndicatorType `json:"screenstatusindicator,omitempty"`
Sessions []*SessionType `json:"sessions,omitempty"`
ActiveSessionId string `json:"activesessionid,omitempty"`
Screens []*ScreenType `json:"screens,omitempty"`
ScreenLines *ScreenLinesType `json:"screenlines,omitempty"`
Line *LineType `json:"line,omitempty"`
Lines []*LineType `json:"lines,omitempty"`
Cmd *CmdType `json:"cmd,omitempty"`
CmdLine *utilfn.StrWithPos `json:"cmdline,omitempty"`
Info *InfoMsgType `json:"info,omitempty"`
ClearInfo bool `json:"clearinfo,omitempty"`
Remotes []RemoteRuntimeState `json:"remotes,omitempty"`
History *HistoryInfoType `json:"history,omitempty"`
Interactive bool `json:"interactive"`
Connect bool `json:"connect,omitempty"`
MainView string `json:"mainview,omitempty"`
Bookmarks []*BookmarkType `json:"bookmarks,omitempty"`
SelectedBookmark string `json:"selectedbookmark,omitempty"`
HistoryViewData *HistoryViewData `json:"historyviewdata,omitempty"`
ClientData *ClientData `json:"clientdata,omitempty"`
RemoteView *RemoteViewType `json:"remoteview,omitempty"`
ScreenTombstones []*ScreenTombstoneType `json:"screentombstones,omitempty"`
SessionTombstones []*SessionTombstoneType `json:"sessiontombstones,omitempty"`
OpenAICmdInfoChat []*packet.OpenAICmdInfoChatMessage `json:"openaicmdinfochat,omitempty"`
AlertMessage *AlertMessageType `json:"alertmessage,omitempty"`
ScreenStatusIndicator *ScreenStatusIndicatorType `json:"screenstatusindicator,omitempty"`
ScreenNumRunningCommands *ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
}
func (*ModelUpdate) UpdateType() string {
@@ -267,3 +268,8 @@ type ScreenStatusIndicatorType struct {
ScreenId string `json:"screenid"`
Status StatusIndicatorLevel `json:"status"`
}
type ScreenNumRunningCommandsType struct {
ScreenId string `json:"screenid"`
Num int `json:"num"`
}