mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-03-09 13:00:53 +01:00
Terminal search (#1654)
This adds support for searching the terminal buffer using the `@xterm/addon-search` library. It also adds three options for searching: regex, case-sensitive, and whole-word. These can be included or excluded from the search options for `useSearch` depending on whether the search backend supports it.  I didn't like any of the Font Awesome icons for these toggles so until we have time to make some of our own icons that better match the Font Awesome style, I've appropriated VSCode's icons from their [codicons font](https://github.com/microsoft/vscode-codicons). To implement the toggle-able buttons for these options, I've introduced a new HeaderElem component, `ToggleIconButton`. This is styled similarly to `IconButton`, but when you hover over it, it also shows a highlighted background and when active, it shows as fully-opaque and with an accented border. Also removes the `useDismiss` behavior for the search box to better match behavior in other apps. Also fixes the scrollbar observer from my previous PR so it's wider.
This commit is contained in:
parent
fe91d167b6
commit
da2291f889
@ -30,7 +30,7 @@ import {
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { ErrorBoundary } from "@/element/errorboundary";
|
||||
import { IconButton } from "@/element/iconbutton";
|
||||
import { IconButton, ToggleIconButton } from "@/element/iconbutton";
|
||||
import { MagnifyIcon } from "@/element/magnify";
|
||||
import { MenuButton } from "@/element/menubutton";
|
||||
import { NodeModel } from "@/layout/index";
|
||||
@ -278,6 +278,8 @@ const BlockFrame_Header = ({
|
||||
const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {
|
||||
if (elem.elemtype == "iconbutton") {
|
||||
return <IconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
|
||||
} else if (elem.elemtype == "toggleiconbutton") {
|
||||
return <ToggleIconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
|
||||
} else if (elem.elemtype == "input") {
|
||||
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
|
||||
} else if (elem.elemtype == "text") {
|
||||
|
@ -32,4 +32,17 @@
|
||||
cursor: default;
|
||||
opacity: 0.45 !important;
|
||||
}
|
||||
|
||||
&.toggle {
|
||||
border-radius: 3px;
|
||||
padding: 1px;
|
||||
&.active {
|
||||
opacity: 1;
|
||||
border: 1px solid var(--accent-color);
|
||||
padding: 0;
|
||||
}
|
||||
&:hover {
|
||||
background: var(--highlight-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,8 @@
|
||||
import { useLongClick } from "@/app/hook/useLongClick";
|
||||
import { makeIconClass } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import { forwardRef, memo, useRef } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { forwardRef, memo, useMemo, useRef } from "react";
|
||||
import "./iconbutton.scss";
|
||||
|
||||
type IconButtonProps = { decl: IconButtonDecl; className?: string };
|
||||
@ -13,15 +14,48 @@ export const IconButton = memo(
|
||||
ref = ref ?? useRef<HTMLButtonElement>(null);
|
||||
const spin = decl.iconSpin ?? false;
|
||||
useLongClick(ref, decl.click, decl.longClick, decl.disabled);
|
||||
const disabled = decl.disabled ?? false;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx("wave-iconbutton", className, decl.className, {
|
||||
disabled: decl.disabled,
|
||||
disabled,
|
||||
"no-action": decl.noAction,
|
||||
})}
|
||||
title={decl.title}
|
||||
aria-label={decl.title}
|
||||
style={{ color: decl.iconColor ?? "inherit" }}
|
||||
disabled={disabled}
|
||||
>
|
||||
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
type ToggleIconButtonProps = { decl: ToggleIconButtonDecl; className?: string };
|
||||
|
||||
export const ToggleIconButton = memo(
|
||||
forwardRef<HTMLButtonElement, ToggleIconButtonProps>(({ decl, className }, ref) => {
|
||||
const activeAtom = useMemo(() => decl.active ?? atom(false), [decl.active]);
|
||||
const [active, setActive] = useAtom(activeAtom);
|
||||
ref = ref ?? useRef<HTMLButtonElement>(null);
|
||||
const spin = decl.iconSpin ?? false;
|
||||
const title = `${decl.title}${active ? " (Active)" : ""}`;
|
||||
const disabled = decl.disabled ?? false;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx("wave-iconbutton", "toggle", className, decl.className, {
|
||||
active,
|
||||
disabled,
|
||||
"no-action": decl.noAction,
|
||||
})}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{ color: decl.iconColor ?? "inherit" }}
|
||||
onClick={() => setActive(!active)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
|
||||
</button>
|
||||
|
@ -1,3 +1,6 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -31,13 +34,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
.right-buttons {
|
||||
.right-buttons,
|
||||
.additional-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding-left: 5px;
|
||||
border-left: 1px solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.right-buttons {
|
||||
gap: 5px;
|
||||
padding-left: 4px;
|
||||
button {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.additional-buttons {
|
||||
gap: 1px;
|
||||
padding-left: 5px;
|
||||
button {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,10 +16,35 @@ const meta: Meta<typeof Search> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Popover>;
|
||||
|
||||
export const DefaultSearch: Story = {
|
||||
export const Default: Story = {
|
||||
render: (args) => {
|
||||
const props = useSearch();
|
||||
const setIsOpen = useSetAtom(props.isOpenAtom);
|
||||
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
className="viewbox"
|
||||
ref={props.anchorRef as React.RefObject<HTMLDivElement>}
|
||||
style={{
|
||||
border: "2px solid black",
|
||||
width: "100%",
|
||||
height: "200px",
|
||||
background: "var(--main-bg-color)",
|
||||
}}
|
||||
>
|
||||
<Search {...args} {...props} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const AdditionalButtons: Story = {
|
||||
render: (args) => {
|
||||
const props = useSearch({ regex: true, caseSensitive: true, wholeWord: true });
|
||||
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
@ -44,8 +69,8 @@ export const DefaultSearch: Story = {
|
||||
export const Results10: Story = {
|
||||
render: (args) => {
|
||||
const props = useSearch();
|
||||
const setIsOpen = useSetAtom(props.isOpenAtom);
|
||||
const setNumResults = useSetAtom(props.numResultsAtom);
|
||||
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
|
||||
const setNumResults = useSetAtom<number, [number], void>(props.resultsCount);
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
setNumResults(10);
|
||||
@ -71,13 +96,13 @@ export const Results10: Story = {
|
||||
export const InputAndResults10: Story = {
|
||||
render: (args) => {
|
||||
const props = useSearch();
|
||||
const setIsOpen = useSetAtom(props.isOpenAtom);
|
||||
const setNumResults = useSetAtom(props.numResultsAtom);
|
||||
const setSearch = useSetAtom(props.searchAtom);
|
||||
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
|
||||
const setNumResults = useSetAtom<number, [number], void>(props.resultsCount);
|
||||
const setSearch = useSetAtom<string, [string], void>(props.searchValue);
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
setNumResults(10);
|
||||
setSearch("search term");
|
||||
setTimeout(() => setNumResults(10), 10);
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
@ -100,13 +125,13 @@ export const InputAndResults10: Story = {
|
||||
export const LongInputAndResults10: Story = {
|
||||
render: (args) => {
|
||||
const props = useSearch();
|
||||
const setIsOpen = useSetAtom(props.isOpenAtom);
|
||||
const setNumResults = useSetAtom(props.numResultsAtom);
|
||||
const setSearch = useSetAtom(props.searchAtom);
|
||||
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
|
||||
const setNumResults = useSetAtom<number, [number], void>(props.resultsCount);
|
||||
const setSearch = useSetAtom<string, [string], void>(props.searchValue);
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
setNumResults(10);
|
||||
setSearch("search term ".repeat(10).trimEnd());
|
||||
setTimeout(() => setNumResults(10), 10);
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { autoUpdate, FloatingPortal, Middleware, offset, useDismiss, useFloating } from "@floating-ui/react";
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { autoUpdate, FloatingPortal, Middleware, offset, useFloating } from "@floating-ui/react";
|
||||
import clsx from "clsx";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom, useAtom, WritableAtom } from "jotai";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { IconButton } from "./iconbutton";
|
||||
import { IconButton, ToggleIconButton } from "./iconbutton";
|
||||
import { Input } from "./input";
|
||||
import "./search.scss";
|
||||
|
||||
@ -16,10 +19,13 @@ type SearchProps = SearchAtoms & {
|
||||
};
|
||||
|
||||
const SearchComponent = ({
|
||||
searchAtom,
|
||||
indexAtom,
|
||||
numResultsAtom,
|
||||
isOpenAtom,
|
||||
searchValue: searchAtom,
|
||||
resultsIndex: indexAtom,
|
||||
resultsCount: numResultsAtom,
|
||||
regex: regexAtom,
|
||||
caseSensitive: caseSensitiveAtom,
|
||||
wholeWord: wholeWordAtom,
|
||||
isOpen: isOpenAtom,
|
||||
anchorRef,
|
||||
offsetX = 10,
|
||||
offsetY = 10,
|
||||
@ -37,9 +43,11 @@ const SearchComponent = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
setIndex(0);
|
||||
setNumResults(0);
|
||||
if (!isOpen) {
|
||||
setSearch("");
|
||||
setIndex(0);
|
||||
setNumResults(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -62,7 +70,6 @@ const SearchComponent = ({
|
||||
if (floatingLeft < 5) {
|
||||
xOffsetCalc += 5 - floatingLeft;
|
||||
}
|
||||
console.log("offsetCalc", yOffsetCalc, xOffsetCalc);
|
||||
return {
|
||||
mainAxis: yOffsetCalc,
|
||||
crossAxis: xOffsetCalc,
|
||||
@ -72,7 +79,7 @@ const SearchComponent = ({
|
||||
);
|
||||
middleware.push(offset(offsetCallback));
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: "top-end",
|
||||
open: isOpen,
|
||||
onOpenChange: handleOpenChange,
|
||||
@ -83,8 +90,6 @@ const SearchComponent = ({
|
||||
},
|
||||
});
|
||||
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const onPrevWrapper = useCallback(
|
||||
() => (onPrev ? onPrev() : setIndex((index - 1) % numResults)),
|
||||
[onPrev, index, numResults]
|
||||
@ -112,6 +117,7 @@ const SearchComponent = ({
|
||||
elemtype: "iconbutton",
|
||||
icon: "chevron-up",
|
||||
title: "Previous Result (Shift+Enter)",
|
||||
disabled: numResults === 0,
|
||||
click: onPrevWrapper,
|
||||
};
|
||||
|
||||
@ -119,6 +125,7 @@ const SearchComponent = ({
|
||||
elemtype: "iconbutton",
|
||||
icon: "chevron-down",
|
||||
title: "Next Result (Enter)",
|
||||
disabled: numResults === 0,
|
||||
click: onNextWrapper,
|
||||
};
|
||||
|
||||
@ -129,11 +136,15 @@ const SearchComponent = ({
|
||||
click: () => setIsOpen(false),
|
||||
};
|
||||
|
||||
const regexDecl = createToggleButtonDecl(regexAtom, "custom@regex", "Regular Expression");
|
||||
const wholeWordDecl = createToggleButtonDecl(wholeWordAtom, "custom@whole-word", "Whole Word");
|
||||
const caseSensitiveDecl = createToggleButtonDecl(caseSensitiveAtom, "custom@case-sensitive", "Case Sensitive");
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<FloatingPortal>
|
||||
<div className="search-container" style={{ ...floatingStyles }} {...dismiss} ref={refs.setFloating}>
|
||||
<div className="search-container" style={{ ...floatingStyles }} ref={refs.setFloating}>
|
||||
<Input
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
@ -148,6 +159,15 @@ const SearchComponent = ({
|
||||
>
|
||||
{index + 1}/{numResults}
|
||||
</div>
|
||||
|
||||
{(caseSensitiveDecl || wholeWordDecl || regexDecl) && (
|
||||
<div className="additional-buttons">
|
||||
{caseSensitiveDecl && <ToggleIconButton decl={caseSensitiveDecl} />}
|
||||
{wholeWordDecl && <ToggleIconButton decl={wholeWordDecl} />}
|
||||
{regexDecl && <ToggleIconButton decl={regexDecl} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="right-buttons">
|
||||
<IconButton decl={prevDecl} />
|
||||
<IconButton decl={nextDecl} />
|
||||
@ -162,16 +182,49 @@ const SearchComponent = ({
|
||||
|
||||
export const Search = memo(SearchComponent) as typeof SearchComponent;
|
||||
|
||||
export function useSearch(anchorRef?: React.RefObject<HTMLElement>, viewModel?: ViewModel): SearchProps {
|
||||
type SearchOptions = {
|
||||
anchorRef?: React.RefObject<HTMLElement>;
|
||||
viewModel?: ViewModel;
|
||||
regex?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
wholeWord?: boolean;
|
||||
};
|
||||
|
||||
export function useSearch(options?: SearchOptions): SearchProps {
|
||||
const searchAtoms: SearchAtoms = useMemo(
|
||||
() => ({ searchAtom: atom(""), indexAtom: atom(0), numResultsAtom: atom(0), isOpenAtom: atom(false) }),
|
||||
() => ({
|
||||
searchValue: atom(""),
|
||||
resultsIndex: atom(0),
|
||||
resultsCount: atom(0),
|
||||
isOpen: atom(false),
|
||||
regex: options?.regex !== undefined ? atom(options.regex) : undefined,
|
||||
caseSensitive: options?.caseSensitive !== undefined ? atom(options.caseSensitive) : undefined,
|
||||
wholeWord: options?.wholeWord !== undefined ? atom(options.wholeWord) : undefined,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
anchorRef ??= useRef(null);
|
||||
const anchorRef = options?.anchorRef ?? useRef(null);
|
||||
useEffect(() => {
|
||||
if (viewModel) {
|
||||
viewModel.searchAtoms = searchAtoms;
|
||||
if (options?.viewModel) {
|
||||
options.viewModel.searchAtoms = searchAtoms;
|
||||
return () => {
|
||||
options.viewModel.searchAtoms = undefined;
|
||||
};
|
||||
}
|
||||
}, [viewModel]);
|
||||
}, [options?.viewModel]);
|
||||
return { ...searchAtoms, anchorRef };
|
||||
}
|
||||
|
||||
const createToggleButtonDecl = (
|
||||
atom: WritableAtom<boolean, [boolean], void> | undefined,
|
||||
icon: string,
|
||||
title: string
|
||||
): ToggleIconButtonDecl =>
|
||||
atom
|
||||
? {
|
||||
elemtype: "toggleiconbutton",
|
||||
icon,
|
||||
title,
|
||||
active: atom,
|
||||
}
|
||||
: null;
|
||||
|
@ -321,21 +321,22 @@ function registerGlobalKeys() {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
function activateSearch(): boolean {
|
||||
console.log("activateSearch");
|
||||
function activateSearch(event: WaveKeyboardEvent): boolean {
|
||||
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
|
||||
// Ctrl+f is reserved in most shells
|
||||
if (event.control && bcm.viewModel.viewType == "term") {
|
||||
return false;
|
||||
}
|
||||
if (bcm.viewModel.searchAtoms) {
|
||||
console.log("activateSearch2");
|
||||
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, true);
|
||||
globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function deactivateSearch(): boolean {
|
||||
console.log("deactivateSearch");
|
||||
const bcm = getBlockComponentModel(getFocusedBlockInStaticTab());
|
||||
if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpenAtom)) {
|
||||
globalStore.set(bcm.viewModel.searchAtoms.isOpenAtom, false);
|
||||
if (bcm.viewModel.searchAtoms && globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
|
||||
globalStore.set(bcm.viewModel.searchAtoms.isOpen, false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -126,13 +126,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The 18px width is the width of the scrollbar plus the margin
|
||||
.term-scrollbar-show-observer {
|
||||
z-index: calc(var(--zindex-xterm-viewport-overlay) - 1);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 12px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.term-scrollbar-hide-observer {
|
||||
@ -142,7 +143,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: calc(100% - 12px);
|
||||
width: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.terminal {
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
import { Block, SubBlock } from "@/app/block/block";
|
||||
import { BlockNodeModel } from "@/app/block/blocktypes";
|
||||
import { Search, useSearch } from "@/app/element/search";
|
||||
import { getAllGlobalKeyBindings } from "@/app/store/keymodel";
|
||||
import { waveEventSubscribe } from "@/app/store/wps";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
@ -24,7 +25,8 @@ import {
|
||||
} from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import * as keyutil from "@/util/keyutil";
|
||||
import { boundNumber } from "@/util/util";
|
||||
import { boundNumber, fireAndForget, useAtomValueSafe } from "@/util/util";
|
||||
import { ISearchOptions } from "@xterm/addon-search";
|
||||
import clsx from "clsx";
|
||||
import debug from "debug";
|
||||
import * as jotai from "jotai";
|
||||
@ -71,6 +73,7 @@ class TermViewModel implements ViewModel {
|
||||
shellProcStatusUnsubFn: () => void;
|
||||
isCmdController: jotai.Atom<boolean>;
|
||||
isRestarting: jotai.PrimitiveAtom<boolean>;
|
||||
searchAtoms?: SearchAtoms;
|
||||
|
||||
constructor(blockId: string, nodeModel: BlockNodeModel) {
|
||||
this.viewType = "term";
|
||||
@ -361,6 +364,10 @@ class TermViewModel implements ViewModel {
|
||||
}
|
||||
|
||||
giveFocus(): boolean {
|
||||
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
|
||||
console.log("search is open, not giving focus");
|
||||
return true;
|
||||
}
|
||||
let termMode = globalStore.get(this.termMode);
|
||||
if (termMode == "term") {
|
||||
if (this.termRef?.current?.terminal) {
|
||||
@ -785,6 +792,76 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"];
|
||||
|
||||
// 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);
|
||||
@ -816,6 +893,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
fontWeightBold: "bold",
|
||||
allowTransparency: true,
|
||||
scrollback: termScrollback,
|
||||
allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations
|
||||
},
|
||||
{
|
||||
keydownHandler: model.handleTerminalKeydown.bind(model),
|
||||
@ -828,7 +906,11 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
termWrap.handleResize_debounced();
|
||||
});
|
||||
rszObs.observe(connectElemRef.current);
|
||||
termWrap.initTerminal();
|
||||
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();
|
||||
@ -867,6 +949,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
cols: model.termRef.current?.terminal.cols ?? 80,
|
||||
blockId: blockId,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef}>
|
||||
<TermResyncHandler blockId={blockId} model={model} />
|
||||
@ -882,6 +965,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
|
||||
onPointerOver={onScrollbarHideObserver}
|
||||
/>
|
||||
</div>
|
||||
<Search {...searchProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -9,6 +9,7 @@ import { PLATFORM, WOS, atoms, fetchWaveFile, getSettingsKeyAtom, globalStore, o
|
||||
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";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
@ -50,12 +51,15 @@ export class TermWrap {
|
||||
terminal: Terminal;
|
||||
connectElem: HTMLDivElement;
|
||||
fitAddon: FitAddon;
|
||||
searchAddon: SearchAddon;
|
||||
serializeAddon: SerializeAddon;
|
||||
mainFileSubject: SubjectWithRef<WSFileEventData>;
|
||||
loaded: boolean;
|
||||
heldData: Uint8Array[];
|
||||
handleResize_debounced: () => void;
|
||||
hasResized: boolean;
|
||||
onSearchResultsDidChange?: (result: { resultIndex: number; resultCount: number }) => void;
|
||||
private toDispose: TermTypes.IDisposable[] = [];
|
||||
|
||||
constructor(
|
||||
blockId: string,
|
||||
@ -72,6 +76,8 @@ export class TermWrap {
|
||||
this.fitAddon = new FitAddon();
|
||||
this.fitAddon.noScrollbar = PLATFORM == "darwin";
|
||||
this.serializeAddon = new SerializeAddon();
|
||||
this.searchAddon = new SearchAddon();
|
||||
this.terminal.loadAddon(this.searchAddon);
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(this.serializeAddon);
|
||||
this.terminal.loadAddon(
|
||||
@ -93,9 +99,11 @@ export class TermWrap {
|
||||
);
|
||||
if (WebGLSupported && waveOptions.useWebGl) {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
});
|
||||
this.toDispose.push(
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
})
|
||||
);
|
||||
this.terminal.loadAddon(webglAddon);
|
||||
if (!loggedWebGL) {
|
||||
console.log("loaded webgl!");
|
||||
@ -137,18 +145,23 @@ export class TermWrap {
|
||||
|
||||
async initTerminal() {
|
||||
const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect");
|
||||
this.terminal.onData(this.handleTermData.bind(this));
|
||||
this.terminal.onSelectionChange(
|
||||
debounce(50, () => {
|
||||
if (!globalStore.get(copyOnSelectAtom)) {
|
||||
return;
|
||||
}
|
||||
const selectedText = this.terminal.getSelection();
|
||||
if (selectedText.length > 0) {
|
||||
navigator.clipboard.writeText(selectedText);
|
||||
}
|
||||
})
|
||||
this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this)));
|
||||
this.toDispose.push(
|
||||
this.terminal.onSelectionChange(
|
||||
debounce(50, () => {
|
||||
if (!globalStore.get(copyOnSelectAtom)) {
|
||||
return;
|
||||
}
|
||||
const selectedText = this.terminal.getSelection();
|
||||
if (selectedText.length > 0) {
|
||||
navigator.clipboard.writeText(selectedText);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
if (this.onSearchResultsDidChange != null) {
|
||||
this.toDispose.push(this.searchAddon.onDidChangeResults(this.onSearchResultsDidChange.bind(this)));
|
||||
}
|
||||
this.mainFileSubject = getFileSubject(this.blockId, TermFileName);
|
||||
this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this));
|
||||
try {
|
||||
@ -161,6 +174,11 @@ export class TermWrap {
|
||||
|
||||
dispose() {
|
||||
this.terminal.dispose();
|
||||
this.toDispose.forEach((d) => {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch (_) {}
|
||||
});
|
||||
this.mainFileSubject.release();
|
||||
}
|
||||
|
||||
|
@ -310,7 +310,7 @@ export class WebViewModel implements ViewModel {
|
||||
fireAndForget(() => ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { url }));
|
||||
globalStore.set(this.url, url);
|
||||
if (this.searchAtoms) {
|
||||
globalStore.set(this.searchAtoms.isOpenAtom, false);
|
||||
globalStore.set(this.searchAtoms.isOpen, false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,7 +406,7 @@ export class WebViewModel implements ViewModel {
|
||||
|
||||
giveFocus(): boolean {
|
||||
console.log("webview giveFocus");
|
||||
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpenAtom)) {
|
||||
if (this.searchAtoms && globalStore.get(this.searchAtoms.isOpen)) {
|
||||
console.log("search is open, not giving focus");
|
||||
return true;
|
||||
}
|
||||
@ -570,11 +570,11 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
|
||||
const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1;
|
||||
|
||||
// Search
|
||||
const searchProps = useSearch(model.webviewRef, model);
|
||||
const searchVal = useAtomValue<string>(searchProps.searchAtom);
|
||||
const setSearchIndex = useSetAtom(searchProps.indexAtom);
|
||||
const setNumSearchResults = useSetAtom(searchProps.numResultsAtom);
|
||||
const onSearch = useCallback((search: string) => {
|
||||
const searchProps = useSearch({ anchorRef: model.webviewRef, viewModel: model });
|
||||
const searchVal = useAtomValue<string>(searchProps.searchValue);
|
||||
const setSearchIndex = useSetAtom(searchProps.resultsIndex);
|
||||
const setNumSearchResults = useSetAtom(searchProps.resultsCount);
|
||||
searchProps.onSearch = useCallback((search: string) => {
|
||||
try {
|
||||
if (search) {
|
||||
model.webviewRef.current?.findInPage(search, { findNext: true });
|
||||
@ -585,7 +585,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
|
||||
console.error("Failed to search", e);
|
||||
}
|
||||
}, []);
|
||||
const onSearchNext = useCallback(() => {
|
||||
searchProps.onNext = useCallback(() => {
|
||||
try {
|
||||
console.log("search next", searchVal);
|
||||
model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: true });
|
||||
@ -593,7 +593,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
|
||||
console.error("Failed to search next", e);
|
||||
}
|
||||
}, [searchVal]);
|
||||
const onSearchPrev = useCallback(() => {
|
||||
searchProps.onPrev = useCallback(() => {
|
||||
try {
|
||||
console.log("search prev", searchVal);
|
||||
model.webviewRef.current?.findInPage(searchVal, { findNext: false, forward: false });
|
||||
@ -782,7 +782,7 @@ const WebView = memo(({ model, onFailLoad }: WebViewProps) => {
|
||||
<div>{errorText}</div>
|
||||
</div>
|
||||
)}
|
||||
<Search {...searchProps} onSearch={onSearch} onNext={onSearchNext} onPrev={onSearchPrev} />
|
||||
<Search {...searchProps} />
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
28
frontend/types/custom.d.ts
vendored
28
frontend/types/custom.d.ts
vendored
@ -143,6 +143,7 @@ declare global {
|
||||
|
||||
type HeaderElem =
|
||||
| IconButtonDecl
|
||||
| ToggleIconButtonDecl
|
||||
| HeaderText
|
||||
| HeaderInput
|
||||
| HeaderDiv
|
||||
@ -150,19 +151,27 @@ declare global {
|
||||
| ConnectionButton
|
||||
| MenuButton;
|
||||
|
||||
type IconButtonDecl = {
|
||||
elemtype: "iconbutton";
|
||||
type IconButtonCommon = {
|
||||
icon: string | React.ReactNode;
|
||||
iconColor?: string;
|
||||
iconSpin?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
click?: (e: React.MouseEvent<any>) => void;
|
||||
longClick?: (e: React.MouseEvent<any>) => void;
|
||||
disabled?: boolean;
|
||||
noAction?: boolean;
|
||||
};
|
||||
|
||||
type IconButtonDecl = IconButtonCommon & {
|
||||
elemtype: "iconbutton";
|
||||
click?: (e: React.MouseEvent<any>) => void;
|
||||
longClick?: (e: React.MouseEvent<any>) => void;
|
||||
};
|
||||
|
||||
type ToggleIconButtonDecl = IconButtonCommon & {
|
||||
elemtype: "toggleiconbutton";
|
||||
active: jotai.WritableAtom<boolean, [boolean], void>;
|
||||
};
|
||||
|
||||
type HeaderTextButton = {
|
||||
elemtype: "textbutton";
|
||||
text: string;
|
||||
@ -229,10 +238,13 @@ declare global {
|
||||
} & MenuButtonProps;
|
||||
|
||||
type SearchAtoms = {
|
||||
searchAtom: PrimitiveAtom<string>;
|
||||
indexAtom: PrimitiveAtom<number>;
|
||||
numResultsAtom: PrimitiveAtom<number>;
|
||||
isOpenAtom: PrimitiveAtom<boolean>;
|
||||
searchValue: PrimitiveAtom<string>;
|
||||
resultsIndex: PrimitiveAtom<number>;
|
||||
resultsCount: PrimitiveAtom<number>;
|
||||
isOpen: PrimitiveAtom<boolean>;
|
||||
regex?: PrimitiveAtom<boolean>;
|
||||
caseSensitive?: PrimitiveAtom<boolean>;
|
||||
wholeWord?: PrimitiveAtom<boolean>;
|
||||
};
|
||||
|
||||
interface ViewModel {
|
||||
|
@ -93,6 +93,7 @@
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
|
2
public/fontawesome/css/custom-icons.min.css
vendored
2
public/fontawesome/css/custom-icons.min.css
vendored
@ -1 +1 @@
|
||||
@charset "utf-8";.fak.fa-wave-logo-outline,.fa-kit.fa-wave-logo-outline{--fa:"";--fa--fa:""}.fak.fa-wave-logo-solid,.fa-kit.fa-wave-logo-solid{--fa:"";--fa--fa:""}.fak,.fa-kit{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:var(--fa-display,inline-block);font-variant:normal;text-rendering:auto;font-family:Font Awesome Kit;font-style:normal;font-weight:400;line-height:1}.fak:before,.fa-kit:before{content:var(--fa)}@font-face{font-family:Font Awesome Kit;font-style:normal;font-display:block;src:url(../webfonts/custom-icons.woff2)format("woff2"),url(../webfonts/custom-icons.ttf)format("truetype")}
|
||||
@charset "utf-8";.fak.fa-case-sensitive,.fa-kit.fa-case-sensitive{--fa:"";--fa--fa:""}.fak.fa-regex,.fa-kit.fa-regex{--fa:"";--fa--fa:""}.fak.fa-wave-logo-outline,.fa-kit.fa-wave-logo-outline{--fa:"";--fa--fa:""}.fak.fa-wave-logo-solid,.fa-kit.fa-wave-logo-solid{--fa:"";--fa--fa:""}.fak.fa-whole-word,.fa-kit.fa-whole-word{--fa:"";--fa--fa:""}.fak,.fa-kit{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:var(--fa-display,inline-block);font-variant:normal;text-rendering:auto;font-family:Font Awesome Kit;font-style:normal;font-weight:400;line-height:1}.fak:before,.fa-kit:before{content:var(--fa)}@font-face{font-family:Font Awesome Kit;font-style:normal;font-display:block;src:url(../webfonts/custom-icons.woff2)format("woff2"),url(../webfonts/custom-icons.ttf)format("truetype")}
|
Binary file not shown.
10
yarn.lock
10
yarn.lock
@ -7314,6 +7314,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xterm/addon-search@npm:^0.15.0":
|
||||
version: 0.15.0
|
||||
resolution: "@xterm/addon-search@npm:0.15.0"
|
||||
peerDependencies:
|
||||
"@xterm/xterm": ^5.0.0
|
||||
checksum: 10c0/2d68233d234eabc9ffe1bc9e4fcd28cd50c1f8c316b0e71a81ee2005b5e4da87c1c0361f2aa117ec566afbbbc35cd37456c7eb889ebc936416d14953c82e5a2a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xterm/addon-serialize@npm:^0.13.0":
|
||||
version: 0.13.0
|
||||
resolution: "@xterm/addon-serialize@npm:0.13.0"
|
||||
@ -21982,6 +21991,7 @@ __metadata:
|
||||
"@vitejs/plugin-react-swc": "npm:^3.7.2"
|
||||
"@vitest/coverage-istanbul": "npm:^2.1.8"
|
||||
"@xterm/addon-fit": "npm:^0.10.0"
|
||||
"@xterm/addon-search": "npm:^0.15.0"
|
||||
"@xterm/addon-serialize": "npm:^0.13.0"
|
||||
"@xterm/addon-web-links": "npm:^0.11.0"
|
||||
"@xterm/addon-webgl": "npm:^0.18.0"
|
||||
|
Loading…
Reference in New Issue
Block a user