[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
580
components/terminal/hooks/useTerminalSearch.ts
Normal file
580
components/terminal/hooks/useTerminalSearch.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
import { STORAGE_KEY_TERMINAL_SEARCH_OPEN } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
type SearchMatchCount = { current: number; total: number } | null;
|
||||
|
||||
type SearchAddonResetTarget = Pick<SearchAddon, "findNext" | "clearDecorations"> | null;
|
||||
type TerminalSearchVisualElement = {
|
||||
querySelectorAll: (selector: string) => ArrayLike<{ remove: () => void }>;
|
||||
};
|
||||
type TerminalSearchResetTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> & {
|
||||
element?: TerminalSearchVisualElement | null;
|
||||
clearTextureAtlas?: () => void;
|
||||
} | null;
|
||||
type TerminalSearchGuardTarget = TerminalSearchResetTarget;
|
||||
|
||||
const SEARCH_DECORATIONS = {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
matchOverviewRuler: "#FFFF00",
|
||||
activeMatchBackground: "#FF880088",
|
||||
activeMatchBorder: "#FF8800",
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
} as const;
|
||||
|
||||
const SEARCH_DECORATION_BACKGROUNDS = new Set<string>([
|
||||
SEARCH_DECORATIONS.matchBackground.toLowerCase(),
|
||||
SEARCH_DECORATIONS.activeMatchBackground.toLowerCase(),
|
||||
]);
|
||||
|
||||
type StaleSearchDecoration = {
|
||||
dispose: () => void;
|
||||
options?: { backgroundColor?: string };
|
||||
element?: {
|
||||
classList?: { contains: (name: string) => boolean };
|
||||
style?: { backgroundColor?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type CellDecorationService = {
|
||||
decorations?: Iterable<StaleSearchDecoration>;
|
||||
forEachDecorationAtCell?: (
|
||||
x: number,
|
||||
y: number,
|
||||
layer: "bottom" | "top" | undefined,
|
||||
callback: (decoration: StaleSearchDecoration) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type SearchAddonInternals = {
|
||||
clearDecorations?: () => void;
|
||||
clearActiveDecoration?: () => void;
|
||||
_highlightTimeout?: { clear?: () => void };
|
||||
_state?: { reset?: () => void };
|
||||
};
|
||||
|
||||
type TerminalDecorationHost = {
|
||||
_core?: { _decorationService?: CellDecorationService };
|
||||
_decorationService?: CellDecorationService;
|
||||
};
|
||||
|
||||
export const SEARCH_DECORATION_TRACKER_KEY = "__netcattySearchDecorationTracker";
|
||||
|
||||
export type SearchDecorationTracker = {
|
||||
disposeAll: () => number;
|
||||
size: () => number;
|
||||
markSearched: () => void;
|
||||
hasSearched: () => boolean;
|
||||
consumeSearched: () => boolean;
|
||||
noteEmptyQueryReset: () => void;
|
||||
consumeCloseSweep: () => boolean;
|
||||
};
|
||||
|
||||
type TrackableTerminal = Pick<XTerm, "registerDecoration"> & {
|
||||
[SEARCH_DECORATION_TRACKER_KEY]?: SearchDecorationTracker;
|
||||
};
|
||||
|
||||
const SEARCH_OPTIONS = {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
decorations: SEARCH_DECORATIONS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SearchAddon schedules `_updateMatches` 200ms after writes/resizes and does
|
||||
* not cancel that timer from `clearDecorations()`. A timeout that already
|
||||
* captured the prior term can revive yellow match decorations after reset —
|
||||
* re-clear once past that window (issue #2980).
|
||||
*/
|
||||
export const SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS = 250;
|
||||
|
||||
/**
|
||||
* SearchAddon paints matches as HTML overlays (`.xterm-find-result-decoration`).
|
||||
* Disposing the addon decoration does not always detach that node — after Esc
|
||||
* closes the search bar and the terminal refits, the first two cells of the
|
||||
* last hit can stay outlined on top of the buffer.
|
||||
*/
|
||||
export const SEARCH_DECORATION_NODE_SELECTOR =
|
||||
".xterm-find-result-decoration, .xterm-find-active-result-decoration";
|
||||
|
||||
export const stripStaleSearchDecorationNodes = (
|
||||
term?: { element?: TerminalSearchVisualElement | null } | null,
|
||||
): void => {
|
||||
const nodes = term?.element?.querySelectorAll(SEARCH_DECORATION_NODE_SELECTOR);
|
||||
if (!nodes) return;
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
nodes[i]?.remove();
|
||||
}
|
||||
};
|
||||
|
||||
export const isSearchDecorationBackground = (color?: string): boolean => (
|
||||
Boolean(color) && SEARCH_DECORATION_BACKGROUNDS.has(color.trim().toLowerCase())
|
||||
);
|
||||
|
||||
export const installSearchDecorationTracker = (
|
||||
term: TrackableTerminal,
|
||||
): SearchDecorationTracker => {
|
||||
const existing = term[SEARCH_DECORATION_TRACKER_KEY];
|
||||
if (existing) return existing;
|
||||
|
||||
const tracked = new Set<{ dispose: () => void }>();
|
||||
let searched = false;
|
||||
let pendingCloseSweep = false;
|
||||
const originalRegister = term.registerDecoration.bind(term);
|
||||
term.registerDecoration = (options) => {
|
||||
// Keep search fill on the HTML overlay only. Passing backgroundColor into
|
||||
// xterm lets WebGL bake the yellow into the glyph atlas, and those cells
|
||||
// stay stained after Esc even when the decoration handle is gone.
|
||||
const searchBackground = isSearchDecorationBackground(options.backgroundColor)
|
||||
? options.backgroundColor
|
||||
: undefined;
|
||||
const decoration = originalRegister(
|
||||
searchBackground ? { ...options, backgroundColor: undefined } : options,
|
||||
);
|
||||
if (!decoration) return decoration;
|
||||
if (!searchBackground) return decoration;
|
||||
tracked.add(decoration);
|
||||
decoration.onRender((element) => {
|
||||
element.style.backgroundColor = searchBackground;
|
||||
});
|
||||
decoration.onDispose(() => {
|
||||
tracked.delete(decoration);
|
||||
});
|
||||
return decoration;
|
||||
};
|
||||
|
||||
const tracker: SearchDecorationTracker = {
|
||||
disposeAll: () => {
|
||||
const leftover = [...tracked];
|
||||
tracked.clear();
|
||||
for (const decoration of leftover) decoration.dispose();
|
||||
return leftover.length;
|
||||
},
|
||||
size: () => tracked.size,
|
||||
markSearched: () => {
|
||||
searched = true;
|
||||
pendingCloseSweep = true;
|
||||
},
|
||||
hasSearched: () => searched,
|
||||
consumeSearched: () => {
|
||||
const value = searched;
|
||||
searched = false;
|
||||
return value;
|
||||
},
|
||||
noteEmptyQueryReset: () => {
|
||||
searched = false;
|
||||
},
|
||||
consumeCloseSweep: () => {
|
||||
const value = pendingCloseSweep;
|
||||
pendingCloseSweep = false;
|
||||
return value;
|
||||
},
|
||||
};
|
||||
term[SEARCH_DECORATION_TRACKER_KEY] = tracker;
|
||||
return tracker;
|
||||
};
|
||||
|
||||
export const getSearchDecorationTracker = (term?: unknown): SearchDecorationTracker | null => {
|
||||
if (!term || typeof term !== "object") return null;
|
||||
const tracker = (term as TrackableTerminal)[SEARCH_DECORATION_TRACKER_KEY];
|
||||
return tracker && typeof tracker.disposeAll === "function" ? tracker : null;
|
||||
};
|
||||
|
||||
const isStaleSearchDecoration = (decoration: StaleSearchDecoration): boolean => (
|
||||
isSearchDecorationBackground(decoration.options?.backgroundColor)
|
||||
|| isSearchDecorationBackground(decoration.element?.style?.backgroundColor)
|
||||
|| decoration.element?.classList?.contains("xterm-find-result-decoration") === true
|
||||
|| decoration.element?.classList?.contains("xterm-find-active-result-decoration") === true
|
||||
);
|
||||
|
||||
const readDecorationService = (term?: unknown): CellDecorationService | null => {
|
||||
const host = term as TerminalDecorationHost & {
|
||||
_core?: Record<string, unknown>;
|
||||
} | null | undefined;
|
||||
const direct = host?._core?._decorationService ?? host?._decorationService;
|
||||
if (direct && (direct.decorations || direct.forEachDecorationAtCell)) {
|
||||
return direct;
|
||||
}
|
||||
const core = host?._core;
|
||||
if (!core || typeof core !== "object") return null;
|
||||
for (const value of Object.values(core)) {
|
||||
const candidate = value as CellDecorationService | undefined;
|
||||
if (candidate && typeof candidate.forEachDecorationAtCell === "function") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readDecorationIterable = (
|
||||
term?: unknown,
|
||||
): Iterable<StaleSearchDecoration> | null => (
|
||||
readDecorationService(term)?.decorations ?? null
|
||||
);
|
||||
|
||||
export const disposeSearchDecorationsInViewport = (term?: unknown): number => {
|
||||
const service = readDecorationService(term);
|
||||
const view = term as {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
buffer?: { active?: { viewportY?: number } };
|
||||
} | null | undefined;
|
||||
if (!service?.forEachDecorationAtCell || !view?.cols || !view.rows) return 0;
|
||||
const viewportY = view.buffer?.active?.viewportY ?? 0;
|
||||
const stale = new Set<StaleSearchDecoration>();
|
||||
for (let y = viewportY; y < viewportY + view.rows; y += 1) {
|
||||
for (let x = 0; x < view.cols; x += 1) {
|
||||
service.forEachDecorationAtCell(x, y, undefined, (decoration) => {
|
||||
if (isStaleSearchDecoration(decoration)) stale.add(decoration);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const decoration of stale) decoration.dispose();
|
||||
return stale.size;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebGL paints decoration backgroundColor into the cell. SearchAddon can lose
|
||||
* a couple of those handles on Esc+refit, so walk the terminal decoration
|
||||
* service and dispose anything still using the search yellow/orange.
|
||||
*/
|
||||
export const disposeStaleSearchDecorations = (term?: unknown): number => {
|
||||
if (term && typeof term === "object" && "registerDecoration" in term) {
|
||||
installSearchDecorationTracker(term as TrackableTerminal);
|
||||
}
|
||||
const trackedCount = getSearchDecorationTracker(term)?.disposeAll() ?? 0;
|
||||
const viewportCount = disposeSearchDecorationsInViewport(term);
|
||||
const decorations = readDecorationIterable(term);
|
||||
if (!decorations) return trackedCount + viewportCount;
|
||||
const stale: StaleSearchDecoration[] = [];
|
||||
for (const decoration of decorations) {
|
||||
if (isStaleSearchDecoration(decoration)) stale.push(decoration);
|
||||
}
|
||||
for (const decoration of stale) decoration.dispose();
|
||||
return trackedCount + viewportCount + stale.length;
|
||||
};
|
||||
|
||||
/** Cancel SearchAddon's 200ms _updateMatches timer and drop cached term/options. */
|
||||
export const disarmSearchAddonRevival = (searchAddon?: unknown): void => {
|
||||
const addon = searchAddon as SearchAddonInternals | null | undefined;
|
||||
if (!addon) return;
|
||||
addon._highlightTimeout?.clear?.();
|
||||
addon._state?.reset?.();
|
||||
addon.clearActiveDecoration?.();
|
||||
addon.clearDecorations?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Delayed re-clear for addon decoration revival only. Do not clearSelection
|
||||
* here: reset already cleared the search selection, and a user may have made
|
||||
* a new manual selection during the guard window.
|
||||
*/
|
||||
export const clearTerminalSearchHighlights = (
|
||||
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
|
||||
term?: Pick<XTerm, "refresh" | "rows"> & {
|
||||
element?: TerminalSearchVisualElement | null;
|
||||
clearTextureAtlas?: () => void;
|
||||
} | null,
|
||||
): void => {
|
||||
disarmSearchAddonRevival(searchAddon);
|
||||
disposeStaleSearchDecorations(term);
|
||||
stripStaleSearchDecorationNodes(term);
|
||||
if (term && term.rows > 0) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* After the search bar unmounts the terminal grows and is force-fitted.
|
||||
* Re-sweep leftover overlays and the addon selection that a resize can revive
|
||||
* as a 2-cell sliver of the last match.
|
||||
*/
|
||||
export const settleTerminalSearchAfterLayout = (
|
||||
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
|
||||
term?: TerminalSearchResetTarget,
|
||||
onRepaint?: () => void,
|
||||
): void => {
|
||||
// Search-open state is shared across terminals. A sibling that never
|
||||
// searched still sees the bar close and would otherwise lose a manual
|
||||
// selection via clearSelection(). Emptying the query resets highlights
|
||||
// while the bar stays open; keep the close-time repaint, but do not
|
||||
// treat that stale search as a reason to wipe a later manual selection.
|
||||
const tracker = getSearchDecorationTracker(term);
|
||||
const shouldClearSelection = tracker ? tracker.consumeSearched() : true;
|
||||
const shouldSweepLeftovers = tracker ? tracker.consumeCloseSweep() : true;
|
||||
const shouldSweep = !tracker || shouldClearSelection || shouldSweepLeftovers;
|
||||
if (!shouldSweep) return;
|
||||
clearTerminalSearchHighlights(searchAddon, term);
|
||||
if (shouldClearSelection) term?.clearSelection();
|
||||
term?.clearTextureAtlas?.();
|
||||
onRepaint?.();
|
||||
};
|
||||
|
||||
export const resetTerminalSearch = (
|
||||
searchAddon: SearchAddonResetTarget,
|
||||
searchTermRef: { current: string },
|
||||
term?: TerminalSearchResetTarget,
|
||||
): void => {
|
||||
searchTermRef.current = "";
|
||||
// Drop decorations and cachedSearchTerm first so any not-yet-running addon
|
||||
// `_updateMatches` timeout observes an empty cache and does not revive.
|
||||
disarmSearchAddonRevival(searchAddon);
|
||||
// clearDecorations() leaves the active-match selection; clear it explicitly.
|
||||
term?.clearSelection();
|
||||
// Empty find clears selection via the addon path. Do NOT pass SEARCH_OPTIONS:
|
||||
// findNext always assigns lastSearchOptions, and decoration options would
|
||||
// keep that latch armed for later write/resize updates.
|
||||
try {
|
||||
searchAddon?.findNext("");
|
||||
} catch {
|
||||
// Addon not activated yet.
|
||||
}
|
||||
// findNext("") assigns cachedSearchTerm back to "". Clear again so the cache
|
||||
// is undefined rather than an empty string.
|
||||
searchAddon?.clearDecorations();
|
||||
// SearchAddon can drop a couple of decoration handles on Esc+refit. Those
|
||||
// leftover yellow cells are still in xterm's decoration service and WebGL
|
||||
// keeps painting them until we dispose them directly.
|
||||
disposeStaleSearchDecorations(term);
|
||||
// Disposing search decorations does not always detach the overlay nodes
|
||||
// (Esc close leaves the first two cells of the last hit). Sweep them
|
||||
// before refresh so WebGL/DOM cannot keep the yellow outline.
|
||||
stripStaleSearchDecorationNodes(term);
|
||||
// Disposing search decorations does not always repaint cells (observed on
|
||||
// Windows after clearing or closing search). Keyword highlighting already
|
||||
// forces a refresh after dispose; do the same here so yellow match
|
||||
// backgrounds cannot linger.
|
||||
if (term && term.rows > 0) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pointer listeners used to tell a user-created selection apart from the
|
||||
* addon's delayed findPrevious re-select. Keyboard selections in this 250ms
|
||||
* window are rare enough that treating them as addon revival is acceptable.
|
||||
*/
|
||||
export const subscribeTerminalUserSelection = (
|
||||
term: Pick<XTerm, "element"> | null | undefined,
|
||||
mark: () => void,
|
||||
): (() => void) => {
|
||||
const el = term?.element;
|
||||
if (!el) return () => {};
|
||||
const onPointer = () => mark();
|
||||
el.addEventListener("mousedown", onPointer);
|
||||
el.addEventListener("touchstart", onPointer);
|
||||
return () => {
|
||||
el.removeEventListener("mousedown", onPointer);
|
||||
el.removeEventListener("touchstart", onPointer);
|
||||
};
|
||||
};
|
||||
|
||||
export const armSearchHighlightRevivalGuard = ({
|
||||
getSearchAddon,
|
||||
getTerm,
|
||||
subscribeUserSelection,
|
||||
delayMs = SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
|
||||
setTimeoutFn = setTimeout,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
}: {
|
||||
getSearchAddon: () => Pick<SearchAddon, "clearDecorations"> | null;
|
||||
getTerm: () => TerminalSearchGuardTarget;
|
||||
subscribeUserSelection?: (mark: () => void) => () => void;
|
||||
delayMs?: number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
}): { arm: () => void; dispose: () => void; markUserSelection: () => void } => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let userTouchedSelection = false;
|
||||
let unsubscribeUserSelection: (() => void) | null = null;
|
||||
|
||||
const markUserSelection = () => {
|
||||
userTouchedSelection = true;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
}
|
||||
unsubscribeUserSelection?.();
|
||||
unsubscribeUserSelection = null;
|
||||
};
|
||||
|
||||
const arm = () => {
|
||||
dispose();
|
||||
userTouchedSelection = false;
|
||||
if (subscribeUserSelection) {
|
||||
unsubscribeUserSelection = subscribeUserSelection(markUserSelection);
|
||||
}
|
||||
timer = setTimeoutFn(() => {
|
||||
timer = null;
|
||||
unsubscribeUserSelection?.();
|
||||
unsubscribeUserSelection = null;
|
||||
const term = getTerm();
|
||||
clearTerminalSearchHighlights(getSearchAddon(), term);
|
||||
// Addon findPrevious re-selects the prior active match. Clear that
|
||||
// revived selection unless the user started a new one in this window.
|
||||
if (!userTouchedSelection) {
|
||||
term?.clearSelection();
|
||||
}
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
return { arm, dispose, markUserSelection };
|
||||
};
|
||||
|
||||
/** True when this terminal has a local query that shared search-close must clear. */
|
||||
export const shouldResetOnSharedSearchClose = (localSearchTerm: string): boolean =>
|
||||
localSearchTerm !== "";
|
||||
|
||||
export const useTerminalSearch = ({
|
||||
searchAddonRef,
|
||||
termRef,
|
||||
}: {
|
||||
searchAddonRef: RefObject<SearchAddon | null>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
}) => {
|
||||
const [isSearchOpen, setIsSearchOpen] = useStoredBoolean(
|
||||
STORAGE_KEY_TERMINAL_SEARCH_OPEN,
|
||||
false,
|
||||
);
|
||||
const [searchMatchCount, setSearchMatchCount] = useState<SearchMatchCount>(null);
|
||||
// Bumped each time the search hotkey fires. The SearchBar watches this token
|
||||
// to refocus its input — without it, calling setIsSearchOpen(true) when
|
||||
// already open is a no-op (React bails on the unchanged boolean) and focus
|
||||
// never returns to the input. See issue #1789.
|
||||
const [searchFocusToken, setSearchFocusToken] = useState(0);
|
||||
const searchTermRef = useRef<string>("");
|
||||
const revivalGuardRef = useRef<ReturnType<typeof armSearchHighlightRevivalGuard> | null>(null);
|
||||
|
||||
// Existing sessions (and Vite HMR) never go back through createXTermRuntime.
|
||||
// Install on the live term so Esc can still find leaked decorations.
|
||||
if (termRef.current) {
|
||||
installSearchDecorationTracker(termRef.current);
|
||||
}
|
||||
|
||||
if (revivalGuardRef.current === null) {
|
||||
revivalGuardRef.current = armSearchHighlightRevivalGuard({
|
||||
getSearchAddon: () => searchAddonRef.current,
|
||||
getTerm: () => termRef.current,
|
||||
subscribeUserSelection: (mark) => subscribeTerminalUserSelection(termRef.current, mark),
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
revivalGuardRef.current?.dispose();
|
||||
}, []);
|
||||
|
||||
const runReset = useCallback(() => {
|
||||
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
|
||||
revivalGuardRef.current?.arm();
|
||||
}, [searchAddonRef, termRef]);
|
||||
|
||||
// Search open state is shared via localStorage across terminal sessions. When
|
||||
// another session closes search, this session's bar unmounts without going
|
||||
// through handleCloseSearch — clear leftover decorations only when this
|
||||
// terminal actually searched (otherwise shared false would wipe unrelated
|
||||
// manual selections in other splits).
|
||||
useEffect(() => {
|
||||
if (isSearchOpen) return;
|
||||
setSearchMatchCount(null);
|
||||
if (!shouldResetOnSharedSearchClose(searchTermRef.current)) return;
|
||||
runReset();
|
||||
}, [isSearchOpen, runReset]);
|
||||
|
||||
// Invoked by the searchTerminal hotkey (Cmd/Ctrl+F). Always opens the bar
|
||||
// and bumps the focus token: when closed, setIsSearchOpen(true) mounts the
|
||||
// SearchBar (whose isOpen effect focuses the input); when open, the token
|
||||
// bump makes the SearchBar re-run its focus effect and refocus. Doing both
|
||||
// unconditionally avoids reading `isSearchOpen` here — the xterm runtime
|
||||
// captures this callback once at creation (it only re-runs on host.id /
|
||||
// sessionId change), so a stale `isSearchOpen` closure would otherwise pick
|
||||
// the wrong branch.
|
||||
const requestSearchFocus = useCallback(() => {
|
||||
setIsSearchOpen(true);
|
||||
setSearchFocusToken((n) => n + 1);
|
||||
}, [setIsSearchOpen]);
|
||||
|
||||
const handleToggleSearch = useCallback(() => {
|
||||
const next = !isSearchOpen;
|
||||
setIsSearchOpen(next);
|
||||
if (!next) {
|
||||
setSearchMatchCount(null);
|
||||
runReset();
|
||||
}
|
||||
}, [isSearchOpen, runReset, setIsSearchOpen]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
if (!searchAddon || !term) {
|
||||
runReset();
|
||||
if (termRef.current) installSearchDecorationTracker(termRef.current);
|
||||
getSearchDecorationTracker(termRef.current)?.noteEmptyQueryReset();
|
||||
setSearchMatchCount(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
searchTermRef.current = term;
|
||||
revivalGuardRef.current?.dispose();
|
||||
// Incremental typing (ro -> root) can leave the previous term's
|
||||
// decorations in xterm even after clearDecorations(). Drop our tracked
|
||||
// leftovers before painting the new matches.
|
||||
if (termRef.current) installSearchDecorationTracker(termRef.current);
|
||||
getSearchDecorationTracker(termRef.current)?.markSearched();
|
||||
disposeStaleSearchDecorations(termRef.current);
|
||||
searchAddon.clearDecorations();
|
||||
|
||||
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
|
||||
if (found) {
|
||||
setSearchMatchCount({ current: 1, total: 1 });
|
||||
} else {
|
||||
setSearchMatchCount({ current: 0, total: 0 });
|
||||
}
|
||||
|
||||
return found;
|
||||
},
|
||||
[runReset, searchAddonRef, termRef],
|
||||
);
|
||||
|
||||
const handleFindNext = useCallback((): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
const term = searchTermRef.current;
|
||||
if (!searchAddon || !term) return false;
|
||||
return searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
}, [searchAddonRef]);
|
||||
|
||||
const handleFindPrevious = useCallback((): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
const term = searchTermRef.current;
|
||||
if (!searchAddon || !term) return false;
|
||||
return searchAddon.findPrevious(term, SEARCH_OPTIONS);
|
||||
}, [searchAddonRef]);
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchMatchCount(null);
|
||||
runReset();
|
||||
termRef.current?.focus();
|
||||
}, [runReset, setIsSearchOpen, termRef]);
|
||||
|
||||
return {
|
||||
isSearchOpen,
|
||||
setIsSearchOpen,
|
||||
searchMatchCount,
|
||||
searchFocusToken,
|
||||
requestSearchFocus,
|
||||
handleToggleSearch,
|
||||
handleSearch,
|
||||
handleFindNext,
|
||||
handleFindPrevious,
|
||||
handleCloseSearch,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user