Files
spaceshell/app/src/SearchBar.tsx
T
vasyansk 04ac7cdec2 fix(app): working scrollback search + stop prompt duplication on focus
Search fixes:
- TerminalView sets allowProposedApi (the search addon's match decorations
  use registerMarker/registerDecoration); without it findNext threw before
  firing results, so the counter was stuck at 0/0.
- The search bar now renders inside the panel it targets (in the header)
  instead of a global top-right overlay, so it's obvious which panel is
  searched.
- Search is anchored to the panel it was opened on (searchSurfaceId) and no
  longer follows focus — opening it in one panel and switching away no longer
  shows it open elsewhere.

Prompt duplication:
- The focus border was 1px when unfocused, 2px when focused; with border-box
  that resized the content on every focus switch, firing ResizeObserver -> fit
  -> PTY SIGWINCH and making zsh/powerlevel10k reprint its prompt. The border
  is now a constant 2px, color-only on focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:34:43 +07:00

146 lines
3.8 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { ChevronUp, ChevronDown, X } from "lucide-react";
import { COLORS, FONT } from "./theme";
import { getSearch } from "./searchRegistry";
import type { ISearchOptions } from "@xterm/addon-search";
const SEARCH_OPTS: ISearchOptions = {
decorations: {
matchBackground: COLORS.searchMatch,
matchOverviewRuler: COLORS.stWait,
activeMatchBackground: COLORS.stWait,
activeMatchColorOverviewRuler: COLORS.stWait,
},
};
export function SearchBar({
surfaceId,
reopenNonce,
onClose,
}: {
surfaceId: string | null;
reopenNonce: number;
onClose: () => void;
}) {
const [term, setTerm] = useState("");
const [count, setCount] = useState({ index: -1, total: 0 });
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, [reopenNonce]);
useEffect(() => {
inputRef.current?.focus();
if (!surfaceId) return;
const addon = getSearch(surfaceId);
if (!addon) return;
const sub = addon.onDidChangeResults((r) =>
setCount({ index: r.resultIndex, total: r.resultCount })
);
return () => {
sub.dispose();
addon.clearDecorations();
};
}, [surfaceId]);
function run(forward: boolean, override?: string) {
if (!surfaceId) return;
const addon = getSearch(surfaceId);
const query = override ?? term;
if (!addon || !query) {
addon?.clearDecorations();
setCount({ index: -1, total: 0 });
return;
}
if (forward) addon.findNext(query, SEARCH_OPTS);
else addon.findPrevious(query, SEARCH_OPTS);
}
return (
<div
style={{
// Anchored to the focused panel's card (position:relative). Sits over
// the 30px header so it's obvious which panel is being searched.
position: "absolute",
top: 3,
right: 6,
zIndex: 50,
display: "flex",
alignItems: "center",
gap: 4,
height: 24,
padding: "0 6px",
background: COLORS.bgElevated,
border: `1px solid ${COLORS.borderStrong}`,
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
}}
>
<input
ref={inputRef}
value={term}
onChange={(e) => {
const value = e.target.value;
setTerm(value);
if (!value) {
setCount({ index: -1, total: 0 });
if (surfaceId) getSearch(surfaceId)?.clearDecorations();
} else {
run(true, value); // search-as-you-type
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
run(!e.shiftKey);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
placeholder="Search scrollback"
style={{
width: 150,
background: "transparent",
border: "none",
outline: "none",
color: COLORS.textPrimary,
fontFamily: FONT.ui,
fontSize: 13,
}}
/>
<span
style={{
fontFamily: FONT.mono,
fontSize: 11,
color: COLORS.textMuted,
minWidth: 40,
textAlign: "right",
}}
>
{count.total > 0 ? `${count.index + 1}/${count.total}` : "0/0"}
</span>
<ChevronUp
size={15}
color={COLORS.textSecondary}
style={{ cursor: "pointer" }}
onClick={() => run(false)}
/>
<ChevronDown
size={15}
color={COLORS.textSecondary}
style={{ cursor: "pointer" }}
onClick={() => run(true)}
/>
<X
size={15}
color={COLORS.textMuted}
style={{ cursor: "pointer" }}
onClick={onClose}
/>
</div>
);
}