From e10dabfb725eb903e18111073aab2370d095e146 Mon Sep 17 00:00:00 2001 From: carl2027 Date: Fri, 9 Jan 2026 09:30:34 +0800 Subject: [PATCH 1/5] add search match indicator --- .../app/view/term/search-match-indicator.tsx | 199 ++++++++++++++++++ frontend/app/view/term/term.scss | 14 ++ frontend/app/view/term/term.tsx | 12 ++ 3 files changed, 225 insertions(+) create mode 100644 frontend/app/view/term/search-match-indicator.tsx diff --git a/frontend/app/view/term/search-match-indicator.tsx b/frontend/app/view/term/search-match-indicator.tsx new file mode 100644 index 0000000000..2be8eeef24 --- /dev/null +++ b/frontend/app/view/term/search-match-indicator.tsx @@ -0,0 +1,199 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ISearchOptions } from "@xterm/addon-search"; +import { Terminal } from "@xterm/xterm"; +import clsx from "clsx"; +import * as React from "react"; + +interface SearchMatchIndicatorProps { + terminal: Terminal | null; + searchText: string; + searchOpts: ISearchOptions; + activeMatchIndex: number; + totalMatches: number; +} + +const SearchMatchIndicator: React.FC = ({ + terminal, + searchText, + searchOpts, + activeMatchIndex, + totalMatches, +}) => { + const [matchLines, setMatchLines] = React.useState([]); + const indicatorRef = React.useRef(null); + + // Find all matching lines + React.useEffect(() => { + if (!terminal || !searchText || searchText.trim() === "") { + setMatchLines([]); + return; + } + + const findMatches = () => { + const buffer = terminal.buffer.active; + const totalLines = buffer.length; + const matches: number[] = []; + const searchPattern = searchOpts.regex + ? new RegExp(searchText, searchOpts.caseSensitive ? "g" : "gi") + : null; + + for (let i = 0; i < totalLines; i++) { + const line = buffer.getLine(i); + if (!line) continue; + + const lineText = line.translateToString(true); + let hasMatch = false; + + if (searchOpts.regex && searchPattern) { + try { + hasMatch = searchPattern.test(lineText); + // Reset regex lastIndex for next iteration + searchPattern.lastIndex = 0; + } catch (e) { + // Invalid regex, skip + continue; + } + } else { + const searchStr = searchOpts.caseSensitive ? searchText : searchText.toLowerCase(); + const lineStr = searchOpts.caseSensitive ? lineText : lineText.toLowerCase(); + if (searchOpts.wholeWord) { + const wordBoundaryRegex = new RegExp(`\\b${escapeRegex(searchStr)}\\b`, "i"); + hasMatch = wordBoundaryRegex.test(lineStr); + } else { + hasMatch = lineStr.includes(searchStr); + } + } + + if (hasMatch) { + // Convert buffer line index to scrollback line index + // Buffer lines are 0-indexed from top, scrollback lines are 0-indexed from bottom + const scrollbackLine = totalLines - 1 - i; + matches.push(scrollbackLine); + } + } + + setMatchLines(matches.sort((a, b) => a - b)); + }; + + // Debounce the search to avoid performance issues + const timeoutId = setTimeout(findMatches, 100); + return () => clearTimeout(timeoutId); + }, [terminal, searchText, searchOpts]); + + const handleMarkerClick = React.useCallback( + (line: number, markerIndex: number) => { + if (!terminal) return; + const searchAddon = (terminal as any).searchAddon; + if (!searchAddon || !searchText) return; + + // Convert scrollback line index back to buffer line index + const buffer = terminal.buffer.active; + const totalLines = buffer.length; + const bufferLine = totalLines - 1 - line; + + // Navigate to the match at this line by finding all matches up to this one + // First, go to the top to start from a known position + terminal.scrollToLine(0); + + // Then find all matches up to and including the target match + setTimeout(() => { + try { + for (let i = 0; i <= markerIndex; i++) { + searchAddon.findNext(searchText, searchOpts); + } + + // Now scroll to show the match in the viewport + // Try to center it, but ensure it's visible + const viewportHeight = terminal.rows; + const targetScrollLine = Math.max(0, bufferLine - Math.floor(viewportHeight / 3)); + terminal.scrollToLine(targetScrollLine); + } catch (e) { + // If navigation fails, at least scroll to the right line + console.warn("Failed to navigate to match:", e); + const viewportHeight = terminal.rows; + const targetScrollLine = Math.max(0, bufferLine - Math.floor(viewportHeight / 3)); + terminal.scrollToLine(targetScrollLine); + } + }, 10); + }, + [terminal, searchText, searchOpts] + ); + + if (!terminal || !searchText || searchText.trim() === "" || matchLines.length === 0) { + return null; + } + + const buffer = terminal.buffer.active; + const totalLines = buffer.length; + + // Calculate the position of each match marker + const markers = matchLines.map((line) => { + // Convert scrollback line to buffer line + const bufferLine = totalLines - 1 - line; + // Calculate position as percentage (0 = top, 100 = bottom) + const position = totalLines > 1 ? (bufferLine / (totalLines - 1)) * 100 : 50; + return { line, position, bufferLine }; + }); + + return ( +
+ {markers.map((marker, index) => { + // activeMatchIndex is 1-based, matchLines is 0-based + const isActive = totalMatches > 0 && index === activeMatchIndex - 1; + return ( +
{ + e.stopPropagation(); // Prevent scrollbar interaction + handleMarkerClick(marker.line, index); + }} + onMouseEnter={(e) => { + // Increase opacity on hover for better visibility + e.currentTarget.style.opacity = "1"; + }} + onMouseLeave={(e) => { + // Restore opacity on leave + e.currentTarget.style.opacity = isActive ? "0.95" : "0.9"; + }} + title={`Match ${index + 1} of ${matchLines.length} (line ${marker.line + 1})`} + /> + ); + })} +
+ ); +}; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export { SearchMatchIndicator }; diff --git a/frontend/app/view/term/term.scss b/frontend/app/view/term/term.scss index b96c4e18f0..0af409003d 100644 --- a/frontend/app/view/term/term.scss +++ b/frontend/app/view/term/term.scss @@ -154,7 +154,9 @@ } &::-webkit-scrollbar-track { + // Make track slightly transparent so search match indicators are visible background-color: var(--scrollbar-background-color); + opacity: 0.4; // Allow search match indicators to show through } &::-webkit-scrollbar-thumb { @@ -179,4 +181,16 @@ } } } + + // Search match indicator styles + .search-match-indicator { + .search-match-marker { + // Ensure markers are visible even when scrollbar is over them + box-shadow: 0 0 2px rgba(255, 255, 0, 0.5); + + &.active { + box-shadow: 0 0 3px rgba(255, 150, 50, 0.7); + } + } + } } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 387155752d..562bad08b4 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -15,6 +15,7 @@ import clsx from "clsx"; import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; +import { SearchMatchIndicator } from "./search-match-indicator"; import { TermStickers } from "./termsticker"; import { TermThemeUpdater } from "./termtheme"; import { computeTheme } from "./termutil"; @@ -184,6 +185,8 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const wholeWord = useAtomValueSafe(searchProps.wholeWord); const regex = useAtomValueSafe(searchProps.regex); const searchVal = jotai.useAtomValue(searchProps.searchValue); + const searchResultsIndex = jotai.useAtomValue(searchProps.resultsIndex); + const searchResultsCount = jotai.useAtomValue(searchProps.resultsCount); const searchDecorations = React.useMemo( () => ({ matchOverviewRuler: "#000000", @@ -364,6 +367,15 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => className="term-scrollbar-hide-observer" onPointerOver={onScrollbarHideObserver} /> + {searchIsOpen && searchVal && ( + + )}
From d7f19933b736541fa6f442453e71fc442c00e810 Mon Sep 17 00:00:00 2001 From: carl2027 Date: Sat, 10 Jan 2026 19:18:10 +0800 Subject: [PATCH 2/5] increase thumb opacity when scroll --- .../app/view/term/search-match-indicator.tsx | 2 +- frontend/app/view/term/term.scss | 14 +++++++ frontend/app/view/term/term.tsx | 37 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/term/search-match-indicator.tsx b/frontend/app/view/term/search-match-indicator.tsx index 2be8eeef24..8f7563f586 100644 --- a/frontend/app/view/term/search-match-indicator.tsx +++ b/frontend/app/view/term/search-match-indicator.tsx @@ -143,7 +143,7 @@ const SearchMatchIndicator: React.FC = ({ className="search-match-indicator" style={{ position: "absolute", - right: "9px", // Align with scrollbar position + right: "8px", // Align with scrollbar position top: "0", bottom: "0", width: "6px", // Match scrollbar width diff --git a/frontend/app/view/term/term.scss b/frontend/app/view/term/term.scss index 0af409003d..a0acc54610 100644 --- a/frontend/app/view/term/term.scss +++ b/frontend/app/view/term/term.scss @@ -161,6 +161,7 @@ &::-webkit-scrollbar-thumb { display: none; + // Use original CSS variables for default and hover states background-color: var(--scrollbar-thumb-color); border-radius: 4px; margin: 0 1px 0 1px; @@ -173,6 +174,19 @@ background-color: var(--scrollbar-thumb-active-color); } } + + // Increase opacity when scrolling + &.scrolling::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.5); // More opaque when scrolling + + &:hover { + background-color: rgba(255, 255, 255, 0.6); + } + + &:active { + background-color: rgba(255, 255, 255, 0.7); + } + } } &:hover { diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 562bad08b4..9dc20d8171 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -331,17 +331,54 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => }, [isMI, isBasicTerm, isFocused]); const scrollbarHideObserverRef = React.useRef(null); + const scrollTimeoutRef = React.useRef(null); + const onScrollbarShowObserver = React.useCallback(() => { const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement; termViewport.style.zIndex = "var(--zindex-xterm-viewport-overlay)"; scrollbarHideObserverRef.current.style.display = "block"; }, []); + const onScrollbarHideObserver = React.useCallback(() => { const termViewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLDivElement; termViewport.style.zIndex = "auto"; scrollbarHideObserverRef.current.style.display = "none"; }, []); + // Handle scrollbar opacity during scrolling + React.useEffect(() => { + if (!viewRef.current) return; + + const viewport = viewRef.current.getElementsByClassName("xterm-viewport")[0] as HTMLElement; + if (!viewport) return; + + const handleScroll = () => { + // Add scrolling class when user scrolls + viewport.classList.add("scrolling"); + + // Clear existing timeout + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + // Remove scrolling class after scroll stops (300ms delay) + scrollTimeoutRef.current = setTimeout(() => { + viewport.classList.remove("scrolling"); + }, 300); + }; + + viewport.addEventListener("scroll", handleScroll, { passive: true }); + viewport.addEventListener("wheel", handleScroll, { passive: true }); + + return () => { + viewport.removeEventListener("scroll", handleScroll); + viewport.removeEventListener("wheel", handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, [viewRef]); + const stickerConfig = { charWidth: 8, charHeight: 16, From 26bb73676573ee24d5effbe22f0f36a7a8c201b7 Mon Sep 17 00:00:00 2001 From: carl2027 Date: Sat, 10 Jan 2026 19:33:46 +0800 Subject: [PATCH 3/5] descrease opactity for search indicator --- .../app/view/term/search-match-indicator.tsx | 101 +++++++++++++++--- frontend/app/view/term/term.scss | 14 ++- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/frontend/app/view/term/search-match-indicator.tsx b/frontend/app/view/term/search-match-indicator.tsx index 8f7563f586..a599fb5fa6 100644 --- a/frontend/app/view/term/search-match-indicator.tsx +++ b/frontend/app/view/term/search-match-indicator.tsx @@ -129,12 +129,70 @@ const SearchMatchIndicator: React.FC = ({ const totalLines = buffer.length; // Calculate the position of each match marker - const markers = matchLines.map((line) => { + const markers = matchLines.map((line, index) => { // Convert scrollback line to buffer line const bufferLine = totalLines - 1 - line; // Calculate position as percentage (0 = top, 100 = bottom) const position = totalLines > 1 ? (bufferLine / (totalLines - 1)) * 100 : 50; - return { line, position, bufferLine }; + return { line, position, bufferLine, originalIndex: index }; + }); + + // Merge nearby markers into blocks + // Threshold: if markers are within 0.5% of position, merge them + const MERGE_THRESHOLD = 0.5; + const mergedBlocks: Array<{ + startPosition: number; + endPosition: number; + startLine: number; + endLine: number; + startIndex: number; + endIndex: number; + containsActive: boolean; + }> = []; + + if (markers.length > 0) { + let currentBlock = { + startPosition: markers[0].position, + endPosition: markers[0].position, + startLine: markers[0].line, + endLine: markers[0].line, + startIndex: markers[0].originalIndex, + endIndex: markers[0].originalIndex, + containsActive: false, + }; + + for (let i = 1; i < markers.length; i++) { + const prevMarker = markers[i - 1]; + const currMarker = markers[i]; + const positionDiff = Math.abs(currMarker.position - prevMarker.position); + + if (positionDiff <= MERGE_THRESHOLD) { + // Merge into current block + currentBlock.endPosition = currMarker.position; + currentBlock.endLine = currMarker.line; + currentBlock.endIndex = currMarker.originalIndex; + } else { + // Save current block and start a new one + mergedBlocks.push(currentBlock); + currentBlock = { + startPosition: currMarker.position, + endPosition: currMarker.position, + startLine: currMarker.line, + endLine: currMarker.line, + startIndex: currMarker.originalIndex, + endIndex: currMarker.originalIndex, + containsActive: false, + }; + } + } + // Don't forget the last block + mergedBlocks.push(currentBlock); + } + + // Check which blocks contain the active match + mergedBlocks.forEach((block) => { + const activeIndex = activeMatchIndex - 1; // Convert to 0-based + block.containsActive = totalMatches > 0 && activeIndex >= block.startIndex && activeIndex <= block.endIndex; }); return ( @@ -143,29 +201,33 @@ const SearchMatchIndicator: React.FC = ({ className="search-match-indicator" style={{ position: "absolute", - right: "8px", // Align with scrollbar position + right: "5px", // Align with scrollbar position (same as xterm-viewport right: 0) top: "0", bottom: "0", - width: "6px", // Match scrollbar width + width: "5px", // Match scrollbar width zIndex: 7, // Below scrollbar overlay but above terminal decorations pointerEvents: "none", // Let clicks pass through to scrollbar, but markers will have pointerEvents: auto }} > - {markers.map((marker, index) => { - // activeMatchIndex is 1-based, matchLines is 0-based - const isActive = totalMatches > 0 && index === activeMatchIndex - 1; + {mergedBlocks.map((block, blockIndex) => { + const topPosition = Math.max(0, Math.min(100, block.startPosition)); + const matchCount = block.endIndex - block.startIndex + 1; + const isActive = block.containsActive; + // Use fixed height: 3px for single match, 4px for multiple matches + const height = matchCount === 1 ? "3px" : "4px"; + return (
= ({ }} onClick={(e) => { e.stopPropagation(); // Prevent scrollbar interaction - handleMarkerClick(marker.line, index); + // Navigate to the first match in the block + handleMarkerClick(block.startLine, block.startIndex); }} onMouseEnter={(e) => { // Increase opacity on hover for better visibility - e.currentTarget.style.opacity = "1"; + e.currentTarget.style.opacity = "0.9"; }} onMouseLeave={(e) => { // Restore opacity on leave - e.currentTarget.style.opacity = isActive ? "0.95" : "0.9"; + e.currentTarget.style.opacity = "0.4"; }} - title={`Match ${index + 1} of ${matchLines.length} (line ${marker.line + 1})`} + title={ + matchCount > 1 + ? `${matchCount} matches (lines ${block.startLine + 1}-${block.endLine + 1})` + : `Match ${block.startIndex + 1} of ${matchLines.length} (line ${block.startLine + 1})` + } /> ); })} diff --git a/frontend/app/view/term/term.scss b/frontend/app/view/term/term.scss index a0acc54610..383ad937eb 100644 --- a/frontend/app/view/term/term.scss +++ b/frontend/app/view/term/term.scss @@ -59,8 +59,16 @@ min-height: 0; overflow: hidden; line-height: 1; - margin: 5px; + margin-top: 5px; + margin-bottom: 5px; margin-left: 4px; + margin-right: 0; // Remove margin-right, use padding-right instead + position: relative; // Ensure positioning context for absolute children + // Use padding-right instead of margin-right to ensure fixed spacing + // Padding is part of the element's box model and won't be compressed by flexbox + // This ensures the scrollbar maintains a consistent right spacing regardless of window width + padding-right: 5px; // Fixed padding to maintain scrollbar right spacing + box-sizing: border-box; // Ensure padding is included in width calculation } .term-htmlelem { @@ -147,6 +155,10 @@ } .terminal { + // Ensure terminal container respects parent margin + width: 100%; + height: 100%; + .xterm-viewport { &::-webkit-scrollbar { width: 6px; From 09fb4f659de7c3ae3e826f7b576b5f40e6176835 Mon Sep 17 00:00:00 2001 From: carl2027 Date: Sat, 10 Jan 2026 21:31:36 +0800 Subject: [PATCH 4/5] fix computing the position for search indicator --- .../app/view/term/search-match-indicator.tsx | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/frontend/app/view/term/search-match-indicator.tsx b/frontend/app/view/term/search-match-indicator.tsx index a599fb5fa6..faa2342e05 100644 --- a/frontend/app/view/term/search-match-indicator.tsx +++ b/frontend/app/view/term/search-match-indicator.tsx @@ -23,6 +23,26 @@ const SearchMatchIndicator: React.FC = ({ }) => { const [matchLines, setMatchLines] = React.useState([]); const indicatorRef = React.useRef(null); + const [cols, setCols] = React.useState(terminal?.cols ?? 80); + + // Listen for terminal resize events to recalculate positions when width changes + React.useEffect(() => { + if (!terminal) return; + + // Initialize cols + setCols(terminal.cols); + + // Listen for resize events + // onResize returns a disposable object that can be used to unsubscribe + const disposable = terminal.onResize(() => { + setCols(terminal.cols); + }); + + return () => { + // Cleanup: dispose the event listener + disposable.dispose(); + }; + }, [terminal]); // Find all matching lines React.useEffect(() => { @@ -127,13 +147,52 @@ const SearchMatchIndicator: React.FC = ({ const buffer = terminal.buffer.active; const totalLines = buffer.length; + // Use state cols to trigger recalculation when width changes + const currentCols = cols; + + // Calculate visual line positions considering line wrapping + // Each buffer line may wrap to multiple visual lines if it exceeds terminal width + const calculateVisualLinePosition = (bufferLineIndex: number): number => { + let visualLineCount = 0; + + // Count visual lines for all buffer lines up to and including the target line + for (let i = 0; i <= bufferLineIndex; i++) { + const line = buffer.getLine(i); + if (line) { + const lineLength = line.length; + // Calculate how many visual lines this buffer line occupies + // Each visual line can hold 'currentCols' characters + const visualLinesForThisLine = Math.max(1, Math.ceil(lineLength / currentCols)); + visualLineCount += visualLinesForThisLine; + } else { + // Empty line still takes one visual line + visualLineCount += 1; + } + } + + return visualLineCount; + }; + + // Calculate total visual lines in the buffer + let totalVisualLines = 0; + for (let i = 0; i < totalLines; i++) { + const line = buffer.getLine(i); + if (line) { + const lineLength = line.length; + totalVisualLines += Math.max(1, Math.ceil(lineLength / currentCols)); + } else { + totalVisualLines += 1; + } + } - // Calculate the position of each match marker + // Calculate the position of each match marker based on visual lines const markers = matchLines.map((line, index) => { // Convert scrollback line to buffer line const bufferLine = totalLines - 1 - line; - // Calculate position as percentage (0 = top, 100 = bottom) - const position = totalLines > 1 ? (bufferLine / (totalLines - 1)) * 100 : 50; + // Calculate visual line position for this buffer line + const visualLinePosition = calculateVisualLinePosition(bufferLine); + // Calculate position as percentage based on visual lines (0 = top, 100 = bottom) + const position = totalVisualLines > 1 ? ((visualLinePosition - 1) / (totalVisualLines - 1)) * 100 : 50; return { line, position, bufferLine, originalIndex: index }; }); @@ -210,7 +269,7 @@ const SearchMatchIndicator: React.FC = ({ }} > {mergedBlocks.map((block, blockIndex) => { - const topPosition = Math.max(0, Math.min(100, block.startPosition)); + const topPosition = Math.max(0, Math.min(100, block.startPosition)) - 0.0; const matchCount = block.endIndex - block.startIndex + 1; const isActive = block.containsActive; // Use fixed height: 3px for single match, 4px for multiple matches From c603ec95c69f700796a20d03a571124980f82085 Mon Sep 17 00:00:00 2001 From: carl2027 Date: Mon, 12 Jan 2026 09:14:55 +0800 Subject: [PATCH 5/5] use xterm search highline indicator --- .../app/view/term/search-match-indicator.tsx | 325 ------------------ frontend/app/view/term/term.scss | 30 +- frontend/app/view/term/term.tsx | 20 +- 3 files changed, 26 insertions(+), 349 deletions(-) delete mode 100644 frontend/app/view/term/search-match-indicator.tsx diff --git a/frontend/app/view/term/search-match-indicator.tsx b/frontend/app/view/term/search-match-indicator.tsx deleted file mode 100644 index faa2342e05..0000000000 --- a/frontend/app/view/term/search-match-indicator.tsx +++ /dev/null @@ -1,325 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { ISearchOptions } from "@xterm/addon-search"; -import { Terminal } from "@xterm/xterm"; -import clsx from "clsx"; -import * as React from "react"; - -interface SearchMatchIndicatorProps { - terminal: Terminal | null; - searchText: string; - searchOpts: ISearchOptions; - activeMatchIndex: number; - totalMatches: number; -} - -const SearchMatchIndicator: React.FC = ({ - terminal, - searchText, - searchOpts, - activeMatchIndex, - totalMatches, -}) => { - const [matchLines, setMatchLines] = React.useState([]); - const indicatorRef = React.useRef(null); - const [cols, setCols] = React.useState(terminal?.cols ?? 80); - - // Listen for terminal resize events to recalculate positions when width changes - React.useEffect(() => { - if (!terminal) return; - - // Initialize cols - setCols(terminal.cols); - - // Listen for resize events - // onResize returns a disposable object that can be used to unsubscribe - const disposable = terminal.onResize(() => { - setCols(terminal.cols); - }); - - return () => { - // Cleanup: dispose the event listener - disposable.dispose(); - }; - }, [terminal]); - - // Find all matching lines - React.useEffect(() => { - if (!terminal || !searchText || searchText.trim() === "") { - setMatchLines([]); - return; - } - - const findMatches = () => { - const buffer = terminal.buffer.active; - const totalLines = buffer.length; - const matches: number[] = []; - const searchPattern = searchOpts.regex - ? new RegExp(searchText, searchOpts.caseSensitive ? "g" : "gi") - : null; - - for (let i = 0; i < totalLines; i++) { - const line = buffer.getLine(i); - if (!line) continue; - - const lineText = line.translateToString(true); - let hasMatch = false; - - if (searchOpts.regex && searchPattern) { - try { - hasMatch = searchPattern.test(lineText); - // Reset regex lastIndex for next iteration - searchPattern.lastIndex = 0; - } catch (e) { - // Invalid regex, skip - continue; - } - } else { - const searchStr = searchOpts.caseSensitive ? searchText : searchText.toLowerCase(); - const lineStr = searchOpts.caseSensitive ? lineText : lineText.toLowerCase(); - if (searchOpts.wholeWord) { - const wordBoundaryRegex = new RegExp(`\\b${escapeRegex(searchStr)}\\b`, "i"); - hasMatch = wordBoundaryRegex.test(lineStr); - } else { - hasMatch = lineStr.includes(searchStr); - } - } - - if (hasMatch) { - // Convert buffer line index to scrollback line index - // Buffer lines are 0-indexed from top, scrollback lines are 0-indexed from bottom - const scrollbackLine = totalLines - 1 - i; - matches.push(scrollbackLine); - } - } - - setMatchLines(matches.sort((a, b) => a - b)); - }; - - // Debounce the search to avoid performance issues - const timeoutId = setTimeout(findMatches, 100); - return () => clearTimeout(timeoutId); - }, [terminal, searchText, searchOpts]); - - const handleMarkerClick = React.useCallback( - (line: number, markerIndex: number) => { - if (!terminal) return; - const searchAddon = (terminal as any).searchAddon; - if (!searchAddon || !searchText) return; - - // Convert scrollback line index back to buffer line index - const buffer = terminal.buffer.active; - const totalLines = buffer.length; - const bufferLine = totalLines - 1 - line; - - // Navigate to the match at this line by finding all matches up to this one - // First, go to the top to start from a known position - terminal.scrollToLine(0); - - // Then find all matches up to and including the target match - setTimeout(() => { - try { - for (let i = 0; i <= markerIndex; i++) { - searchAddon.findNext(searchText, searchOpts); - } - - // Now scroll to show the match in the viewport - // Try to center it, but ensure it's visible - const viewportHeight = terminal.rows; - const targetScrollLine = Math.max(0, bufferLine - Math.floor(viewportHeight / 3)); - terminal.scrollToLine(targetScrollLine); - } catch (e) { - // If navigation fails, at least scroll to the right line - console.warn("Failed to navigate to match:", e); - const viewportHeight = terminal.rows; - const targetScrollLine = Math.max(0, bufferLine - Math.floor(viewportHeight / 3)); - terminal.scrollToLine(targetScrollLine); - } - }, 10); - }, - [terminal, searchText, searchOpts] - ); - - if (!terminal || !searchText || searchText.trim() === "" || matchLines.length === 0) { - return null; - } - - const buffer = terminal.buffer.active; - const totalLines = buffer.length; - // Use state cols to trigger recalculation when width changes - const currentCols = cols; - - // Calculate visual line positions considering line wrapping - // Each buffer line may wrap to multiple visual lines if it exceeds terminal width - const calculateVisualLinePosition = (bufferLineIndex: number): number => { - let visualLineCount = 0; - - // Count visual lines for all buffer lines up to and including the target line - for (let i = 0; i <= bufferLineIndex; i++) { - const line = buffer.getLine(i); - if (line) { - const lineLength = line.length; - // Calculate how many visual lines this buffer line occupies - // Each visual line can hold 'currentCols' characters - const visualLinesForThisLine = Math.max(1, Math.ceil(lineLength / currentCols)); - visualLineCount += visualLinesForThisLine; - } else { - // Empty line still takes one visual line - visualLineCount += 1; - } - } - - return visualLineCount; - }; - - // Calculate total visual lines in the buffer - let totalVisualLines = 0; - for (let i = 0; i < totalLines; i++) { - const line = buffer.getLine(i); - if (line) { - const lineLength = line.length; - totalVisualLines += Math.max(1, Math.ceil(lineLength / currentCols)); - } else { - totalVisualLines += 1; - } - } - - // Calculate the position of each match marker based on visual lines - const markers = matchLines.map((line, index) => { - // Convert scrollback line to buffer line - const bufferLine = totalLines - 1 - line; - // Calculate visual line position for this buffer line - const visualLinePosition = calculateVisualLinePosition(bufferLine); - // Calculate position as percentage based on visual lines (0 = top, 100 = bottom) - const position = totalVisualLines > 1 ? ((visualLinePosition - 1) / (totalVisualLines - 1)) * 100 : 50; - return { line, position, bufferLine, originalIndex: index }; - }); - - // Merge nearby markers into blocks - // Threshold: if markers are within 0.5% of position, merge them - const MERGE_THRESHOLD = 0.5; - const mergedBlocks: Array<{ - startPosition: number; - endPosition: number; - startLine: number; - endLine: number; - startIndex: number; - endIndex: number; - containsActive: boolean; - }> = []; - - if (markers.length > 0) { - let currentBlock = { - startPosition: markers[0].position, - endPosition: markers[0].position, - startLine: markers[0].line, - endLine: markers[0].line, - startIndex: markers[0].originalIndex, - endIndex: markers[0].originalIndex, - containsActive: false, - }; - - for (let i = 1; i < markers.length; i++) { - const prevMarker = markers[i - 1]; - const currMarker = markers[i]; - const positionDiff = Math.abs(currMarker.position - prevMarker.position); - - if (positionDiff <= MERGE_THRESHOLD) { - // Merge into current block - currentBlock.endPosition = currMarker.position; - currentBlock.endLine = currMarker.line; - currentBlock.endIndex = currMarker.originalIndex; - } else { - // Save current block and start a new one - mergedBlocks.push(currentBlock); - currentBlock = { - startPosition: currMarker.position, - endPosition: currMarker.position, - startLine: currMarker.line, - endLine: currMarker.line, - startIndex: currMarker.originalIndex, - endIndex: currMarker.originalIndex, - containsActive: false, - }; - } - } - // Don't forget the last block - mergedBlocks.push(currentBlock); - } - - // Check which blocks contain the active match - mergedBlocks.forEach((block) => { - const activeIndex = activeMatchIndex - 1; // Convert to 0-based - block.containsActive = totalMatches > 0 && activeIndex >= block.startIndex && activeIndex <= block.endIndex; - }); - - return ( -
- {mergedBlocks.map((block, blockIndex) => { - const topPosition = Math.max(0, Math.min(100, block.startPosition)) - 0.0; - const matchCount = block.endIndex - block.startIndex + 1; - const isActive = block.containsActive; - // Use fixed height: 3px for single match, 4px for multiple matches - const height = matchCount === 1 ? "3px" : "4px"; - - return ( -
{ - e.stopPropagation(); // Prevent scrollbar interaction - // Navigate to the first match in the block - handleMarkerClick(block.startLine, block.startIndex); - }} - onMouseEnter={(e) => { - // Increase opacity on hover for better visibility - e.currentTarget.style.opacity = "0.9"; - }} - onMouseLeave={(e) => { - // Restore opacity on leave - e.currentTarget.style.opacity = "0.4"; - }} - title={ - matchCount > 1 - ? `${matchCount} matches (lines ${block.startLine + 1}-${block.endLine + 1})` - : `Match ${block.startIndex + 1} of ${matchLines.length} (line ${block.startLine + 1})` - } - /> - ); - })} -
- ); -}; - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -export { SearchMatchIndicator }; diff --git a/frontend/app/view/term/term.scss b/frontend/app/view/term/term.scss index 383ad937eb..e8fe8261d6 100644 --- a/frontend/app/view/term/term.scss +++ b/frontend/app/view/term/term.scss @@ -208,15 +208,25 @@ } } - // Search match indicator styles - .search-match-indicator { - .search-match-marker { - // Ensure markers are visible even when scrollbar is over them - box-shadow: 0 0 2px rgba(255, 255, 0, 0.5); - - &.active { - box-shadow: 0 0 3px rgba(255, 150, 50, 0.7); - } - } + // Overview ruler styles for xterm search decorations + // Ensure overview ruler is visible and positioned correctly + // The overview ruler should be positioned to the left of the scrollbar + :global(.xterm-decoration-overview-ruler) { + width: 15px !important; + min-width: 15px; + height: 100% !important; + background-color: rgba(0, 0, 0, 0.3) !important; + border-left: 1px solid rgba(255, 255, 255, 0.3) !important; + z-index: 7 !important; // Below scrollbar overlay but above decorations + right: 6px !important; // Position to the left of scrollbar (scrollbar is 6px wide) + display: block !important; + visibility: visible !important; + } + + // Style for individual overview ruler markers + :global(.xterm-decoration-overview-ruler .xterm-decoration) { + width: 100% !important; + height: 2px !important; + min-height: 2px !important; } } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 9dc20d8171..e74367786d 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -15,7 +15,6 @@ import clsx from "clsx"; import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; -import { SearchMatchIndicator } from "./search-match-indicator"; import { TermStickers } from "./termsticker"; import { TermThemeUpdater } from "./termtheme"; import { computeTheme } from "./termutil"; @@ -185,14 +184,15 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const wholeWord = useAtomValueSafe(searchProps.wholeWord); const regex = useAtomValueSafe(searchProps.regex); const searchVal = jotai.useAtomValue(searchProps.searchValue); - const searchResultsIndex = jotai.useAtomValue(searchProps.resultsIndex); - const searchResultsCount = jotai.useAtomValue(searchProps.resultsCount); const searchDecorations = React.useMemo( () => ({ - matchOverviewRuler: "#000000", - activeMatchColorOverviewRuler: "#000000", + matchOverviewRuler: "#FFFF00", // Yellow for all matches + activeMatchColorOverviewRuler: "#FF9632", // Orange for active match activeMatchBorder: "#FF9632", matchBorder: "#FFFF00", + // Optional: add background colors for better visibility + matchBackground: undefined, // Let xterm use default + activeMatchBackground: undefined, // Let xterm use default }), [] ); @@ -279,6 +279,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => allowTransparency: true, scrollback: termScrollback, allowProposedApi: true, // Required by @xterm/addon-search to enable search functionality and decorations + overviewRulerWidth: 15, // Enable overview ruler for search match indicators ignoreBracketedPasteMode: !termAllowBPM, macOptionIsMeta: termMacOptionIsMeta, }, @@ -404,15 +405,6 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => className="term-scrollbar-hide-observer" onPointerOver={onScrollbarHideObserver} /> - {searchIsOpen && searchVal && ( - - )}