mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-22 21:42:49 +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 Web Views
|
||||||
- ✅ Search in the Terminal
|
- ✅ Search in the Terminal
|
||||||
- 🔷 Custom init files for widgets and terminal blocks
|
- 🔷 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
|
- ✅ Gemini AI support
|
||||||
- 🔷 Monaco Theming
|
- 🔷 Monaco Theming
|
||||||
- 🤞 Blockcontroller fixes for terminal escape sequences
|
- 🤞 Blockcontroller fixes for terminal escape sequences
|
||||||
|
@ -292,7 +292,7 @@ const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; previe
|
|||||||
);
|
);
|
||||||
} else if (elem.elemtype == "textbutton") {
|
} else if (elem.elemtype == "textbutton") {
|
||||||
return (
|
return (
|
||||||
<Button className={elem.className} onClick={(e) => elem.onClick(e)}>
|
<Button className={elem.className} onClick={(e) => elem.onClick(e)} title={elem.title}>
|
||||||
{elem.text}
|
{elem.text}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
@ -164,6 +164,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
|||||||
notifications: notificationsAtom,
|
notifications: notificationsAtom,
|
||||||
notificationPopoverMode: notificationPopoverModeAtom,
|
notificationPopoverMode: notificationPopoverModeAtom,
|
||||||
reinitVersion,
|
reinitVersion,
|
||||||
|
isTermMultiInput: atom(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,6 +497,10 @@ function getBlockComponentModel(blockId: string): BlockComponentModel {
|
|||||||
return blockComponentModelMap.get(blockId);
|
return blockComponentModelMap.get(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAllBlockComponentModels(): BlockComponentModel[] {
|
||||||
|
return Array.from(blockComponentModelMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
function getFocusedBlockId(): string {
|
function getFocusedBlockId(): string {
|
||||||
const layoutModel = getLayoutModelForStaticTab();
|
const layoutModel = getLayoutModelForStaticTab();
|
||||||
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
|
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
|
||||||
@ -665,6 +670,7 @@ export {
|
|||||||
createBlock,
|
createBlock,
|
||||||
createTab,
|
createTab,
|
||||||
fetchWaveFile,
|
fetchWaveFile,
|
||||||
|
getAllBlockComponentModels,
|
||||||
getApi,
|
getApi,
|
||||||
getBlockComponentModel,
|
getBlockComponentModel,
|
||||||
getBlockMetaKeyAtom,
|
getBlockMetaKeyAtom,
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
atoms,
|
atoms,
|
||||||
createBlock,
|
createBlock,
|
||||||
createTab,
|
createTab,
|
||||||
|
getAllBlockComponentModels,
|
||||||
getApi,
|
getApi,
|
||||||
getBlockComponentModel,
|
getBlockComponentModel,
|
||||||
globalStore,
|
globalStore,
|
||||||
@ -232,6 +233,19 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean {
|
|||||||
return appHandleKeyDown(event);
|
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() {
|
function registerGlobalKeys() {
|
||||||
globalKeyMap.set("Cmd:]", () => {
|
globalKeyMap.set("Cmd:]", () => {
|
||||||
switchTab(1);
|
switchTab(1);
|
||||||
@ -314,6 +328,15 @@ function registerGlobalKeys() {
|
|||||||
return true;
|
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++) {
|
for (let idx = 1; idx <= 9; idx++) {
|
||||||
globalKeyMap.set(`Cmd:${idx}`, () => {
|
globalKeyMap.set(`Cmd:${idx}`, () => {
|
||||||
switchTabAbs(idx);
|
switchTabAbs(idx);
|
||||||
|
@ -13,6 +13,7 @@ import { TermWshClient } from "@/app/view/term/term-wsh";
|
|||||||
import { VDomModel } from "@/app/view/vdom/vdom-model";
|
import { VDomModel } from "@/app/view/vdom/vdom-model";
|
||||||
import {
|
import {
|
||||||
atoms,
|
atoms,
|
||||||
|
getAllBlockComponentModels,
|
||||||
getBlockComponentModel,
|
getBlockComponentModel,
|
||||||
getBlockMetaKeyAtom,
|
getBlockMetaKeyAtom,
|
||||||
getConnStatusAtom,
|
getConnStatusAtom,
|
||||||
@ -25,7 +26,7 @@ import {
|
|||||||
} from "@/store/global";
|
} from "@/store/global";
|
||||||
import * as services from "@/store/services";
|
import * as services from "@/store/services";
|
||||||
import * as keyutil from "@/util/keyutil";
|
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 { ISearchOptions } from "@xterm/addon-search";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
@ -132,7 +133,7 @@ class TermViewModel implements ViewModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
const vdomBlockId = get(this.vdomBlockId);
|
const vdomBlockId = get(this.vdomBlockId);
|
||||||
const rtn = [];
|
const rtn: HeaderElem[] = [];
|
||||||
if (vdomBlockId) {
|
if (vdomBlockId) {
|
||||||
rtn.push({
|
rtn.push({
|
||||||
elemtype: "iconbutton",
|
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;
|
return rtn;
|
||||||
});
|
});
|
||||||
this.manageConnection = jotai.atom((get) => {
|
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") {
|
setTermMode(mode: "term" | "vdom") {
|
||||||
if (mode == "term") {
|
if (mode == "term") {
|
||||||
mode = null;
|
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 {
|
function makeTerminalModel(blockId: string, nodeModel: BlockNodeModel): TermViewModel {
|
||||||
return new TermViewModel(blockId, nodeModel);
|
return new TermViewModel(blockId, nodeModel);
|
||||||
}
|
}
|
||||||
@ -791,6 +849,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
|||||||
const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
|
const termFontSize = jotai.useAtomValue(model.fontSizeAtom);
|
||||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||||
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"];
|
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
|
// search
|
||||||
const searchProps = useSearch({
|
const searchProps = useSearch({
|
||||||
@ -898,6 +959,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
|||||||
{
|
{
|
||||||
keydownHandler: model.handleTerminalKeydown.bind(model),
|
keydownHandler: model.handleTerminalKeydown.bind(model),
|
||||||
useWebGl: !termSettings?.["term:disablewebgl"],
|
useWebGl: !termSettings?.["term:disablewebgl"],
|
||||||
|
sendDataHandler: model.sendDataToController.bind(model),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
(window as any).term = termWrap;
|
(window as any).term = termWrap;
|
||||||
@ -930,6 +992,18 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
|||||||
termModeRef.current = termMode;
|
termModeRef.current = termMode;
|
||||||
}, [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 scrollbarHideObserverRef = React.useRef<HTMLDivElement>(null);
|
||||||
const onScrollbarShowObserver = React.useCallback(() => {
|
const onScrollbarShowObserver = React.useCallback(() => {
|
||||||
const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement;
|
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 { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||||
import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global";
|
import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, openLink } from "@/store/global";
|
||||||
import * as services from "@/store/services";
|
import * as services from "@/store/services";
|
||||||
import * as util from "@/util/util";
|
|
||||||
import { base64ToArray, fireAndForget } from "@/util/util";
|
import { base64ToArray, fireAndForget } from "@/util/util";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||||
@ -42,6 +41,7 @@ let loggedWebGL = false;
|
|||||||
type TermWrapOptions = {
|
type TermWrapOptions = {
|
||||||
keydownHandler?: (e: KeyboardEvent) => boolean;
|
keydownHandler?: (e: KeyboardEvent) => boolean;
|
||||||
useWebGl?: boolean;
|
useWebGl?: boolean;
|
||||||
|
sendDataHandler?: (data: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TermWrap {
|
export class TermWrap {
|
||||||
@ -58,6 +58,8 @@ export class TermWrap {
|
|||||||
heldData: Uint8Array[];
|
heldData: Uint8Array[];
|
||||||
handleResize_debounced: () => void;
|
handleResize_debounced: () => void;
|
||||||
hasResized: boolean;
|
hasResized: boolean;
|
||||||
|
multiInputCallback: (data: string) => void;
|
||||||
|
sendDataHandler: (data: string) => void;
|
||||||
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
|
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
|
||||||
private toDispose: TermTypes.IDisposable[] = [];
|
private toDispose: TermTypes.IDisposable[] = [];
|
||||||
|
|
||||||
@ -69,6 +71,7 @@ export class TermWrap {
|
|||||||
) {
|
) {
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
this.blockId = blockId;
|
this.blockId = blockId;
|
||||||
|
this.sendDataHandler = waveOptions.sendDataHandler;
|
||||||
this.ptyOffset = 0;
|
this.ptyOffset = 0;
|
||||||
this.dataBytesProcessed = 0;
|
this.dataBytesProcessed = 0;
|
||||||
this.hasResized = false;
|
this.hasResized = false;
|
||||||
@ -146,6 +149,7 @@ export class TermWrap {
|
|||||||
async initTerminal() {
|
async initTerminal() {
|
||||||
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
|
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
|
||||||
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
|
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
|
||||||
|
this.toDispose.push(this.terminal.onKey(this.onKeyHandler.bind(this)));
|
||||||
this.toDispose.push(
|
this.toDispose.push(
|
||||||
this.terminal.onSelectionChange(
|
this.terminal.onSelectionChange(
|
||||||
debounce(50, () => {
|
debounce(50, () => {
|
||||||
@ -186,8 +190,13 @@ export class TermWrap {
|
|||||||
if (!this.loaded) {
|
if (!this.loaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const b64data = util.stringToBase64(data);
|
this.sendDataHandler?.(data);
|
||||||
RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, inputdata64: b64data });
|
}
|
||||||
|
|
||||||
|
onKeyHandler(data: { key: string; domEvent: KeyboardEvent }) {
|
||||||
|
if (this.multiInputCallback) {
|
||||||
|
this.multiInputCallback(data.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addFocusListener(focusFn: () => void) {
|
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[]>;
|
notifications: jotai.PrimitiveAtom<NotificationType[]>;
|
||||||
notificationPopoverMode: jotia.atom<boolean>;
|
notificationPopoverMode: jotia.atom<boolean>;
|
||||||
reinitVersion: jotai.PrimitiveAtom<number>;
|
reinitVersion: jotai.PrimitiveAtom<number>;
|
||||||
|
isTermMultiInput: jotai.PrimitiveAtom<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
|
type WritableWaveObjectAtom<T extends WaveObj> = jotai.WritableAtom<T, [value: T], void>;
|
||||||
@ -176,6 +177,7 @@ declare global {
|
|||||||
elemtype: "textbutton";
|
elemtype: "textbutton";
|
||||||
text: string;
|
text: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
title?: string;
|
||||||
onClick?: (e: React.MouseEvent<any>) => void;
|
onClick?: (e: React.MouseEvent<any>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -260,6 +262,9 @@ declare global {
|
|||||||
filterOutNowsh?: jotai.Atom<boolean>;
|
filterOutNowsh?: jotai.Atom<boolean>;
|
||||||
searchAtoms?: SearchAtoms;
|
searchAtoms?: SearchAtoms;
|
||||||
|
|
||||||
|
// just for terminal
|
||||||
|
isBasicTerm?: (getFn: jotai.Getter) => boolean;
|
||||||
|
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
getSettingsMenuItems?: () => ContextMenuItem[];
|
getSettingsMenuItems?: () => ContextMenuItem[];
|
||||||
|
Loading…
Reference in New Issue
Block a user