Change Connection UI (#269)

This allows the user to select different connections from the terminal
block. Some features include:
- a status bar at the top of the term block that shows your current
connection
- an icon next to the status bar that shows whether the connection is
currently connected
- the ability to click the status bar and type in a new connection in
order to change the current connection

---------

Co-authored-by: sawka <mike.sawka@gmail.com>
This commit is contained in:
Sylvie Crowe
2024-08-23 18:12:40 -07:00
committed by GitHub
co-authored by sawka
parent 3e14eb2514
commit 636d71e652
22 changed files with 736 additions and 99 deletions
+28
View File
@@ -137,6 +137,34 @@
}
}
.connection-button {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
font-weight: 400;
color: var(--main-text-color);
border-radius: 2px;
padding-right: 6px;
&:hover {
background-color: var(--highlight-bg-color);
}
.connection-icon-box {
flex: 1 1 auto;
overflow: hidden;
}
.connection-name {
flex: 1 100 auto;
overflow: hidden;
}
}
.block-frame-textelems-wrapper {
display: flex;
flex: 1 100 auto;
+10 -1
View File
@@ -1,7 +1,14 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { blockViewToIcon, blockViewToName, getBlockHeaderIcon, IconButton, Input } from "@/app/block/blockutil";
import {
blockViewToIcon,
blockViewToName,
ConnectionButton,
getBlockHeaderIcon,
IconButton,
Input,
} from "@/app/block/blockutil";
import { Button } from "@/app/element/button";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { atoms, globalStore, useBlockAtom, WOS } from "@/app/store/global";
@@ -170,6 +177,8 @@ const HeaderTextElem = React.memo(({ elem }: { elem: HeaderElem }) => {
{elem.text}
</Button>
);
} else if (elem.elemtype == "connectionbutton") {
return <ConnectionButton decl={elem} />;
} else if (elem.elemtype == "div") {
return (
<div
+28
View File
@@ -168,6 +168,34 @@ export const IconButton = React.memo(({ decl, className }: { decl: HeaderIconBut
);
});
export const ConnectionButton = React.memo(({ decl }: { decl: ConnectionButton }) => {
const buttonRef = React.useRef<HTMLDivElement>(null);
return (
<div ref={buttonRef} className={clsx("connection-button")} onClick={decl.onClick}>
<span className="fa-stack connection-icon-box">
{typeof decl.icon === "string" ? (
<i
className={clsx(util.makeIconClass(decl.icon, true), "fa-stack-1x")}
style={{ color: decl.iconColor, marginRight: "2px" }}
/>
) : (
decl.icon
)}
<i
className="fa-slash fa-solid fa-stack-1x"
style={{
color: decl.iconColor,
marginRight: "2px",
textShadow: "0 1px black, 0 1.5px black",
opacity: decl.connected ? 0 : 1,
}}
/>
</span>
<div className="connection-name">{decl.text}</div>
</div>
);
});
export const Input = React.memo(({ decl, className }: { decl: HeaderInput; className: string }) => {
const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl;
return (
+4 -1
View File
@@ -104,8 +104,9 @@ interface TypeAheadModalProps {
suggestions?: SuggestionType[];
label?: string;
className?: string;
onSelect?: (_: string) => void;
value?: string;
onChange?: (_: string) => void;
onSelect?: (_: string) => void;
onClickBackdrop?: () => void;
onKeyDown?: (_) => void;
}
@@ -115,6 +116,7 @@ const TypeAheadModal = ({
suggestions = dummy,
label,
anchor,
value,
onChange,
onSelect,
onClickBackdrop,
@@ -167,6 +169,7 @@ const TypeAheadModal = ({
<Input
ref={inputRef}
onChange={handleChange}
value={value}
autoFocus
decoration={{
startDecoration: (
+122 -2
View File
@@ -1,7 +1,7 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { handleIncomingRpcMessage } from "@/app/store/wshrpc";
import { handleIncomingRpcMessage, sendRawRpcMessage } from "@/app/store/wshrpc";
import {
getLayoutModelForTabById,
LayoutTreeActionType,
@@ -11,6 +11,7 @@ import {
} from "@/layout/index";
import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints";
import { fetch } from "@/util/fetchutil";
import * as util from "@/util/util";
import { produce } from "immer";
import * as jotai from "jotai";
import * as rxjs from "rxjs";
@@ -25,6 +26,7 @@ let atoms: GlobalAtomsType;
let globalEnvironment: "electron" | "renderer";
const blockViewModelMap = new Map<string, ViewModel>();
const Counters = new Map<string, number>();
const ConnStatusMap = new Map<string, jotai.PrimitiveAtom<ConnStatus>>();
type GlobalInitOptions = {
platform: NodeJS.Platform;
@@ -143,9 +145,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
};
}
type WaveEventSubjectContainer = {
id: string;
handler: (event: WaveEvent) => void;
scope: string;
};
// key is "eventType" or "eventType|oref"
const eventSubjects = new Map<string, SubjectWithRef<WSEventType>>();
const fileSubjects = new Map<string, SubjectWithRef<WSFileEventData>>();
const waveEventSubjects = new Map<string, WaveEventSubjectContainer[]>();
function getSubjectInternal(subjectKey: string): SubjectWithRef<WSEventType> {
let subject = eventSubjects.get(subjectKey);
@@ -173,6 +182,61 @@ function getEventORefSubject(eventType: string, oref: string): SubjectWithRef<WS
return getSubjectInternal(eventType + "|" + oref);
}
function makeWaveReSubCommand(eventType: string): RpcMessage {
let subjects = waveEventSubjects.get(eventType);
if (subjects == null) {
return { command: "eventunsub", data: eventType };
}
let subreq: SubscriptionRequest = { event: eventType, scopes: [], allscopes: false };
for (const scont of subjects) {
if (util.isBlank(scont.scope)) {
subreq.allscopes = true;
subreq.scopes = [];
break;
}
subreq.scopes.push(scont.scope);
}
return { command: "eventsub", data: subreq };
}
function updateWaveEventSub(eventType: string) {
const command = makeWaveReSubCommand(eventType);
sendRawRpcMessage(command);
}
function waveEventSubscribe(eventType: string, scope: string, handler: (event: WaveEvent) => void): () => void {
if (handler == null) {
return;
}
const id = crypto.randomUUID();
const subject = new rxjs.Subject() as any;
const scont: WaveEventSubjectContainer = { id, scope, handler };
let subjects = waveEventSubjects.get(eventType);
if (subjects == null) {
subjects = [];
waveEventSubjects.set(eventType, subjects);
}
subjects.push(scont);
updateWaveEventSub(eventType);
return () => waveEventUnsubscribe(eventType, id);
}
function waveEventUnsubscribe(eventType: string, id: string) {
let subjects = waveEventSubjects.get(eventType);
if (subjects == null) {
return;
}
const idx = subjects.findIndex((s) => s.id === id);
if (idx === -1) {
return;
}
subjects.splice(idx, 1);
if (subjects.length === 0) {
waveEventSubjects.delete(eventType);
}
updateWaveEventSub(eventType);
}
function getFileSubject(zoneId: string, fileName: string): SubjectWithRef<WSFileEventData> {
const subjectKey = zoneId + "|" + fileName;
let subject = fileSubjects.get(subjectKey);
@@ -251,6 +315,25 @@ function useBlockDataLoaded(blockId: string): boolean {
let globalWS: WSControl = null;
function handleWaveEvent(event: WaveEvent) {
const subjects = waveEventSubjects.get(event.event);
if (subjects == null) {
return;
}
for (const scont of subjects) {
if (util.isBlank(scont.scope)) {
scont.handler(event);
continue;
}
if (event.scopes == null) {
continue;
}
if (event.scopes.includes(scont.scope)) {
scont.handler(event);
}
}
}
function handleWSEventMessage(msg: WSEventType) {
if (msg.eventtype == null) {
console.log("unsupported event", msg);
@@ -275,7 +358,7 @@ function handleWSEventMessage(msg: WSEventType) {
}
if (msg.eventtype == "rpc") {
const rpcMsg: RpcMessage = msg.data;
handleIncomingRpcMessage(rpcMsg);
handleIncomingRpcMessage(rpcMsg, handleWaveEvent);
return;
}
if (msg.eventtype == "layoutaction") {
@@ -496,6 +579,38 @@ function countersPrint() {
console.log(outStr);
}
async function loadConnStatus() {
const connStatusArr = await services.ClientService.GetAllConnStatus();
if (connStatusArr == null) {
return;
}
for (const connStatus of connStatusArr) {
const curAtom = getConnStatusAtom(connStatus.connection);
globalStore.set(curAtom, connStatus);
}
}
function subscribeToConnEvents() {
waveEventSubscribe("connchange", null, (event: WaveEvent) => {
const connStatus = event.data as ConnStatus;
if (connStatus == null || util.isBlank(connStatus.connection)) {
return;
}
let curAtom = ConnStatusMap.get(connStatus.connection);
globalStore.set(curAtom, connStatus);
});
}
function getConnStatusAtom(conn: string): jotai.PrimitiveAtom<ConnStatus> {
let rtn = ConnStatusMap.get(conn);
if (rtn == null) {
const connStatus: ConnStatus = { connection: conn, connected: false, error: null };
rtn = jotai.atom(connStatus);
ConnStatusMap.set(conn, rtn);
}
return rtn;
}
export {
atoms,
counterInc,
@@ -504,6 +619,7 @@ export {
createBlock,
fetchWaveFile,
getApi,
getConnStatusAtom,
getEventORefSubject,
getEventSubject,
getFileSubject,
@@ -514,16 +630,20 @@ export {
initGlobal,
initWS,
isDev,
loadConnStatus,
openLink,
PLATFORM,
registerViewModel,
sendWSCommand,
setBlockFocus,
setPlatform,
subscribeToConnEvents,
unregisterViewModel,
useBlockAtom,
useBlockCache,
useBlockDataLoaded,
useSettingsAtom,
waveEventSubscribe,
waveEventUnsubscribe,
WOS,
};
+3
View File
@@ -32,6 +32,9 @@ class ClientServiceType {
FocusWindow(arg2: string): Promise<void> {
return WOS.callBackendService("client", "FocusWindow", Array.from(arguments))
}
GetAllConnStatus(): Promise<ConnStatus[]> {
return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments))
}
GetClientData(): Promise<Client> {
return WOS.callBackendService("client", "GetClientData", Array.from(arguments))
}
+17 -4
View File
@@ -10,7 +10,7 @@ type RpcEntry = {
msgFn: (msg: RpcMessage) => void;
};
let openRpcs = new Map<string, RpcEntry>();
const openRpcs = new Map<string, RpcEntry>();
async function* rpcResponseGenerator(
command: string,
@@ -86,10 +86,23 @@ function sendRpcCommand(msg: RpcMessage): AsyncGenerator<RpcMessage, void, boole
return rtnGen;
}
function handleIncomingRpcMessage(msg: RpcMessage) {
function sendRawRpcMessage(msg: RpcMessage) {
const wsMsg: WSRpcCommand = { wscommand: "rpc", message: msg };
globalWS.pushMessage(wsMsg);
}
function handleIncomingRpcMessage(msg: RpcMessage, eventHandlerFn: (event: WaveEvent) => void) {
const isRequest = msg.command != null || msg.reqid != null;
if (isRequest) {
console.log("rpc request not supported", msg);
// handle events
if (msg.command == "eventrecv") {
if (eventHandlerFn != null) {
eventHandlerFn(msg.data);
}
return;
}
console.log("rpc command not supported", msg);
return;
}
if (msg.resid == null) {
@@ -122,4 +135,4 @@ if (globalThis.window != null) {
globalThis["consumeGenerator"] = consumeGenerator;
}
export { handleIncomingRpcMessage, sendRpcCommand };
export { handleIncomingRpcMessage, sendRawRpcMessage, sendRpcCommand };
+1 -1
View File
@@ -53,7 +53,7 @@ class WshServerType {
}
// command "eventunsub" [call]
EventUnsubCommand(data: SubscriptionRequest, opts?: RpcOpts): Promise<void> {
EventUnsubCommand(data: string, opts?: RpcOpts): Promise<void> {
return WOS.wshServerRpcHelper_call("eventunsub", data, opts);
}
+10
View File
@@ -1,6 +1,16 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.connection-btn {
min-height: 0;
overflow: hidden;
line-height: 1;
display: flex;
background-color: orangered;
justify-content: flex-start;
width: 200px;
}
.view-term {
display: flex;
flex-direction: column;
+99 -3
View File
@@ -1,9 +1,18 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { TypeAheadModal } from "@/app/modals/typeaheadmodal";
import { WshServer } from "@/app/store/wshserver";
import { VDomView } from "@/app/view/term/vdom";
import { WOS, atoms, getEventORefSubject, globalStore, useBlockAtom, useSettingsAtom } from "@/store/global";
import {
WOS,
atoms,
getConnStatusAtom,
getEventORefSubject,
globalStore,
useBlockAtom,
useSettingsAtom,
} from "@/store/global";
import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util";
@@ -109,13 +118,16 @@ function setBlockFocus(blockId: string) {
class TermViewModel {
viewType: string;
connected: boolean;
termRef: React.RefObject<TermWrap>;
blockAtom: jotai.Atom<Block>;
termMode: jotai.Atom<string>;
connectedAtom: jotai.Atom<boolean>;
typeahead: boolean;
htmlElemFocusRef: React.RefObject<HTMLInputElement>;
blockId: string;
viewIcon: jotai.Atom<string>;
viewText: jotai.Atom<string>;
viewText: jotai.Atom<HeaderElem[]>;
viewName: jotai.Atom<string>;
blockBg: jotai.Atom<MetaType>;
@@ -123,6 +135,14 @@ class TermViewModel {
this.viewType = "term";
this.blockId = blockId;
this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
this.connectedAtom = jotai.atom((get) => {
const connectionName = get(this.blockAtom).meta?.connection || "";
if (connectionName == "") {
return true;
}
const status = get(getConnStatusAtom(connectionName));
return status.connected;
});
this.termMode = jotai.atom((get) => {
const blockData = get(this.blockAtom);
return blockData?.meta?.["term:mode"] ?? "term";
@@ -139,7 +159,30 @@ class TermViewModel {
});
this.viewText = jotai.atom((get) => {
const blockData = get(this.blockAtom);
return blockData?.meta?.title ?? "";
const titleText: HeaderText = { elemtype: "text", text: blockData?.meta?.title ?? "" };
const typeAhead = get(atoms.typeAheadModalAtom);
const connectionName = blockData?.meta?.connection || "";
const isConnected = get(this.connectedAtom);
let iconColor: string;
if (connectionName != "") {
iconColor = "#53b4ea";
} else {
iconColor = "var(--grey-text-color)";
}
const connButton: ConnectionButton = {
elemtype: "connectionbutton",
icon: "arrow-right-arrow-left",
iconColor: iconColor,
text: connectionName,
connected: isConnected,
onClick: () => {
globalStore.set(atoms.typeAheadModalAtom, {
...(typeAhead as TypeAheadModalType),
[blockId]: true,
});
},
};
return [connButton, titleText] as HeaderElem[];
});
this.blockBg = jotai.atom((get) => {
const blockData = get(this.blockAtom);
@@ -152,6 +195,10 @@ class TermViewModel {
});
}
resetConnection() {
WshServer.ControllerRestartCommand({ blockid: this.blockId });
}
giveFocus(): boolean {
let termMode = globalStore.get(this.termMode);
if (termMode == "term") {
@@ -196,6 +243,9 @@ interface TerminalViewProps {
}
const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const typeAhead = jotai.useAtomValue(atoms.typeAheadModalAtom);
const viewRef = React.createRef<HTMLDivElement>();
const [connSelected, setConnSelected] = React.useState("");
const connectElemRef = React.useRef<HTMLDivElement>(null);
const termRef = React.useRef<TermWrap>(null);
model.termRef = termRef;
@@ -371,11 +421,57 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
}
}
const changeConnection = React.useCallback(
async (connName: string) => {
await WshServer.SetMetaCommand({ oref: WOS.makeORef("block", blockId), meta: { connection: connName } });
await WshServer.ControllerRestartCommand({ blockid: blockId });
},
[blockId]
);
const handleTypeAheadKeyDown = React.useCallback(
(waveEvent: WaveKeyboardEvent): boolean => {
if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
changeConnection(connSelected);
globalStore.set(atoms.typeAheadModalAtom, {
...(typeAhead as TypeAheadModalType),
[blockId]: false,
});
setConnSelected("");
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
globalStore.set(atoms.typeAheadModalAtom, {
...(typeAhead as TypeAheadModalType),
[blockId]: false,
});
setConnSelected("");
model.giveFocus();
return true;
}
},
[typeAhead, model, blockId, connSelected]
);
return (
<div
className={clsx("view-term", "term-mode-" + termMode, isFocused ? "is-focused" : null)}
onKeyDown={handleKeyDown}
ref={viewRef}
>
{typeAhead[blockId] && (
<TypeAheadModal
anchor={viewRef}
suggestions={[]}
onSelect={(selected: string) => {
changeConnection(selected);
}}
onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)}
onChange={(current: string) => setConnSelected(current)}
value={connSelected}
label="Switch Connection"
/>
)}
<TermThemeUpdater blockId={blockId} termRef={termRef} />
<TermStickers config={stickerConfig} />
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
+11 -1
View File
@@ -140,7 +140,7 @@ declare global {
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
type HeaderElem = HeaderIconButton | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton;
type HeaderElem = HeaderIconButton | HeaderText | HeaderInput | HeaderDiv | HeaderTextButton | ConnectionButton;
type HeaderIconButton = {
elemtype: "iconbutton";
@@ -181,6 +181,16 @@ declare global {
children: HeaderElem[];
onMouseOver?: (e: React.MouseEvent<any>) => void;
onMouseOut?: (e: React.MouseEvent<any>) => void;
onClick?: (e: React.MouseEvent<any>) => void;
};
type ConnectionButton = {
elemtype: "connectionbutton";
icon: string;
text: string;
iconColor: string;
onClick?: (e: React.MouseEvent<any>) => void;
connected: boolean;
};
interface ViewModel {
+8
View File
@@ -158,6 +158,14 @@ declare global {
meta: MetaType;
};
// wshrpc.ConnStatus
type ConnStatus = {
status: string;
connection: string;
connected: boolean;
error?: string;
};
// wshrpc.CpuDataRequest
type CpuDataRequest = {
id: string;
+14 -1
View File
@@ -2,7 +2,18 @@
// SPDX-License-Identifier: Apache-2.0
import { WshServer } from "@/app/store/wshserver";
import { atoms, countersClear, countersPrint, getApi, globalStore, globalWS, initGlobal, initWS } from "@/store/global";
import {
atoms,
countersClear,
countersPrint,
getApi,
globalStore,
globalWS,
initGlobal,
initWS,
loadConnStatus,
subscribeToConnEvents,
} from "@/store/global";
import * as services from "@/store/services";
import * as WOS from "@/store/wos";
import * as keyutil from "@/util/keyutil";
@@ -44,6 +55,8 @@ document.addEventListener("DOMContentLoaded", async () => {
const initialTab = await WOS.loadAndPinWaveObject<Tab>(WOS.makeORef("tab", waveWindow.activetabid));
await WOS.loadAndPinWaveObject<LayoutState>(WOS.makeORef("layout", initialTab.layoutstate));
initWS();
await loadConnStatus();
subscribeToConnEvents();
const settings = await services.FileService.GetSettingsConfig();
console.log("settings", settings);
globalStore.set(atoms.settingsConfigAtom, settings);