mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { Block, SubBlock } from "@/app/block/block";
|
|
import { Search, useSearch } from "@/app/element/search";
|
|
import { waveEventSubscribe } from "@/app/store/wps";
|
|
import { RpcApi } from "@/app/store/wshclientapi";
|
|
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import type { TermViewModel } from "@/app/view/term/term-model";
|
|
import { atoms, getOverrideConfigAtom, getSettingsPrefixAtom, globalStore, WOS } from "@/store/global";
|
|
import { fireAndForget, useAtomValueSafe } from "@/util/util";
|
|
import { computeBgStyleFromMeta } from "@/util/waveutil";
|
|
import { ISearchOptions } from "@xterm/addon-search";
|
|
import clsx from "clsx";
|
|
import debug from "debug";
|
|
import * as jotai from "jotai";
|
|
import * as React from "react";
|
|
import { TermStickers } from "./termsticker";
|
|
import { TermThemeUpdater } from "./termtheme";
|
|
import { computeTheme } from "./termutil";
|
|
import { TermWrap } from "./termwrap";
|
|
import "./xterm.css";
|
|
|
|
const dlog = debug("wave:term");
|
|
|
|
interface TerminalViewProps {
|
|
blockId: string;
|
|
model: TermViewModel;
|
|
}
|
|
|
|
const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => {
|
|
const connStatus = jotai.useAtomValue(model.connStatus);
|
|
const [lastConnStatus, setLastConnStatus] = React.useState<ConnStatus>(connStatus);
|
|
|
|
React.useEffect(() => {
|
|
if (!model.termRef.current?.hasResized) {
|
|
return;
|
|
}
|
|
const isConnected = connStatus?.status == "connected";
|
|
const wasConnected = lastConnStatus?.status == "connected";
|
|
const curConnName = connStatus?.connection;
|
|
const lastConnName = lastConnStatus?.connection;
|
|
if (isConnected == wasConnected && curConnName == lastConnName) {
|
|
return;
|
|
}
|
|
model.termRef.current?.resyncController("resync handler");
|
|
setLastConnStatus(connStatus);
|
|
}, [connStatus]);
|
|
|
|
return null;
|
|
});
|
|
|
|
const TermVDomToolbarNode = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
|
|
React.useEffect(() => {
|
|
const unsub = waveEventSubscribe({
|
|
eventType: "blockclose",
|
|
scope: WOS.makeORef("block", vdomBlockId),
|
|
handler: (event) => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", blockId),
|
|
meta: {
|
|
"term:mode": null,
|
|
"term:vdomtoolbarblockid": null,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
return () => {
|
|
unsub();
|
|
};
|
|
}, []);
|
|
let vdomNodeModel = {
|
|
blockId: vdomBlockId,
|
|
isFocused: jotai.atom(false),
|
|
focusNode: () => {},
|
|
onClose: () => {
|
|
if (vdomBlockId != null) {
|
|
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
|
|
}
|
|
},
|
|
};
|
|
const toolbarTarget = jotai.useAtomValue(model.vdomToolbarTarget);
|
|
const heightStr = toolbarTarget?.height ?? "1.5em";
|
|
return (
|
|
<div key="vdomToolbar" className="term-toolbar" style={{ height: heightStr }}>
|
|
<SubBlock key="vdom" nodeModel={vdomNodeModel} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => {
|
|
React.useEffect(() => {
|
|
const unsub = waveEventSubscribe({
|
|
eventType: "blockclose",
|
|
scope: WOS.makeORef("block", vdomBlockId),
|
|
handler: (event) => {
|
|
RpcApi.SetMetaCommand(TabRpcClient, {
|
|
oref: WOS.makeORef("block", blockId),
|
|
meta: {
|
|
"term:mode": null,
|
|
"term:vdomblockid": null,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
return () => {
|
|
unsub();
|
|
};
|
|
}, []);
|
|
const isFocusedAtom = jotai.atom((get) => {
|
|
return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom";
|
|
});
|
|
let vdomNodeModel = {
|
|
blockId: vdomBlockId,
|
|
isFocused: isFocusedAtom,
|
|
focusNode: () => {
|
|
model.nodeModel.focusNode();
|
|
},
|
|
onClose: () => {
|
|
if (vdomBlockId != null) {
|
|
RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId });
|
|
}
|
|
},
|
|
};
|
|
return (
|
|
<div key="htmlElem" className="term-htmlelem">
|
|
<SubBlock key="vdom" nodeModel={vdomNodeModel} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TermVDomNode = ({ blockId, model }: TerminalViewProps) => {
|
|
const vdomBlockId = jotai.useAtomValue(model.vdomBlockId);
|
|
if (vdomBlockId == null) {
|
|
return null;
|
|
}
|
|
return <TermVDomNodeSingleId key={vdomBlockId} vdomBlockId={vdomBlockId} blockId={blockId} model={model} />;
|
|
};
|
|
|
|
const TermToolbarVDomNode = ({ blockId, model }: TerminalViewProps) => {
|
|
const vdomToolbarBlockId = jotai.useAtomValue(model.vdomToolbarBlockId);
|
|
if (vdomToolbarBlockId == null) {
|
|
return null;
|
|
}
|
|
return (
|
|
<TermVDomToolbarNode
|
|
key={vdomToolbarBlockId}
|
|
vdomBlockId={vdomToolbarBlockId}
|
|
blockId={blockId}
|
|
model={model}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) => {
|
|
const viewRef = React.useRef<HTMLDivElement>(null);
|
|
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
|
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
|
|
const termSettingsAtom = getSettingsPrefixAtom("term");
|
|
const termSettings = jotai.useAtomValue(termSettingsAtom);
|
|
let termMode = blockData?.meta?.["term:mode"] ?? "term";
|
|
if (termMode != "term" && termMode != "vdom") {
|
|
termMode = "term";
|
|
}
|
|
const termModeRef = React.useRef(termMode);
|
|
|
|
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({
|
|
anchorRef: viewRef,
|
|
viewModel: model,
|
|
caseSensitive: false,
|
|
wholeWord: false,
|
|
regex: false,
|
|
});
|
|
const searchIsOpen = jotai.useAtomValue<boolean>(searchProps.isOpen);
|
|
const caseSensitive = useAtomValueSafe<boolean>(searchProps.caseSensitive);
|
|
const wholeWord = useAtomValueSafe<boolean>(searchProps.wholeWord);
|
|
const regex = useAtomValueSafe<boolean>(searchProps.regex);
|
|
const searchVal = jotai.useAtomValue<string>(searchProps.searchValue);
|
|
const searchDecorations = React.useMemo(
|
|
() => ({
|
|
matchOverviewRuler: "#000000",
|
|
activeMatchColorOverviewRuler: "#000000",
|
|
activeMatchBorder: "#FF9632",
|
|
matchBorder: "#FFFF00",
|
|
}),
|
|
[]
|
|
);
|
|
const searchOpts = React.useMemo<ISearchOptions>(
|
|
() => ({
|
|
regex,
|
|
wholeWord,
|
|
caseSensitive,
|
|
decorations: searchDecorations,
|
|
}),
|
|
[regex, wholeWord, caseSensitive]
|
|
);
|
|
const handleSearchError = React.useCallback((e: Error) => {
|
|
console.warn("search error:", e);
|
|
}, []);
|
|
const executeSearch = React.useCallback(
|
|
(searchText: string, direction: "next" | "previous") => {
|
|
if (searchText === "") {
|
|
model.termRef.current?.searchAddon.clearDecorations();
|
|
return;
|
|
}
|
|
try {
|
|
model.termRef.current?.searchAddon[direction === "next" ? "findNext" : "findPrevious"](
|
|
searchText,
|
|
searchOpts
|
|
);
|
|
} catch (e) {
|
|
handleSearchError(e);
|
|
}
|
|
},
|
|
[searchOpts, handleSearchError]
|
|
);
|
|
searchProps.onSearch = React.useCallback(
|
|
(searchText: string) => executeSearch(searchText, "previous"),
|
|
[executeSearch]
|
|
);
|
|
searchProps.onPrev = React.useCallback(() => executeSearch(searchVal, "previous"), [executeSearch, searchVal]);
|
|
searchProps.onNext = React.useCallback(() => executeSearch(searchVal, "next"), [executeSearch, searchVal]);
|
|
// Return input focus to the terminal when the search is closed
|
|
React.useEffect(() => {
|
|
if (!searchIsOpen) {
|
|
model.giveFocus();
|
|
}
|
|
}, [searchIsOpen]);
|
|
// rerun search when the searchOpts change
|
|
React.useEffect(() => {
|
|
model.termRef.current?.searchAddon.clearDecorations();
|
|
searchProps.onSearch(searchVal);
|
|
}, [searchOpts]);
|
|
// end search
|
|
|
|
React.useEffect(() => {
|
|
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
|
const termThemeName = globalStore.get(model.termThemeNameAtom);
|
|
const termTransparency = globalStore.get(model.termTransparencyAtom);
|
|
const termBPMAtom = getOverrideConfigAtom(blockId, "term:allowbracketedpaste");
|
|
const termMacOptionIsMetaAtom = getOverrideConfigAtom(blockId, "term:macoptionismeta");
|
|
const [termTheme, _] = computeTheme(fullConfig, termThemeName, termTransparency);
|
|
let termScrollback = 2000;
|
|
if (termSettings?.["term:scrollback"]) {
|
|
termScrollback = Math.floor(termSettings["term:scrollback"]);
|
|
}
|
|
if (blockData?.meta?.["term:scrollback"]) {
|
|
termScrollback = Math.floor(blockData.meta["term:scrollback"]);
|
|
}
|
|
if (termScrollback < 0) {
|
|
termScrollback = 0;
|
|
}
|
|
if (termScrollback > 50000) {
|
|
termScrollback = 50000;
|
|
}
|
|
const termAllowBPM = globalStore.get(termBPMAtom) ?? false;
|
|
const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false;
|
|
const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused);
|
|
const termWrap = new TermWrap(
|
|
blockId,
|
|
connectElemRef.current,
|
|
{
|
|
theme: termTheme,
|
|
fontSize: termFontSize,
|
|
fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack",
|
|
drawBoldTextInBrightColors: false,
|
|
fontWeight: "normal",
|
|
fontWeightBold: "bold",
|
|
allowTransparency: true,
|
|
scrollback: termScrollback,
|
|
allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations
|
|
ignoreBracketedPasteMode: !termAllowBPM,
|
|
macOptionIsMeta: termMacOptionIsMeta,
|
|
},
|
|
{
|
|
keydownHandler: model.handleTerminalKeydown.bind(model),
|
|
useWebGl: !termSettings?.["term:disablewebgl"],
|
|
sendDataHandler: model.sendDataToController.bind(model),
|
|
}
|
|
);
|
|
(window as any).term = termWrap;
|
|
model.termRef.current = termWrap;
|
|
const rszObs = new ResizeObserver(() => {
|
|
termWrap.handleResize_debounced();
|
|
});
|
|
rszObs.observe(connectElemRef.current);
|
|
termWrap.onSearchResultsDidChange = (results) => {
|
|
globalStore.set(searchProps.resultsIndex, results.resultIndex);
|
|
globalStore.set(searchProps.resultsCount, results.resultCount);
|
|
};
|
|
fireAndForget(termWrap.initTerminal.bind(termWrap));
|
|
if (wasFocused) {
|
|
setTimeout(() => {
|
|
model.giveFocus();
|
|
}, 10);
|
|
}
|
|
return () => {
|
|
termWrap.dispose();
|
|
rszObs.disconnect();
|
|
};
|
|
}, [blockId, termSettings, termFontSize, connFontFamily]);
|
|
|
|
React.useEffect(() => {
|
|
if (termModeRef.current == "vdom" && termMode == "term") {
|
|
// focus the terminal
|
|
model.giveFocus();
|
|
}
|
|
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;
|
|
termViewport.style.zIndex = "var(--zindex-xterm-viewport-overlay)";
|
|
scrollbarHideObserverRef.current.style.display = "block";
|
|
}, []);
|
|
const onScrollbarHideObserver = React.useCallback(() => {
|
|
const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement;
|
|
termViewport.style.zIndex = "auto";
|
|
scrollbarHideObserverRef.current.style.display = "none";
|
|
}, []);
|
|
|
|
const stickerConfig = {
|
|
charWidth: 8,
|
|
charHeight: 16,
|
|
rows: model.termRef.current?.terminal.rows ?? 24,
|
|
cols: model.termRef.current?.terminal.cols ?? 80,
|
|
blockId: blockId,
|
|
};
|
|
|
|
const termBg = computeBgStyleFromMeta(blockData?.meta);
|
|
|
|
return (
|
|
<div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef}>
|
|
{termBg && <div className="absolute inset-0 z-0 pointer-events-none" style={termBg} />}
|
|
<TermResyncHandler blockId={blockId} model={model} />
|
|
<TermThemeUpdater blockId={blockId} model={model} termRef={model.termRef} />
|
|
<TermStickers config={stickerConfig} />
|
|
<TermToolbarVDomNode key="vdom-toolbar" blockId={blockId} model={model} />
|
|
<TermVDomNode key="vdom" blockId={blockId} model={model} />
|
|
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}>
|
|
<div className="term-scrollbar-show-observer" onPointerOver={onScrollbarShowObserver} />
|
|
<div
|
|
ref={scrollbarHideObserverRef}
|
|
className="term-scrollbar-hide-observer"
|
|
onPointerOver={onScrollbarHideObserver}
|
|
/>
|
|
</div>
|
|
<Search {...searchProps} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { TerminalView };
|