Files
openidea/src/features/terminal/FindBar.tsx
T
Jaw 70f10e741c fix(i18n): Translate the remaining UI text (Git, hierarchy, pull requests, dialogs, aria labels) (#114)
Hard-coded UI text, titles and aria-labels in about 110 components (Git panels, hierarchy, pull requests, dialogs, settings, welcome) now go through tx; spliced fragments became whole sentences (fileCount, noneFoundLabel, hierarchyTitle) and pull request states use keyed messages. 225 zh entries added, stale fragment entries removed.

Also: tx used as a tag without interpolations (tx`No checks`) now looks the text up exactly instead of as a template, so it is translated like tx("No checks"); the text-catalog test checks such tags as plain literals. Check and test counts reuse the existing "{count} passed" / "{failed} failing" entries.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: jaw52 <[email protected]>
2026-10-28 12:18:46 +08:00

168 lines
5.4 KiB
TypeScript

/** ⌘F in the terminal: search the scrollback (xterm addon-search) with Match Case / Words / Regex. */
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { create } from "zustand";
import { IconButton, SearchField } from "@/ui";
import type { TerminalSearchOptions, TerminalSearchResult } from "./session";
import { activeSession } from "./store";
import styles from "./Terminal.module.css";
import { hasPrimaryModifier } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
export const useTerminalFind = create<{ open: boolean; seq: number; query: string; options: TerminalSearchOptions }>(
() => ({
open: false,
seq: 0,
query: "",
options: { caseSensitive: false, regex: false, wholeWord: false },
}),
);
/** Opens (or refocuses) the find bar, seeding it with the terminal selection. */
export function openTerminalFind() {
// While open, the terminal selection is the current match: keep the query.
const selection = useTerminalFind.getState().open ? "" : activeSession()?.term.getSelection().trim();
useTerminalFind.setState((s) => ({
open: true,
seq: s.seq + 1,
query: selection && !selection.includes("\n") ? selection : s.query,
}));
}
export function closeTerminalFind() {
if (!useTerminalFind.getState().open) return;
useTerminalFind.setState({ open: false });
const session = activeSession();
session?.clearSearch();
session?.focus();
}
/** "3/12", "999+" style counter text. */
export function matchCounter(r: TerminalSearchResult | null, query: string): string {
if (!query || !r) return "";
if (r.count === 0) return "0 results";
return r.index >= 0 ? `${r.index + 1}/${r.count}` : `${r.count}+`;
}
export function TerminalFindBar({ sessionKey }: { sessionKey: string | null }) {
const { open, seq, query, options } = useTerminalFind();
const input = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<TerminalSearchResult | null>(null);
useEffect(() => {
if (!open) return;
input.current?.focus();
input.current?.select();
}, [open, seq]);
// Re-run the search on query / option / session changes.
useEffect(() => {
if (!open) return;
const session = activeSession();
if (!session) return;
const off = session.onSearchResults(setResult);
// Results arrive through the listener (clearing reports 0 results).
if (query) session.find(query, options, "next", true);
else session.clearSearch();
return () => {
off();
session.clearSearch();
};
}, [open, query, options, sessionKey]);
if (!open) return null;
const find = (direction: "next" | "previous") => activeSession()?.find(query, options, direction);
const toggle = (k: keyof TerminalSearchOptions) =>
useTerminalFind.setState((s) => ({ options: { ...s.options, [k]: !s.options[k] } }));
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeTerminalFind();
} else if (e.key === "Enter") {
e.preventDefault();
find(e.shiftKey ? "previous" : "next");
} else if (e.key === "F3" || (hasPrimaryModifier(e) && e.key.toLowerCase() === "g")) {
e.preventDefault();
find(e.shiftKey ? "previous" : "next");
} else if (e.altKey && !e.metaKey && !e.ctrlKey) {
const k =
e.code === "KeyC" ? "caseSensitive" : e.code === "KeyW" ? "wholeWord" : e.code === "KeyX" ? "regex" : null;
if (k) {
e.preventDefault();
toggle(k);
}
}
};
const counter = matchCounter(result, query);
const none = !!query && result?.count === 0;
return (
<div
className={styles.find}
role="search"
aria-label={tx("Find in terminal")}
data-terminal-find=""
onKeyDown={onKeyDown}
>
<SearchField
ref={input}
className={styles.findField}
value={query}
onChange={(q) => useTerminalFind.setState({ query: q })}
placeholder="Find in terminal"
aria-label="Find in terminal"
invalid={none}
toggles={[
{
id: "case",
icon: "case",
title: "Match Case",
shortcut: "⌥C",
pressed: options.caseSensitive,
onToggle: () => toggle("caseSensitive"),
},
{
id: "word",
icon: "word",
title: "Words",
shortcut: "⌥W",
pressed: options.wholeWord,
onToggle: () => toggle("wholeWord"),
},
{
id: "regex",
icon: "regex",
title: "Regex",
shortcut: "⌥X",
pressed: options.regex,
onToggle: () => toggle("regex"),
},
]}
/>
<span className={clsx(styles.findCount, none && styles.findCountNone)} aria-live="polite">
{counter}
</span>
<IconButton
icon="arrow-up"
title="Previous Occurrence"
shortcut="⇧↵"
size="small"
disabled={!query}
onClick={() => find("previous")}
/>
<IconButton
icon="arrow-down"
title="Next Occurrence"
shortcut="↵"
size="small"
disabled={!query}
onClick={() => find("next")}
/>
<span className={styles.findSpacer} />
<IconButton icon="close" title="Close" shortcut="⎋" size="small" onClick={closeTerminalFind} />
</div>
);
}