mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-06 19:18:22 +01:00
terminal multi-input for tab (#1643)
This commit is contained in:
parent
e4c1ab6ea5
commit
3fc400960b
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
5
frontend/types/custom.d.ts
vendored
5
frontend/types/custom.d.ts
vendored
@ -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[];
|
||||
|
Loading…
Reference in New Issue
Block a user