terminal multi-input for tab (#1643)

This commit is contained in:
Mike Sawka 2025-01-02 10:06:47 -08:00 committed by GitHub
parent e4c1ab6ea5
commit 3fc400960b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 7 deletions

View File

@ -24,7 +24,7 @@ Legend: ✅ Done | 🔧 In Progress | 🔷 Planned | 🤞 Stretch Goal
- ✅ Search in Web Views
- ✅ Search in the Terminal
- 🔷 Custom init files for widgets and terminal blocks
- 🔧 Multi-Input between terminal blocks on the same tab
- Multi-Input between terminal blocks on the same tab
- ✅ Gemini AI support
- 🔷 Monaco Theming
- 🤞 Blockcontroller fixes for terminal escape sequences

View File

@ -292,7 +292,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
);
} else if (elem.elemtype == "textbutton") {
return (
<Button className={elem.className} onClick={(e) => elem.onClick(e)}>
<Button className={elem.className} onClick={(e) => elem.onClick(e)} title={elem.title}>
{elem.text}
</Button>
);

View File

@ -164,6 +164,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
notifications: notificationsAtom,
notificationPopoverMode: notificationPopoverModeAtom,
reinitVersion,
isTermMultiInput: atom(false),
};
}
@ -496,6 +497,10 @@ function getBlockComponentModel(blockId: string): BlockComponentModel {
return blockComponentModelMap.get(blockId);
}
function getAllBlockComponentModels(): BlockComponentModel[] {
return Array.from(blockComponentModelMap.values());
}
function getFocusedBlockId(): string {
const layoutModel = getLayoutModelForStaticTab();
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
@ -665,6 +670,7 @@ export {
createBlock,
createTab,
fetchWaveFile,
getAllBlockComponentModels,
getApi,
getBlockComponentModel,
getBlockMetaKeyAtom,

View File

@ -5,6 +5,7 @@ import {
atoms,
createBlock,
createTab,
getAllBlockComponentModels,
getApi,
getBlockComponentModel,
globalStore,
@ -232,6 +233,19 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean {
return appHandleKeyDown(event);
}
function countTermBlocks(): number {
const allBCMs = getAllBlockComponentModels();
let count = 0;
let gsGetBound = globalStore.get.bind(globalStore);
for (const bcm of allBCMs) {
const viewModel = bcm.viewModel;
if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) {
count++;
}
}
return count;
}
function registerGlobalKeys() {
globalKeyMap.set("Cmd:]", () => {
switchTab(1);
@ -314,6 +328,15 @@ function registerGlobalKeys() {
return true;
}
});
globalKeyMap.set("Ctrl:Shift:i", () => {
const curMI = globalStore.get(atoms.isTermMultiInput);
if (!curMI && countTermBlocks() <= 1) {
// don't turn on multi-input unless there are 2 or more basic term blocks
return true;
}
globalStore.set(atoms.isTermMultiInput, !curMI);
return true;
});
for (let idx = 1; idx <= 9; idx++) {
globalKeyMap.set(`Cmd:${idx}`, () => {
switchTabAbs(idx);

View File

@ -13,6 +13,7 @@ import { TermWshClient } from "@/app/view/term/term-wsh";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import {
atoms,
getAllBlockComponentModels,
getBlockComponentModel,
getBlockMetaKeyAtom,
getConnStatusAtom,
@ -25,7 +26,7 @@ import {
} from "@/store/global";
import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil";
import { boundNumber, fireAndForget, useAtomValueSafe } from "@/util/util";
import { boundNumber, fireAndForget, stringToBase64, useAtomValueSafe } from "@/util/util";
import { ISearchOptions } from "@xterm/addon-search";
import clsx from "clsx";
import debug from "debug";
@ -132,7 +133,7 @@ class TermViewModel implements ViewModel {
];
}
const vdomBlockId = get(this.vdomBlockId);
const rtn = [];
const rtn: HeaderElem[] = [];
if (vdomBlockId) {
rtn.push({
elemtype: "iconbutton",
@ -189,6 +190,18 @@ class TermViewModel implements ViewModel {
}
}
}
const isMI = get(atoms.isTermMultiInput);
if (isMI && this.isBasicTerm(get)) {
rtn.push({
elemtype: "textbutton",
text: "Multi Input ON",
className: "yellow",
title: "Input will be sent to all connected terminals (click to disable)",
onClick: () => {
globalStore.set(atoms.isTermMultiInput, false);
},
});
}
return rtn;
});
this.manageConnection = jotai.atom((get) => {
@ -305,6 +318,36 @@ class TermViewModel implements ViewModel {
});
}
isBasicTerm(getFn: jotai.Getter): boolean {
// needs to match "const isBasicTerm" in TerminalView()
const termMode = getFn(this.termMode);
if (termMode == "vdom") {
return false;
}
const blockData = getFn(this.blockAtom);
if (blockData?.meta?.controller == "cmd") {
return false;
}
return true;
}
multiInputHandler(data: string) {
let tvms = getAllBasicTermModels();
// filter out "this" from the list
tvms = tvms.filter((tvm) => tvm != this);
if (tvms.length == 0) {
return;
}
for (const tvm of tvms) {
tvm.sendDataToController(data);
}
}
sendDataToController(data: string) {
const b64data = stringToBase64(data);
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
}
setTermMode(mode: "term" | "vdom") {
if (mode == "term") {
mode = null;
@ -643,6 +686,21 @@ class TermViewModel implements ViewModel {
}
}
function getAllBasicTermModels(): TermViewModel[] {
const allBCMs = getAllBlockComponentModels();
const rtn: TermViewModel[] = [];
for (const bcm of allBCMs) {
if (bcm.viewModel?.viewType != "term") {
continue;
}
const termVM = bcm.viewModel as TermViewModel;
if (termVM.isBasicTerm(globalStore.get)) {
rtn.push(termVM);
}
}
return rtn;
}
function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel {
return new TermViewModel(blockId, nodeModel);
}
@ -791,6 +849,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"];
const isFocused = jotai.useAtomValue(model.nodeModel.isFocused);
const isMI = jotai.useAtomValue(atoms.isTermMultiInput);
const isBasicTerm = termMode != "vdom" && blockData?.meta?.controller != "cmd"; // needs to match isBasicTerm
// search
const searchProps = useSearch({
@ -898,6 +959,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
{
keydownHandler: model.handleTerminalKeydown.bind(model),
useWebGl: !termSettings?.["term:disablewebgl"],
sendDataHandler: model.sendDataToController.bind(model),
}
);
(window as any).term = termWrap;
@ -930,6 +992,18 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
termModeRef.current = termMode;
}, [termMode]);
React.useEffect(() => {
if (isMI && isBasicTerm && isFocused && model.termRef.current != null) {
model.termRef.current.multiInputCallback = (data: string) => {
model.multiInputHandler(data);
};
} else {
if (model.termRef.current != null) {
model.termRef.current.multiInputCallback = null;
}
}
}, [isMI, isBasicTerm, isFocused]);
const scrollbarHideObserverRef = React.useRef<HTMLDivElement>(null);
const onScrollbarShowObserver = React.useCallback(() => {
const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement;

View File

@ -7,7 +7,6 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global";
import * as services from "@/store/services";
import * as util from "@/util/util";
import { base64ToArray, fireAndForget } from "@/util/util";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
@ -42,6 +41,7 @@ let loggedWebGL = false;
type TermWrapOptions = {
keydownHandler?: (e: KeyboardEvent) => boolean;
useWebGl?: boolean;
sendDataHandler?: (data: string) => void;
};
export class TermWrap {
@ -58,6 +58,8 @@ export class TermWrap {
heldData: Uint8Array[];
handleResize_debounced: () => void;
hasResized: boolean;
multiInputCallback: (data: string) => void;
sendDataHandler: (data: string) => void;
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
private toDispose: TermTypes.IDisposable[] = [];
@ -69,6 +71,7 @@ export class TermWrap {
) {
this.loaded = false;
this.blockId = blockId;
this.sendDataHandler = waveOptions.sendDataHandler;
this.ptyOffset = 0;
this.dataBytesProcessed = 0;
this.hasResized = false;
@ -146,6 +149,7 @@ export class TermWrap {
async initTerminal() {
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
this.toDispose.push(this.terminal.onKey(this.onKeyHandler.bind(this)));
this.toDispose.push(
this.terminal.onSelectionChange(
debounce(50, () => {
@ -186,8 +190,13 @@ export class TermWrap {
if (!this.loaded) {
return;
}
const b64data = util.stringToBase64(data);
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
this.sendDataHandler?.(data);
}
onKeyHandler(data: { key: string; domEvent: KeyboardEvent }) {
if (this.multiInputCallback) {
this.multiInputCallback(data.key);
}
}
addFocusListener(focusFn: () => void) {

View File

@ -27,6 +27,7 @@ declare global {
notifications: jotai.PrimitiveAtom<NotificationType[]>;
notificationPopoverMode: jotia.atom<boolean>;
reinitVersion: jotai.PrimitiveAtom<number>;
isTermMultiInput: jotai.PrimitiveAtom<boolean>;
};
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
@ -176,6 +177,7 @@ declare global {
elemtype: "textbutton";
text: string;
className?: string;
title?: string;
onClick?: (e: React.MouseEvent<any>) => void;
};
@ -260,6 +262,9 @@ declare global {
filterOutNowsh?: jotai.Atom<boolean>;
searchAtoms?: SearchAtoms;
// just for terminal
isBasicTerm?: (getFn: jotai.Getter) => boolean;
onBack?: () => void;
onForward?: () => void;
getSettingsMenuItems?: () => ContextMenuItem[];