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.


![image](https://github.com/user-attachments/assets/e0b7e2ed-641b-463f-94a2-f24969fb3b06)

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:
Evan Simkowitz 2025-01-01 13:43:02 -05:00 committed by GitHub
parent fe91d167b6
commit da2291f889
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 352 additions and 83 deletions

View File

@ -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") {

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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>
);
};

View File

@ -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();
}

View File

@ -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>
);
});

View File

@ -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 {

View File

@ -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",

View File

@ -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")}

View File

@ -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"