Skip to content

Commit da2291f

Browse files
authored
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.
1 parent fe91d16 commit da2291f

File tree

16 files changed

+352
-83
lines changed

16 files changed

+352
-83
lines changed

frontend/app/block/blockframe.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
import { RpcApi } from "@/app/store/wshclientapi";
3131
import { TabRpcClient } from "@/app/store/wshrpcutil";
3232
import { ErrorBoundary } from "@/element/errorboundary";
33-
import { IconButton } from "@/element/iconbutton";
33+
import { IconButton, ToggleIconButton } from "@/element/iconbutton";
3434
import { MagnifyIcon } from "@/element/magnify";
3535
import { MenuButton } from "@/element/menubutton";
3636
import { NodeModel } from "@/layout/index";
@@ -278,6 +278,8 @@ const BlockFrame_Header = ({
278278
const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => {
279279
if (elem.elemtype == "iconbutton") {
280280
return <IconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
281+
} else if (elem.elemtype == "toggleiconbutton") {
282+
return <ToggleIconButton decl={elem} className={clsx("block-frame-header-iconbutton", elem.className)} />;
281283
} else if (elem.elemtype == "input") {
282284
return <Input decl={elem} className={clsx("block-frame-input", elem.className)} preview={preview} />;
283285
} else if (elem.elemtype == "text") {

frontend/app/element/iconbutton.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,17 @@
3232
cursor: default;
3333
opacity: 0.45 !important;
3434
}
35+
36+
&.toggle {
37+
border-radius: 3px;
38+
padding: 1px;
39+
&.active {
40+
opacity: 1;
41+
border: 1px solid var(--accent-color);
42+
padding: 0;
43+
}
44+
&:hover {
45+
background: var(--highlight-bg-color);
46+
}
47+
}
3548
}

frontend/app/element/iconbutton.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import { useLongClick } from "@/app/hook/useLongClick";
55
import { makeIconClass } from "@/util/util";
66
import clsx from "clsx";
7-
import { forwardRef, memo, useRef } from "react";
7+
import { atom, useAtom } from "jotai";
8+
import { forwardRef, memo, useMemo, useRef } from "react";
89
import "./iconbutton.scss";
910

1011
type IconButtonProps = { decl: IconButtonDecl; className?: string };
@@ -13,15 +14,48 @@ export const IconButton = memo(
1314
ref = ref ?? useRef<HTMLButtonElement>(null);
1415
const spin = decl.iconSpin ?? false;
1516
useLongClick(ref, decl.click, decl.longClick, decl.disabled);
17+
const disabled = decl.disabled ?? false;
1618
return (
1719
<button
1820
ref={ref}
1921
className={clsx("wave-iconbutton", className, decl.className, {
20-
disabled: decl.disabled,
22+
disabled,
2123
"no-action": decl.noAction,
2224
})}
2325
title={decl.title}
26+
aria-label={decl.title}
2427
style={{ color: decl.iconColor ?? "inherit" }}
28+
disabled={disabled}
29+
>
30+
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
31+
</button>
32+
);
33+
})
34+
);
35+
36+
type ToggleIconButtonProps = { decl: ToggleIconButtonDecl; className?: string };
37+
38+
export const ToggleIconButton = memo(
39+
forwardRef<HTMLButtonElement, ToggleIconButtonProps>(({ decl, className }, ref) => {
40+
const activeAtom = useMemo(() => decl.active ?? atom(false), [decl.active]);
41+
const [active, setActive] = useAtom(activeAtom);
42+
ref = ref ?? useRef<HTMLButtonElement>(null);
43+
const spin = decl.iconSpin ?? false;
44+
const title = `${decl.title}${active ? " (Active)" : ""}`;
45+
const disabled = decl.disabled ?? false;
46+
return (
47+
<button
48+
ref={ref}
49+
className={clsx("wave-iconbutton", "toggle", className, decl.className, {
50+
active,
51+
disabled,
52+
"no-action": decl.noAction,
53+
})}
54+
title={title}
55+
aria-label={title}
56+
style={{ color: decl.iconColor ?? "inherit" }}
57+
onClick={() => setActive(!active)}
58+
disabled={disabled}
2559
>
2660
{typeof decl.icon === "string" ? <i className={makeIconClass(decl.icon, true, { spin })} /> : decl.icon}
2761
</button>

frontend/app/element/search.scss

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Copyright 2024, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
14
.search-container {
25
display: flex;
36
flex-direction: row;
@@ -31,13 +34,25 @@
3134
}
3235
}
3336

34-
.right-buttons {
37+
.right-buttons,
38+
.additional-buttons {
3539
display: flex;
36-
gap: 5px;
37-
padding-left: 5px;
3840
border-left: 1px solid var(--modal-border-color);
41+
}
42+
43+
.right-buttons {
44+
gap: 5px;
45+
padding-left: 4px;
3946
button {
4047
font-size: 12px;
4148
}
4249
}
50+
51+
.additional-buttons {
52+
gap: 1px;
53+
padding-left: 5px;
54+
button {
55+
font-size: 14px;
56+
}
57+
}
4358
}

frontend/app/element/search.stories.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,35 @@ const meta: Meta<typeof Search> = {
1616
export default meta;
1717
type Story = StoryObj<typeof Popover>;
1818

19-
export const DefaultSearch: Story = {
19+
export const Default: Story = {
2020
render: (args) => {
2121
const props = useSearch();
22-
const setIsOpen = useSetAtom(props.isOpenAtom);
22+
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
23+
useEffect(() => {
24+
setIsOpen(true);
25+
}, []);
26+
return (
27+
<div
28+
className="viewbox"
29+
ref={props.anchorRef as React.RefObject<HTMLDivElement>}
30+
style={{
31+
border: "2px solid black",
32+
width: "100%",
33+
height: "200px",
34+
background: "var(--main-bg-color)",
35+
}}
36+
>
37+
<Search {...args} {...props} />
38+
</div>
39+
);
40+
},
41+
args: {},
42+
};
43+
44+
export const AdditionalButtons: Story = {
45+
render: (args) => {
46+
const props = useSearch({ regex: true, caseSensitive: true, wholeWord: true });
47+
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
2348
useEffect(() => {
2449
setIsOpen(true);
2550
}, []);
@@ -44,8 +69,8 @@ export const DefaultSearch: Story = {
4469
export const Results10: Story = {
4570
render: (args) => {
4671
const props = useSearch();
47-
const setIsOpen = useSetAtom(props.isOpenAtom);
48-
const setNumResults = useSetAtom(props.numResultsAtom);
72+
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
73+
const setNumResults = useSetAtom<number, [number], void>(props.resultsCount);
4974
useEffect(() => {
5075
setIsOpen(true);
5176
setNumResults(10);
@@ -71,13 +96,13 @@ export const Results10: Story = {
7196
export const InputAndResults10: Story = {
7297
render: (args) => {
7398
const props = useSearch();
74-
const setIsOpen = useSetAtom(props.isOpenAtom);
75-
const setNumResults = useSetAtom(props.numResultsAtom);
76-
const setSearch = useSetAtom(props.searchAtom);
99+
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
100+
const setNumResults = useSetAtom<number, [number], void>(props.resultsCount);
101+
const setSearch = useSetAtom<string, [string], void>(props.searchValue);
77102
useEffect(() => {
78103
setIsOpen(true);
79-
setNumResults(10);
80104
setSearch("search term");
105+
setTimeout(() => setNumResults(10), 10);
81106
}, []);
82107
return (
83108
<div
@@ -100,13 +125,13 @@ export const InputAndResults10: Story = {
100125
export const LongInputAndResults10: Story = {
101126
render: (args) => {
102127
const props = useSearch();
103-
const setIsOpen = useSetAtom(props.isOpenAtom);
104-
const setNumResults = useSetAtom(props.numResultsAtom);
105-
const setSearch = useSetAtom(props.searchAtom);
128+
const setIsOpen = useSetAtom<boolean, [boolean], void>(props.isOpen);
129+
const setNumResults = useSetAtom<number, [number], void>(props.resultsCount);
130+
const setSearch = useSetAtom<string, [string], void>(props.searchValue);
106131
useEffect(() => {
107132
setIsOpen(true);
108-
setNumResults(10);
109133
setSearch("search term ".repeat(10).trimEnd());
134+
setTimeout(() => setNumResults(10), 10);
110135
}, []);
111136
return (
112137
<div

frontend/app/element/search.tsx

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { autoUpdate, FloatingPortal, Middleware, offset, useDismiss, useFloating } from "@floating-ui/react";
1+
// Copyright 2024, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { autoUpdate, FloatingPortal, Middleware, offset, useFloating } from "@floating-ui/react";
25
import clsx from "clsx";
3-
import { atom, useAtom } from "jotai";
6+
import { atom, useAtom, WritableAtom } from "jotai";
47
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
5-
import { IconButton } from "./iconbutton";
8+
import { IconButton, ToggleIconButton } from "./iconbutton";
69
import { Input } from "./input";
710
import "./search.scss";
811

@@ -16,10 +19,13 @@ type SearchProps = SearchAtoms & {
1619
};
1720

1821
const SearchComponent = ({
19-
searchAtom,
20-
indexAtom,
21-
numResultsAtom,
22-
isOpenAtom,
22+
searchValue: searchAtom,
23+
resultsIndex: indexAtom,
24+
resultsCount: numResultsAtom,
25+
regex: regexAtom,
26+
caseSensitive: caseSensitiveAtom,
27+
wholeWord: wholeWordAtom,
28+
isOpen: isOpenAtom,
2329
anchorRef,
2430
offsetX = 10,
2531
offsetY = 10,
@@ -37,9 +43,11 @@ const SearchComponent = ({
3743
}, []);
3844

3945
useEffect(() => {
40-
setSearch("");
41-
setIndex(0);
42-
setNumResults(0);
46+
if (!isOpen) {
47+
setSearch("");
48+
setIndex(0);
49+
setNumResults(0);
50+
}
4351
}, [isOpen]);
4452

4553
useEffect(() => {
@@ -62,7 +70,6 @@ const SearchComponent = ({
6270
if (floatingLeft < 5) {
6371
xOffsetCalc += 5 - floatingLeft;
6472
}
65-
console.log("offsetCalc", yOffsetCalc, xOffsetCalc);
6673
return {
6774
mainAxis: yOffsetCalc,
6875
crossAxis: xOffsetCalc,
@@ -72,7 +79,7 @@ const SearchComponent = ({
7279
);
7380
middleware.push(offset(offsetCallback));
7481

75-
const { refs, floatingStyles, context } = useFloating({
82+
const { refs, floatingStyles } = useFloating({
7683
placement: "top-end",
7784
open: isOpen,
7885
onOpenChange: handleOpenChange,
@@ -83,8 +90,6 @@ const SearchComponent = ({
8390
},
8491
});
8592

86-
const dismiss = useDismiss(context);
87-
8893
const onPrevWrapper = useCallback(
8994
() => (onPrev ? onPrev() : setIndex((index - 1) % numResults)),
9095
[onPrev, index, numResults]
@@ -112,13 +117,15 @@ const SearchComponent = ({
112117
elemtype: "iconbutton",
113118
icon: "chevron-up",
114119
title: "Previous Result (Shift+Enter)",
120+
disabled: numResults === 0,
115121
click: onPrevWrapper,
116122
};
117123

118124
const nextDecl: IconButtonDecl = {
119125
elemtype: "iconbutton",
120126
icon: "chevron-down",
121127
title: "Next Result (Enter)",
128+
disabled: numResults === 0,
122129
click: onNextWrapper,
123130
};
124131

@@ -129,11 +136,15 @@ const SearchComponent = ({
129136
click: () => setIsOpen(false),
130137
};
131138

139+
const regexDecl = createToggleButtonDecl(regexAtom, "custom@regex", "Regular Expression");
140+
const wholeWordDecl = createToggleButtonDecl(wholeWordAtom, "custom@whole-word", "Whole Word");
141+
const caseSensitiveDecl = createToggleButtonDecl(caseSensitiveAtom, "custom@case-sensitive", "Case Sensitive");
142+
132143
return (
133144
<>
134145
{isOpen && (
135146
<FloatingPortal>
136-
<div className="search-container" style={{ ...floatingStyles }} {...dismiss} ref={refs.setFloating}>
147+
<div className="search-container" style={{ ...floatingStyles }} ref={refs.setFloating}>
137148
<Input
138149
placeholder="Search"
139150
value={search}
@@ -148,6 +159,15 @@ const SearchComponent = ({
148159
>
149160
{index + 1}/{numResults}
150161
</div>
162+
163+
{(caseSensitiveDecl || wholeWordDecl || regexDecl) && (
164+
<div className="additional-buttons">
165+
{caseSensitiveDecl && <ToggleIconButton decl={caseSensitiveDecl} />}
166+
{wholeWordDecl && <ToggleIconButton decl={wholeWordDecl} />}
167+
{regexDecl && <ToggleIconButton decl={regexDecl} />}
168+
</div>
169+
)}
170+
151171
<div className="right-buttons">
152172
<IconButton decl={prevDecl} />
153173
<IconButton decl={nextDecl} />
@@ -162,16 +182,49 @@ const SearchComponent = ({
162182

163183
export const Search = memo(SearchComponent) as typeof SearchComponent;
164184

165-
export function useSearch(anchorRef?: React.RefObject<HTMLElement>, viewModel?: ViewModel): SearchProps {
185+
type SearchOptions = {
186+
anchorRef?: React.RefObject<HTMLElement>;
187+
viewModel?: ViewModel;
188+
regex?: boolean;
189+
caseSensitive?: boolean;
190+
wholeWord?: boolean;
191+
};
192+
193+
export function useSearch(options?: SearchOptions): SearchProps {
166194
const searchAtoms: SearchAtoms = useMemo(
167-
() => ({ searchAtom: atom(""), indexAtom: atom(0), numResultsAtom: atom(0), isOpenAtom: atom(false) }),
195+
() => ({
196+
searchValue: atom(""),
197+
resultsIndex: atom(0),
198+
resultsCount: atom(0),
199+
isOpen: atom(false),
200+
regex: options?.regex !== undefined ? atom(options.regex) : undefined,
201+
caseSensitive: options?.caseSensitive !== undefined ? atom(options.caseSensitive) : undefined,
202+
wholeWord: options?.wholeWord !== undefined ? atom(options.wholeWord) : undefined,
203+
}),
168204
[]
169205
);
170-
anchorRef ??= useRef(null);
206+
const anchorRef = options?.anchorRef ?? useRef(null);
171207
useEffect(() => {
172-
if (viewModel) {
173-
viewModel.searchAtoms = searchAtoms;
208+
if (options?.viewModel) {
209+
options.viewModel.searchAtoms = searchAtoms;
210+
return () => {
211+
options.viewModel.searchAtoms = undefined;
212+
};
174213
}
175-
}, [viewModel]);
214+
}, [options?.viewModel]);
176215
return { ...searchAtoms, anchorRef };
177216
}
217+
218+
const createToggleButtonDecl = (
219+
atom: WritableAtom<boolean, [boolean], void> | undefined,
220+
icon: string,
221+
title: string
222+
): ToggleIconButtonDecl =>
223+
atom
224+
? {
225+
elemtype: "toggleiconbutton",
226+
icon,
227+
title,
228+
active: atom,
229+
}
230+
: null;

0 commit comments

Comments
 (0)