## CHANGES

- Search result scrolling now centers correctly within scrollable preview container 🎯
- Search scrolling clamps to non-negative positions for safer navigation 🛡️
- Edit-mode search no longer steals focus while you type—huge UX win ✍️
- Edit-mode search now resets match counts cleanly when query clears 🧹
- Match selection triggers only during navigation (Enter/Shift+Enter) ⌨️
- Added tracking refs to detect match navigation versus query edits 🧠
- Git status widget is now memoized to avoid needless re-renders 🚀
- Session hover tooltip content is memoized for smoother session lists 🧊
This commit is contained in:
Pedram Amini
2026-01-16 00:43:09 -06:00
parent 181a7f436b
commit a95ee31316
3 changed files with 48 additions and 23 deletions

View File

@@ -960,13 +960,17 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
// Scroll to current match
const currentRange = allRanges[targetIndex];
const rect = currentRange.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const scrollParent = contentRef.current;
if (scrollParent && rect) {
// Calculate scroll position to center the match
const scrollTop = scrollParent.scrollTop + rect.top - containerRect.top - scrollParent.clientHeight / 2 + rect.height / 2;
scrollParent.scrollTo({ top: scrollTop, behavior: 'smooth' });
// Calculate position of the match relative to the scroll container's top
// rect.top is viewport-relative, so we need to account for current scroll
// and the scroll container's viewport position
const scrollContainerRect = scrollParent.getBoundingClientRect();
const matchOffsetInScrollContainer = rect.top - scrollContainerRect.top + scrollParent.scrollTop;
// Calculate scroll position to center the match vertically
const scrollTop = matchOffsetInScrollContainer - scrollParent.clientHeight / 2 + rect.height / 2;
scrollParent.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' });
}
} else {
(CSS as any).highlights.delete('search-results');
@@ -1104,9 +1108,18 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
return formatShortcutKeys(shortcut.keys);
};
// Handle search in edit mode - jump to and select the match in textarea
// Track previous search query and match index for edit mode navigation
const prevSearchQueryRef = useRef<string>('');
const prevMatchIndexRef = useRef<number>(0);
// Handle search in edit mode - count matches and update state
// Note: We separate counting from selection to avoid stealing focus while typing
useEffect(() => {
if (!isEditableText || !markdownEditMode || !searchQuery.trim() || !textareaRef.current) {
if (isEditableText && markdownEditMode) {
setTotalMatches(0);
setCurrentMatchIndex(0);
}
return;
}
@@ -1134,20 +1147,29 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
return;
}
// Select the current match in the textarea
const currentMatch = matches[validIndex];
if (currentMatch) {
const textarea = textareaRef.current;
textarea.focus();
textarea.setSelectionRange(currentMatch.start, currentMatch.end);
// Only scroll and select when navigating between matches (Enter/Shift+Enter)
// or when search query is complete (user stopped typing)
// We detect navigation by checking if currentMatchIndex changed without searchQuery changing
const isNavigating = prevSearchQueryRef.current === searchQuery && prevMatchIndexRef.current !== currentMatchIndex;
prevSearchQueryRef.current = searchQuery;
prevMatchIndexRef.current = currentMatchIndex;
// Scroll to make the selection visible
// Calculate approximate line number and scroll to it
const textBeforeMatch = content.substring(0, currentMatch.start);
const lineNumber = textBeforeMatch.split('\n').length;
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 24;
const targetScroll = (lineNumber - 5) * lineHeight; // Leave some lines above
textarea.scrollTop = Math.max(0, targetScroll);
// Select the current match in the textarea only when navigating
if (isNavigating) {
const currentMatch = matches[validIndex];
if (currentMatch) {
const textarea = textareaRef.current;
textarea.focus();
textarea.setSelectionRange(currentMatch.start, currentMatch.end);
// Scroll to make the selection visible
// Calculate approximate line number and scroll to it
const textBeforeMatch = content.substring(0, currentMatch.start);
const lineNumber = textBeforeMatch.split('\n').length;
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 24;
const targetScroll = (lineNumber - 5) * lineHeight; // Leave some lines above
textarea.scrollTop = Math.max(0, targetScroll);
}
}
}, [searchQuery, currentMatchIndex, isEditableText, markdownEditMode, editContent]);

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, memo } from 'react';
import { GitBranch, Plus, Minus, FileEdit, FileDiff } from 'lucide-react';
import type { Theme } from '../types';
import { useGitStatus, type GitFileChange } from '../contexts/GitStatusContext';
@@ -22,8 +22,10 @@ interface GitStatusWidgetProps {
*
* The context provides detailed file changes (with line additions/deletions)
* only for the active session. Non-active sessions will show basic file counts.
*
* PERF: Memoized to prevent re-renders when parent re-renders with same props.
*/
export function GitStatusWidget({ sessionId, isGitRepo, theme, onViewDiff, compact = false }: GitStatusWidgetProps) {
export const GitStatusWidget = memo(function GitStatusWidget({ sessionId, isGitRepo, theme, onViewDiff, compact = false }: GitStatusWidgetProps) {
// Tooltip hover state with timeout for smooth UX
const [tooltipOpen, setTooltipOpen] = useState(false);
const tooltipTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -219,4 +221,4 @@ export function GitStatusWidget({ sessionId, isGitRepo, theme, onViewDiff, compa
)}
</div>
);
}
});

View File

@@ -551,6 +551,7 @@ function HamburgerMenuContent({
// ============================================================================
// SessionTooltipContent - Shared tooltip content for session hover previews
// PERF: Memoized to prevent re-renders when parent list re-renders
// ============================================================================
interface SessionTooltipContentProps {
@@ -561,7 +562,7 @@ interface SessionTooltipContentProps {
isInBatch?: boolean; // Whether session is running in auto mode
}
function SessionTooltipContent({
const SessionTooltipContent = memo(function SessionTooltipContent({
session,
theme,
gitFileCount,
@@ -728,7 +729,7 @@ function SessionTooltipContent({
</div>
</>
);
}
});
// Pre-compiled emoji regex for better performance (compiled once at module load)
// Matches common emoji patterns at the start of the string including: