Files
NetMesh/components/terminal/TerminalSearchBar.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

193 lines
7.5 KiB
TypeScript

/**
* Terminal Search Bar
* Provides search functionality within terminal scrollback buffer
*/
import { ChevronUp, ChevronDown, Search } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface TerminalSearchBarProps {
isOpen: boolean;
/**
* Incremented each time the search hotkey fires while the bar is already
* open. Watched by the focus effect so Cmd/Ctrl+F re-grabs focus when it
* has moved elsewhere (issue #1789). Ignored while `isOpen` is false.
*/
focusToken?: number;
onClose: () => void;
onSearch: (term: string) => boolean;
onFindNext: () => boolean;
onFindPrevious: () => boolean;
matchCount?: { current: number; total: number } | null;
}
export const notifyTerminalSearchTermChange = (
searchTerm: string,
previousSearchTerm: string,
onSearch: (term: string) => boolean,
): string => {
if (searchTerm === previousSearchTerm) return previousSearchTerm;
onSearch(searchTerm);
return searchTerm;
};
export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
isOpen,
focusToken,
onClose,
onSearch,
onFindNext,
onFindPrevious,
matchCount,
}) => {
const { t } = useI18n();
const [searchTerm, setSearchTerm] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const prevSearchTermRef = useRef('');
// Focus input when opened, or when the search hotkey re-fires while open
// (focusToken bumps) so focus returns to the input after it moved elsewhere.
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isOpen, focusToken]);
// Trigger search when term changes. When the term is cleared we still call
// onSearch('') so the underlying search addon clears its highlights;
// otherwise the last match decorations linger after emptying the input.
useEffect(() => {
prevSearchTermRef.current = notifyTerminalSearchTermChange(
searchTerm,
prevSearchTermRef.current,
onSearch,
);
}, [searchTerm, onSearch]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
} else if (e.key === 'F3' || (e.key === 'g' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
if (e.shiftKey) {
onFindPrevious();
} else {
onFindNext();
}
}
}, [onClose, onFindNext, onFindPrevious]);
if (!isOpen) return null;
return (
<div
className="flex items-center gap-1.5 px-2 pt-0 pb-2 bg-black/50 backdrop-blur-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 86%, transparent)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 40%, transparent)' }}
/>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
data-terminal-search-input=""
placeholder={t("terminal.search.placeholder")}
className="w-full h-6 pl-7 pr-2 text-[11px] border-none rounded placeholder:opacity-40 focus:outline-none"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 5%, transparent)',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
/>
</div>
{/* Match count indicator - only show when no results */}
{searchTerm.length > 0 && matchCount?.total === 0 && (
<span
className="text-[10px] flex-shrink-0"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 50%, transparent)' }}
>
{t("terminal.search.noResults")}
</span>
)}
{/* Navigation buttons */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindPrevious();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.prevMatch")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindNext();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronDown size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.nextMatch")}</TooltipContent>
</Tooltip>
</div>
</div>
);
};