Skip to content

Commit 29f49dc

Browse files
authored
fix: search bar clipboard and focus improvements (#3025)
Two small fixes to the in-app search feature: - **Skip copy-on-select during search navigation** — when iterating through search results in the terminal, the clipboard kept getting overwritten with the matched text on every step. This was annoying on its own, but also polluted paste history managers. The root cause was that xterm.js updates the terminal selection programmatically to highlight each match, which triggered the copy-on-select handler. The fix skips the clipboard write whenever an element inside `.search-container` is the active element. - **Refocus search input on repeated Cmd+F** — pressing Cmd+F while the search bar was already open was a no-op (setting the `isOpen` atom to `true` again has no effect). The fix detects the already-open case and directly calls `focus()` + `select()` on the input, so the user can immediately type a new query.
1 parent 7119970 commit 29f49dc

File tree

4 files changed

+32
-2
lines changed

4 files changed

+32
-2
lines changed

frontend/app/element/search.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const SearchComponent = ({
2626
caseSensitive: caseSensitiveAtom,
2727
wholeWord: wholeWordAtom,
2828
isOpen: isOpenAtom,
29+
focusInput: focusInputAtom,
2930
anchorRef,
3031
offsetX = 10,
3132
offsetY = 10,
@@ -37,6 +38,8 @@ const SearchComponent = ({
3738
const [search, setSearch] = useAtom<string>(searchAtom);
3839
const [index, setIndex] = useAtom<number>(indexAtom);
3940
const [numResults, setNumResults] = useAtom<number>(numResultsAtom);
41+
const [focusInputCounter, setFocusInputCounter] = useAtom<number>(focusInputAtom);
42+
const inputRef = useRef<HTMLInputElement>(null);
4043

4144
const handleOpenChange = useCallback((open: boolean) => {
4245
setIsOpen(open);
@@ -47,6 +50,7 @@ const SearchComponent = ({
4750
setSearch("");
4851
setIndex(0);
4952
setNumResults(0);
53+
setFocusInputCounter(0);
5054
}
5155
}, [isOpen]);
5256

@@ -56,6 +60,15 @@ const SearchComponent = ({
5660
onSearch?.(search);
5761
}, [search]);
5862

63+
// When activateSearch fires while already open, it increments focusInputCounter
64+
// to signal this specific instance to grab focus (avoids global DOM queries).
65+
useEffect(() => {
66+
if (focusInputCounter > 0 && isOpen) {
67+
inputRef.current?.focus();
68+
inputRef.current?.select();
69+
}
70+
}, [focusInputCounter]);
71+
5972
const middleware: Middleware[] = [];
6073
const offsetCallback = useCallback(
6174
({ rects }) => {
@@ -146,6 +159,7 @@ const SearchComponent = ({
146159
<FloatingPortal>
147160
<div className="search-container" style={{ ...floatingStyles }} ref={refs.setFloating}>
148161
<Input
162+
ref={inputRef}
149163
placeholder="Search"
150164
value={search}
151165
onChange={setSearch}
@@ -197,6 +211,7 @@ export function useSearch(options?: SearchOptions): SearchProps {
197211
resultsIndex: atom(0),
198212
resultsCount: atom(0),
199213
isOpen: atom(false),
214+
focusInput: atom(0),
200215
regex: options?.regex !== undefined ? atom(options.regex) : undefined,
201216
caseSensitive: options?.caseSensitive !== undefined ? atom(options.caseSensitive) : undefined,
202217
wholeWord: options?.wholeWord !== undefined ? atom(options.wholeWord) : undefined,

frontend/app/store/keymodel.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,15 @@ function registerGlobalKeys() {
695695
return false;
696696
}
697697
if (bcm.viewModel.searchAtoms) {
698-
globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
698+
if (globalStore.get(bcm.viewModel.searchAtoms.isOpen)) {
699+
// Already open — increment the focusInput counter so this block's
700+
// SearchComponent focuses its own input (avoids a global DOM query
701+
// that could target the wrong block when multiple searches are open).
702+
const cur = globalStore.get(bcm.viewModel.searchAtoms.focusInput) as number;
703+
globalStore.set(bcm.viewModel.searchAtoms.focusInput, cur + 1);
704+
} else {
705+
globalStore.set(bcm.viewModel.searchAtoms.isOpen, true);
706+
}
699707
return true;
700708
}
701709
return false;

frontend/app/view/term/termwrap.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,12 @@ export class TermWrap {
343343
if (!globalStore.get(copyOnSelectAtom)) {
344344
return;
345345
}
346+
// Don't copy-on-select when the search bar has focus — navigating
347+
// search results changes the terminal selection programmatically.
348+
const active = document.activeElement;
349+
if (active != null && active.closest(".search-container") != null) {
350+
return;
351+
}
346352
const selectedText = this.terminal.getSelection();
347353
if (selectedText.length > 0) {
348354
navigator.clipboard.writeText(selectedText);

frontend/types/custom.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import type { WaveEnv } from "@/app/waveenv/waveenv";
45
import { type Placement } from "@floating-ui/react";
56
import type * as jotai from "jotai";
67
import type * as rxjs from "rxjs";
7-
import type { WaveEnv } from "@/app/waveenv/waveenv";
88

99
declare global {
1010
type GlobalAtomsType = {
@@ -276,6 +276,7 @@ declare global {
276276
resultsIndex: PrimitiveAtom<number>;
277277
resultsCount: PrimitiveAtom<number>;
278278
isOpen: PrimitiveAtom<boolean>;
279+
focusInput: PrimitiveAtom<number>;
279280
regex?: PrimitiveAtom<boolean>;
280281
caseSensitive?: PrimitiveAtom<boolean>;
281282
wholeWord?: PrimitiveAtom<boolean>;

0 commit comments

Comments
 (0)