diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index a889bd30..54d703e0 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -357,7 +357,8 @@ const resolveImagePath = (src: string, markdownFilePath: string): string => { // Custom image component for markdown that loads images from file paths // Uses a global cache to prevent re-fetching and flickering on re-renders -function MarkdownImage({ +// Wrapped in React.memo to prevent re-renders when parent updates but image props haven't changed +const MarkdownImage = React.memo(function MarkdownImage({ src, alt, markdownFilePath, @@ -555,7 +556,7 @@ function MarkdownImage({ onLoad={handleImageLoad} /> ); -} +}); // Remark plugin to support ==highlighted text== syntax function remarkHighlight() { @@ -607,1102 +608,1108 @@ function remarkHighlight() { }; } -export const FilePreview = forwardRef(function FilePreview( - { - file, - onClose, - theme, - markdownEditMode, - setMarkdownEditMode, - onSave, - shortcuts, - fileTree, - cwd, - onFileClick, - canGoBack, - canGoForward, - onNavigateBack, - onNavigateForward, - backHistory, - forwardHistory, - onNavigateToIndex, - currentHistoryIndex, - onOpenFuzzySearch, - onShortcutUsed, - ghCliAvailable, - onPublishGist, - hasGist, - onOpenInGraph, - sshRemoteId, - externalEditContent, - onEditContentChange, - initialScrollTop, - onScrollPositionChange, - initialSearchQuery, - onSearchQueryChange, - isTabMode, - }, - ref -) { - // Search state - use initialSearchQuery if provided, and notify parent of changes - const [internalSearchQuery, setInternalSearchQuery] = useState(initialSearchQuery ?? ''); - // Wrapper to update state and notify parent - const setSearchQuery = useCallback((query: string) => { - setInternalSearchQuery(query); - onSearchQueryChange?.(query); - }, [onSearchQueryChange]); - // Expose the current search query value - const searchQuery = internalSearchQuery; - // If initialSearchQuery is provided and non-empty, auto-open search - const [searchOpen, setSearchOpen] = useState(Boolean(initialSearchQuery)); - const [showCopyNotification, setShowCopyNotification] = useState(false); - const [showBackPopup, setShowBackPopup] = useState(false); - const [showForwardPopup, setShowForwardPopup] = useState(false); - const [showTocOverlay, setShowTocOverlay] = useState(false); - const backPopupTimeoutRef = useRef | null>(null); - const forwardPopupTimeoutRef = useRef | null>(null); - const [currentMatchIndex, setCurrentMatchIndex] = useState(0); - const [totalMatches, setTotalMatches] = useState(0); - const [fileStats, setFileStats] = useState(null); - const [showStatsBar, setShowStatsBar] = useState(true); - const [tokenCount, setTokenCount] = useState(null); - const [showRemoteImages, setShowRemoteImages] = useState(false); - // Edit mode state - use external content when provided (for file tab persistence) - const [internalEditContent, setInternalEditContent] = useState(''); - // Computed edit content - prefer external if provided - const editContent = externalEditContent ?? internalEditContent; - // Wrapper to update both internal state and notify parent - const setEditContent = useCallback((content: string) => { - setInternalEditContent(content); - onEditContentChange?.(content); - }, [onEditContentChange]); - const [isSaving, setIsSaving] = useState(false); - const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false); - const [copyNotificationMessage, setCopyNotificationMessage] = useState(''); - const searchInputRef = useRef(null); - const codeContainerRef = useRef(null); - const contentRef = useRef(null); - const containerRef = useRef(null); - const textareaRef = useRef(null); - const markdownContainerRef = useRef(null); - const layerIdRef = useRef(); - const matchElementsRef = useRef([]); - const cancelButtonRef = useRef(null); - const scrollSaveTimerRef = useRef | null>(null); - const tocButtonRef = useRef(null); - const tocOverlayRef = useRef(null); - - // Expose focus method to parent via ref - useImperativeHandle( - ref, - () => ({ - focus: () => { - containerRef.current?.focus(); +export const FilePreview = React.memo( + forwardRef(function FilePreview( + { + file, + onClose, + theme, + markdownEditMode, + setMarkdownEditMode, + onSave, + shortcuts, + fileTree, + cwd, + onFileClick, + canGoBack, + canGoForward, + onNavigateBack, + onNavigateForward, + backHistory, + forwardHistory, + onNavigateToIndex, + currentHistoryIndex, + onOpenFuzzySearch, + onShortcutUsed, + ghCliAvailable, + onPublishGist, + hasGist, + onOpenInGraph, + sshRemoteId, + externalEditContent, + onEditContentChange, + initialScrollTop, + onScrollPositionChange, + initialSearchQuery, + onSearchQueryChange, + isTabMode, + }, + ref + ) { + // Search state - use initialSearchQuery if provided, and notify parent of changes + const [internalSearchQuery, setInternalSearchQuery] = useState(initialSearchQuery ?? ''); + // Wrapper to update state and notify parent + const setSearchQuery = useCallback( + (query: string) => { + setInternalSearchQuery(query); + onSearchQueryChange?.(query); }, - }), - [] - ); + [onSearchQueryChange] + ); + // Expose the current search query value + const searchQuery = internalSearchQuery; + // If initialSearchQuery is provided and non-empty, auto-open search + const [searchOpen, setSearchOpen] = useState(Boolean(initialSearchQuery)); + const [showCopyNotification, setShowCopyNotification] = useState(false); + const [showBackPopup, setShowBackPopup] = useState(false); + const [showForwardPopup, setShowForwardPopup] = useState(false); + const [showTocOverlay, setShowTocOverlay] = useState(false); + const backPopupTimeoutRef = useRef | null>(null); + const forwardPopupTimeoutRef = useRef | null>(null); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [totalMatches, setTotalMatches] = useState(0); + const [fileStats, setFileStats] = useState(null); + const [showStatsBar, setShowStatsBar] = useState(true); + const [tokenCount, setTokenCount] = useState(null); + const [showRemoteImages, setShowRemoteImages] = useState(false); + // Edit mode state - use external content when provided (for file tab persistence) + const [internalEditContent, setInternalEditContent] = useState(''); + // Computed edit content - prefer external if provided + const editContent = externalEditContent ?? internalEditContent; + // Wrapper to update both internal state and notify parent + const setEditContent = useCallback( + (content: string) => { + setInternalEditContent(content); + onEditContentChange?.(content); + }, + [onEditContentChange] + ); + const [isSaving, setIsSaving] = useState(false); + const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false); + const [copyNotificationMessage, setCopyNotificationMessage] = useState(''); + const searchInputRef = useRef(null); + const codeContainerRef = useRef(null); + const contentRef = useRef(null); + const containerRef = useRef(null); + const textareaRef = useRef(null); + const markdownContainerRef = useRef(null); + const layerIdRef = useRef(); + const matchElementsRef = useRef([]); + const cancelButtonRef = useRef(null); + const scrollSaveTimerRef = useRef | null>(null); + const tocButtonRef = useRef(null); + const tocOverlayRef = useRef(null); - // Track if content has been modified - const hasChanges = markdownEditMode && editContent !== file?.content; + // Expose focus method to parent via ref + useImperativeHandle( + ref, + () => ({ + focus: () => { + containerRef.current?.focus(); + }, + }), + [] + ); - const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); + // Track if content has been modified + const hasChanges = markdownEditMode && editContent !== file?.content; - // Compute derived values - must be before any early returns but after hooks - const language = file ? getLanguageFromFilename(file.name) : ''; - const isMarkdown = language === 'markdown'; - const isImage = file ? isImageFile(file.name) : false; + const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); - // Check for binary files - either by extension or by content analysis - // Memoize to avoid recalculating on every render (content analysis can be expensive) - const isBinary = useMemo(() => { - if (!file) return false; - if (isImage) return false; - return isBinaryExtension(file.name) || isBinaryContent(file.content); - }, [isImage, file]); + // Compute derived values - must be before any early returns but after hooks + const language = file ? getLanguageFromFilename(file.name) : ''; + const isMarkdown = language === 'markdown'; + const isImage = file ? isImageFile(file.name) : false; - // Any non-binary, non-image file can be edited as text - const isEditableText = !isImage && !isBinary; + // Check for binary files - either by extension or by content analysis + // Memoize to avoid recalculating on every render (content analysis can be expensive) + const isBinary = useMemo(() => { + if (!file) return false; + if (isImage) return false; + return isBinaryExtension(file.name) || isBinaryContent(file.content); + }, [isImage, file]); - // Check if file is large (for performance optimizations) - // Use content length as primary check since fileStats may not be loaded yet - const isLargeFile = useMemo(() => { - if (!file?.content) return false; - return file.content.length > LARGE_FILE_TOKEN_SKIP_THRESHOLD; - }, [file?.content]); + // Any non-binary, non-image file can be edited as text + const isEditableText = !isImage && !isBinary; - // For very large files, truncate content for syntax highlighting to prevent freezes - const displayContent = useMemo(() => { - if (!file?.content) return ''; - if (!isMarkdown && !isImage && !isBinary && file.content.length > LARGE_FILE_PREVIEW_LIMIT) { - return file.content.substring(0, LARGE_FILE_PREVIEW_LIMIT); - } - return file.content; - }, [file?.content, isMarkdown, isImage, isBinary]); + // Check if file is large (for performance optimizations) + // Use content length as primary check since fileStats may not be loaded yet + const isLargeFile = useMemo(() => { + if (!file?.content) return false; + return file.content.length > LARGE_FILE_TOKEN_SKIP_THRESHOLD; + }, [file?.content]); - // Track if content is truncated for display - const isContentTruncated = file?.content && displayContent.length < file.content.length; + // For very large files, truncate content for syntax highlighting to prevent freezes + const displayContent = useMemo(() => { + if (!file?.content) return ''; + if (!isMarkdown && !isImage && !isBinary && file.content.length > LARGE_FILE_PREVIEW_LIMIT) { + return file.content.substring(0, LARGE_FILE_PREVIEW_LIMIT); + } + return file.content; + }, [file?.content, isMarkdown, isImage, isBinary]); - // Calculate task counts for markdown files - const taskCounts = useMemo(() => { - if (!isMarkdown || !file?.content) return null; - const counts = countMarkdownTasks(file.content); - // Only return if there are any tasks - if (counts.open === 0 && counts.closed === 0) return null; - return counts; - }, [isMarkdown, file?.content]); + // Track if content is truncated for display + const isContentTruncated = file?.content && displayContent.length < file.content.length; - // Extract table of contents entries for markdown files - const tocEntries = useMemo(() => { - if (!isMarkdown || !file?.content) return []; - return extractHeadings(file.content); - }, [isMarkdown, file?.content]); + // Calculate task counts for markdown files + const taskCounts = useMemo(() => { + if (!isMarkdown || !file?.content) return null; + const counts = countMarkdownTasks(file.content); + // Only return if there are any tasks + if (counts.open === 0 && counts.closed === 0) return null; + return counts; + }, [isMarkdown, file?.content]); - const scrollMarkdownToBoundary = useCallback( - (direction: 'top' | 'bottom') => { + // Extract table of contents entries for markdown files + const tocEntries = useMemo(() => { + if (!isMarkdown || !file?.content) return []; + return extractHeadings(file.content); + }, [isMarkdown, file?.content]); + + const scrollMarkdownToBoundary = useCallback((direction: 'top' | 'bottom') => { // Use contentRef which is the actual scrollable container const container = contentRef.current; if (!container) return; const top = direction === 'top' ? 0 : container.scrollHeight; container.scrollTo({ top, behavior: 'smooth' }); - }, - [] - ); + }, []); - // Memoize file tree indices to avoid O(n) traversal on every render - const fileTreeIndices = useMemo(() => { - if (fileTree && fileTree.length > 0) { - return buildFileTreeIndices(fileTree); - } - return null; - }, [fileTree]); + // Memoize file tree indices to avoid O(n) traversal on every render + const fileTreeIndices = useMemo(() => { + if (fileTree && fileTree.length > 0) { + return buildFileTreeIndices(fileTree); + } + return null; + }, [fileTree]); - // Memoize remarkPlugins to prevent infinite render loops - // Creating new arrays/objects on each render causes ReactMarkdown to re-render children - const remarkPlugins = useMemo( - () => [ - remarkGfm, - remarkFrontmatter, - remarkFrontmatterTable, - remarkHighlight, - ...(fileTree && fileTree.length > 0 && cwd !== undefined - ? [[remarkFileLinks, { indices: fileTreeIndices || undefined, cwd }] as any] - : []), - ], - [fileTree, fileTreeIndices, cwd] - ); + // Memoize remarkPlugins to prevent infinite render loops + // Creating new arrays/objects on each render causes ReactMarkdown to re-render children + const remarkPlugins = useMemo( + () => [ + remarkGfm, + remarkFrontmatter, + remarkFrontmatterTable, + remarkHighlight, + ...(fileTree && fileTree.length > 0 && cwd !== undefined + ? [[remarkFileLinks, { indices: fileTreeIndices || undefined, cwd }] as any] + : []), + ], + [fileTree, fileTreeIndices, cwd] + ); - // Memoize rehypePlugins array to prevent unnecessary re-renders - const rehypePlugins = useMemo(() => [rehypeRaw, rehypeSlug], []); + // Memoize rehypePlugins array to prevent unnecessary re-renders + const rehypePlugins = useMemo(() => [rehypeRaw, rehypeSlug], []); - // Memoize ReactMarkdown components to prevent infinite render loops - // The img component was causing loops because MarkdownImage useEffect sets state, - // which triggers parent re-render, creating new components object, remounting MarkdownImage - const markdownComponents = useMemo( - () => ({ - a: ({ node: _node, href, children, ...props }: any) => { - // Check for maestro-file:// protocol OR data-maestro-file attribute - // (data attribute is fallback when rehype strips custom protocols) - const dataFilePath = (props as any)['data-maestro-file']; - const isMaestroFile = href?.startsWith('maestro-file://') || !!dataFilePath; - const filePath = - dataFilePath || - (href?.startsWith('maestro-file://') ? href.replace('maestro-file://', '') : null); + // Memoize ReactMarkdown components to prevent infinite render loops + // The img component was causing loops because MarkdownImage useEffect sets state, + // which triggers parent re-render, creating new components object, remounting MarkdownImage + const markdownComponents = useMemo( + () => ({ + a: ({ node: _node, href, children, ...props }: any) => { + // Check for maestro-file:// protocol OR data-maestro-file attribute + // (data attribute is fallback when rehype strips custom protocols) + const dataFilePath = (props as any)['data-maestro-file']; + const isMaestroFile = href?.startsWith('maestro-file://') || !!dataFilePath; + const filePath = + dataFilePath || + (href?.startsWith('maestro-file://') ? href.replace('maestro-file://', '') : null); - // Check for anchor links (same-page navigation) - const isAnchorLink = href?.startsWith('#') ?? false; - const anchorId = isAnchorLink && href ? href.slice(1) : null; + // Check for anchor links (same-page navigation) + const isAnchorLink = href?.startsWith('#') ?? false; + const anchorId = isAnchorLink && href ? href.slice(1) : null; - return ( - { - e.preventDefault(); - if (isMaestroFile && filePath && onFileClick) { - // Cmd/Ctrl+Click opens in new tab, regular click replaces current tab - const openInNewTab = e.metaKey || e.ctrlKey; - onFileClick(filePath, { openInNewTab }); - } else if (isAnchorLink && anchorId) { - // Handle anchor links - scroll to the target element - const targetElement = markdownContainerRef.current - ? markdownContainerRef.current.querySelector(`#${CSS.escape(anchorId)}`) - : document.getElementById(anchorId); - if (targetElement) { - targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return ( + { + e.preventDefault(); + if (isMaestroFile && filePath && onFileClick) { + // Cmd/Ctrl+Click opens in new tab, regular click replaces current tab + const openInNewTab = e.metaKey || e.ctrlKey; + onFileClick(filePath, { openInNewTab }); + } else if (isAnchorLink && anchorId) { + // Handle anchor links - scroll to the target element + const targetElement = markdownContainerRef.current + ? markdownContainerRef.current.querySelector(`#${CSS.escape(anchorId)}`) + : document.getElementById(anchorId); + if (targetElement) { + targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } else if (href) { + window.maestro.shell.openExternal(href); } - } else if (href) { - window.maestro.shell.openExternal(href); + }} + style={{ color: theme.colors.accent, textDecoration: 'underline', cursor: 'pointer' }} + > + {children} + + ); + }, + code: ({ node: _node, inline, className, children, ...props }: any) => { + const match = (className || '').match(/language-(\w+)/); + const lang = match ? match[1] : 'text'; + const codeContent = String(children).replace(/\n$/, ''); + + // Handle mermaid code blocks + if (!inline && lang === 'mermaid') { + return ; + } + + return !inline && match ? ( + + {codeContent} + + ) : ( + + {children} + + ); + }, + img: ({ node: _node, src, alt, ...props }: any) => { + // Check if this image came from file tree (set by remarkFileLinks) + const isFromTree = (props as any)['data-maestro-from-tree'] === 'true'; + // Get the project root from the markdown file path (directory containing the file tree root) + // For FilePreview, the file.path is absolute, so we extract the root from it + // If image is from file tree, we need the project root to resolve correctly + // The project root would be the common ancestor - we'll derive it from the file path + // For now, use the directory where the first folder in cwd would be located + let projectRootForImage: string | undefined; + if (isFromTree && cwd && file) { + // cwd is relative path like "People" or "OPSWAT/Meetings" + // We need to find where in file.path the cwd starts + const cwdIndex = file.path.indexOf(`/${cwd}/`); + if (cwdIndex !== -1) { + projectRootForImage = file.path.substring(0, cwdIndex); + } else { + // Try to find just the first segment of cwd + const firstCwdSegment = cwd.split('/')[0]; + const segmentIndex = file.path.indexOf(`/${firstCwdSegment}/`); + if (segmentIndex !== -1) { + projectRootForImage = file.path.substring(0, segmentIndex); } - }} - style={{ color: theme.colors.accent, textDecoration: 'underline', cursor: 'pointer' }} - > - {children} - - ); - }, - code: ({ node: _node, inline, className, children, ...props }: any) => { - const match = (className || '').match(/language-(\w+)/); - const lang = match ? match[1] : 'text'; - const codeContent = String(children).replace(/\n$/, ''); - - // Handle mermaid code blocks - if (!inline && lang === 'mermaid') { - return ; - } - - return !inline && match ? ( - - {codeContent} - - ) : ( - - {children} - - ); - }, - img: ({ node: _node, src, alt, ...props }: any) => { - // Check if this image came from file tree (set by remarkFileLinks) - const isFromTree = (props as any)['data-maestro-from-tree'] === 'true'; - // Get the project root from the markdown file path (directory containing the file tree root) - // For FilePreview, the file.path is absolute, so we extract the root from it - // If image is from file tree, we need the project root to resolve correctly - // The project root would be the common ancestor - we'll derive it from the file path - // For now, use the directory where the first folder in cwd would be located - let projectRootForImage: string | undefined; - if (isFromTree && cwd && file) { - // cwd is relative path like "People" or "OPSWAT/Meetings" - // We need to find where in file.path the cwd starts - const cwdIndex = file.path.indexOf(`/${cwd}/`); - if (cwdIndex !== -1) { - projectRootForImage = file.path.substring(0, cwdIndex); - } else { - // Try to find just the first segment of cwd - const firstCwdSegment = cwd.split('/')[0]; - const segmentIndex = file.path.indexOf(`/${firstCwdSegment}/`); - if (segmentIndex !== -1) { - projectRootForImage = file.path.substring(0, segmentIndex); } } - } - return ( - - ); - }, - }), - [onFileClick, theme, cwd, file, showRemoteImages, sshRemoteId] - ); + return ( + + ); + }, + }), + [onFileClick, theme, cwd, file, showRemoteImages, sshRemoteId] + ); - // Extract directory path without filename - const directoryPath = file ? file.path.substring(0, file.path.lastIndexOf('/')) : ''; + // Extract directory path without filename + const directoryPath = file ? file.path.substring(0, file.path.lastIndexOf('/')) : ''; - // Fetch file stats when file changes - useEffect(() => { - if (file?.path) { - window.maestro.fs - .stat(file.path, sshRemoteId) - .then((stats) => - setFileStats({ - size: stats.size, - createdAt: stats.createdAt, - modifiedAt: stats.modifiedAt, - }) - ) - .catch((err) => { - console.error('Failed to get file stats:', err); - setFileStats(null); - }); - } - }, [file?.path, sshRemoteId]); + // Fetch file stats when file changes + useEffect(() => { + if (file?.path) { + window.maestro.fs + .stat(file.path, sshRemoteId) + .then((stats) => + setFileStats({ + size: stats.size, + createdAt: stats.createdAt, + modifiedAt: stats.modifiedAt, + }) + ) + .catch((err) => { + console.error('Failed to get file stats:', err); + setFileStats(null); + }); + } + }, [file?.path, sshRemoteId]); - // Count tokens when file content changes (skip for images, binary files, and large files) - // Large files would freeze the UI during token encoding - useEffect(() => { - if (!file?.content || isImage || isBinary || isLargeFile) { - setTokenCount(null); - return; - } - - getEncoder() - .then((encoder) => { - const tokens = encoder.encode(file.content); - setTokenCount(tokens.length); - }) - .catch((err) => { - console.error('Failed to count tokens:', err); + // Count tokens when file content changes (skip for images, binary files, and large files) + // Large files would freeze the UI during token encoding + useEffect(() => { + if (!file?.content || isImage || isBinary || isLargeFile) { setTokenCount(null); - }); - }, [file?.content, isImage, isBinary, isLargeFile]); - - // Sync internal edit content when file changes (only when NOT using external content) - // When externalEditContent is provided (file tab mode), the parent manages the state - useEffect(() => { - if (file?.content && externalEditContent === undefined) { - setInternalEditContent(file.content); - } - }, [file?.content, file?.path, externalEditContent]); - - // Focus appropriate element and sync scroll position when mode changes - const prevMarkdownEditModeRef = useRef(markdownEditMode); - useEffect(() => { - const wasEditMode = prevMarkdownEditModeRef.current; - prevMarkdownEditModeRef.current = markdownEditMode; - - if (markdownEditMode && textareaRef.current) { - // Entering edit mode - focus textarea and sync scroll from preview - if (!wasEditMode && contentRef.current) { - // Calculate scroll percentage from preview mode - const { scrollTop, scrollHeight, clientHeight } = contentRef.current; - const maxScroll = scrollHeight - clientHeight; - const scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0; - - // Apply scroll percentage to textarea after it renders - requestAnimationFrame(() => { - if (textareaRef.current) { - const { scrollHeight: textareaScrollHeight, clientHeight: textareaClientHeight } = - textareaRef.current; - const textareaMaxScroll = textareaScrollHeight - textareaClientHeight; - textareaRef.current.scrollTop = Math.round(scrollPercent * textareaMaxScroll); - } - }); + return; } - textareaRef.current.focus(); - } else if (!markdownEditMode && wasEditMode && containerRef.current) { - // Exiting edit mode - focus container and sync scroll from textarea - if (textareaRef.current && contentRef.current) { - // Calculate scroll percentage from edit mode - const { scrollTop, scrollHeight, clientHeight } = textareaRef.current; - const maxScroll = scrollHeight - clientHeight; - const scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0; - // Apply scroll percentage to preview after it renders - requestAnimationFrame(() => { - if (contentRef.current) { - const { scrollHeight: previewScrollHeight, clientHeight: previewClientHeight } = - contentRef.current; - const previewMaxScroll = previewScrollHeight - previewClientHeight; - contentRef.current.scrollTop = Math.round(scrollPercent * previewMaxScroll); - } + getEncoder() + .then((encoder) => { + const tokens = encoder.encode(file.content); + setTokenCount(tokens.length); + }) + .catch((err) => { + console.error('Failed to count tokens:', err); + setTokenCount(null); }); + }, [file?.content, isImage, isBinary, isLargeFile]); + + // Sync internal edit content when file changes (only when NOT using external content) + // When externalEditContent is provided (file tab mode), the parent manages the state + useEffect(() => { + if (file?.content && externalEditContent === undefined) { + setInternalEditContent(file.content); } - containerRef.current.focus(); - } - }, [markdownEditMode]); + }, [file?.content, file?.path, externalEditContent]); - // Save handler - const handleSave = useCallback(async () => { - if (!file || !onSave || !hasChanges || isSaving) return; + // Focus appropriate element and sync scroll position when mode changes + const prevMarkdownEditModeRef = useRef(markdownEditMode); + useEffect(() => { + const wasEditMode = prevMarkdownEditModeRef.current; + prevMarkdownEditModeRef.current = markdownEditMode; - setIsSaving(true); - try { - await onSave(file.path, editContent); - setCopyNotificationMessage('File Saved'); - setShowCopyNotification(true); - setTimeout(() => setShowCopyNotification(false), 2000); - } catch (err) { - console.error('Failed to save file:', err); - setCopyNotificationMessage('Save Failed'); - setShowCopyNotification(true); - setTimeout(() => setShowCopyNotification(false), 2000); - } finally { - setIsSaving(false); - } - }, [file, onSave, hasChanges, isSaving, editContent]); + if (markdownEditMode && textareaRef.current) { + // Entering edit mode - focus textarea and sync scroll from preview + if (!wasEditMode && contentRef.current) { + // Calculate scroll percentage from preview mode + const { scrollTop, scrollHeight, clientHeight } = contentRef.current; + const maxScroll = scrollHeight - clientHeight; + const scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0; - // Track scroll position to show/hide stats bar and report changes - useEffect(() => { - const contentEl = contentRef.current; - if (!contentEl) return; + // Apply scroll percentage to textarea after it renders + requestAnimationFrame(() => { + if (textareaRef.current) { + const { scrollHeight: textareaScrollHeight, clientHeight: textareaClientHeight } = + textareaRef.current; + const textareaMaxScroll = textareaScrollHeight - textareaClientHeight; + textareaRef.current.scrollTop = Math.round(scrollPercent * textareaMaxScroll); + } + }); + } + textareaRef.current.focus(); + } else if (!markdownEditMode && wasEditMode && containerRef.current) { + // Exiting edit mode - focus container and sync scroll from textarea + if (textareaRef.current && contentRef.current) { + // Calculate scroll percentage from edit mode + const { scrollTop, scrollHeight, clientHeight } = textareaRef.current; + const maxScroll = scrollHeight - clientHeight; + const scrollPercent = maxScroll > 0 ? scrollTop / maxScroll : 0; - const handleScroll = () => { - // Show stats bar when scrolled to top (within 10px), hide otherwise - setShowStatsBar(contentEl.scrollTop <= 10); + // Apply scroll percentage to preview after it renders + requestAnimationFrame(() => { + if (contentRef.current) { + const { scrollHeight: previewScrollHeight, clientHeight: previewClientHeight } = + contentRef.current; + const previewMaxScroll = previewScrollHeight - previewClientHeight; + contentRef.current.scrollTop = Math.round(scrollPercent * previewMaxScroll); + } + }); + } + containerRef.current.focus(); + } + }, [markdownEditMode]); - // Throttled scroll position save (200ms) - same timing as TerminalOutput - if (onScrollPositionChange) { + // Save handler + const handleSave = useCallback(async () => { + if (!file || !onSave || !hasChanges || isSaving) return; + + setIsSaving(true); + try { + await onSave(file.path, editContent); + setCopyNotificationMessage('File Saved'); + setShowCopyNotification(true); + setTimeout(() => setShowCopyNotification(false), 2000); + } catch (err) { + console.error('Failed to save file:', err); + setCopyNotificationMessage('Save Failed'); + setShowCopyNotification(true); + setTimeout(() => setShowCopyNotification(false), 2000); + } finally { + setIsSaving(false); + } + }, [file, onSave, hasChanges, isSaving, editContent]); + + // Track scroll position to show/hide stats bar and report changes + useEffect(() => { + const contentEl = contentRef.current; + if (!contentEl) return; + + const handleScroll = () => { + // Show stats bar when scrolled to top (within 10px), hide otherwise + setShowStatsBar(contentEl.scrollTop <= 10); + + // Throttled scroll position save (200ms) - same timing as TerminalOutput + if (onScrollPositionChange) { + if (scrollSaveTimerRef.current) { + clearTimeout(scrollSaveTimerRef.current); + } + scrollSaveTimerRef.current = setTimeout(() => { + onScrollPositionChange(contentEl.scrollTop); + scrollSaveTimerRef.current = null; + }, 200); + } + }; + + contentEl.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + contentEl.removeEventListener('scroll', handleScroll); + // Clear any pending scroll save timer if (scrollSaveTimerRef.current) { clearTimeout(scrollSaveTimerRef.current); - } - scrollSaveTimerRef.current = setTimeout(() => { - onScrollPositionChange(contentEl.scrollTop); scrollSaveTimerRef.current = null; - }, 200); - } - }; + } + }; + }, [onScrollPositionChange]); - contentEl.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - contentEl.removeEventListener('scroll', handleScroll); - // Clear any pending scroll save timer - if (scrollSaveTimerRef.current) { - clearTimeout(scrollSaveTimerRef.current); - scrollSaveTimerRef.current = null; - } - }; - }, [onScrollPositionChange]); + // Restore scroll position when initialScrollTop is provided (file tab switching) + // Use a ref to track if we've already restored for this file to avoid re-scrolling on re-renders + const hasRestoredScrollRef = useRef(null); + useEffect(() => { + const contentEl = contentRef.current; + if (!contentEl || !file?.path) return; - // Restore scroll position when initialScrollTop is provided (file tab switching) - // Use a ref to track if we've already restored for this file to avoid re-scrolling on re-renders - const hasRestoredScrollRef = useRef(null); - useEffect(() => { - const contentEl = contentRef.current; - if (!contentEl || !file?.path) return; - - // Only restore if this is a new file and we have a scroll position to restore - if ( - initialScrollTop !== undefined && - initialScrollTop > 0 && - hasRestoredScrollRef.current !== file.path - ) { - // Use requestAnimationFrame to ensure DOM is ready - requestAnimationFrame(() => { - contentEl.scrollTop = initialScrollTop; - }); - hasRestoredScrollRef.current = file.path; - } else if (hasRestoredScrollRef.current !== file.path) { - // New file without saved scroll position - reset to top - hasRestoredScrollRef.current = file.path; - } - }, [file?.path, initialScrollTop]); - - // Auto-focus on mount and when file changes so keyboard shortcuts work immediately - useEffect(() => { - containerRef.current?.focus(); - // Close TOC overlay when file changes - setShowTocOverlay(false); - }, [file?.path]); // Run on mount and when navigating to a different file - - // Helper to handle escape key - shows confirmation modal if there are unsaved changes - // In tab mode: Escape only closes internal UI (search, TOC), not the tab itself - // Tabs close via Cmd+W or clicking the close button, not Escape - const handleEscapeRequest = useCallback(() => { - if (showTocOverlay) { - setShowTocOverlay(false); - containerRef.current?.focus(); - } else if (searchOpen) { - setSearchOpen(false); - setSearchQuery(''); - // Refocus container so keyboard navigation (arrow keys) still works - containerRef.current?.focus(); - } else if (!isTabMode) { - // Only close the preview if NOT in tab mode (overlay behavior) - // Tabs should not close on Escape - use Cmd+W or close button - if (hasChanges) { - // Show confirmation modal if there are unsaved changes - setShowUnsavedChangesModal(true); - } else { - onClose(); - } - } - // In tab mode with no internal UI open, Escape does nothing - }, [showTocOverlay, searchOpen, hasChanges, onClose, isTabMode]); - - // Register layer on mount - only for overlay mode (not tab mode) - // Tab mode: File preview is part of the main panel content, not an overlay - // It doesn't need layer registration since it doesn't block keyboard shortcuts or need focus trapping - // Note: handleEscapeRequest is intentionally NOT in the dependency array to prevent - // infinite re-registration loops when its dependencies (hasChanges, searchOpen) change. - // The subsequent useEffect with updateLayerHandler handles keeping the handler current. - useEffect(() => { - // Skip layer registration entirely in tab mode - tabs are main content, not overlays - if (isTabMode) { - return; - } - - layerIdRef.current = registerLayer({ - type: 'overlay', - priority: MODAL_PRIORITIES.FILE_PREVIEW, - blocksLowerLayers: true, - capturesFocus: true, - focusTrap: 'lenient', - ariaLabel: 'File Preview', - onEscape: handleEscapeRequest, - allowClickOutside: false, - }); - - return () => { - if (layerIdRef.current) { - unregisterLayer(layerIdRef.current); - } - }; - - }, [registerLayer, unregisterLayer, isTabMode]); - - // Update handler when dependencies change (only for overlay mode) - useEffect(() => { - if (layerIdRef.current && !isTabMode) { - updateLayerHandler(layerIdRef.current, handleEscapeRequest); - } - }, [handleEscapeRequest, updateLayerHandler, isTabMode]); - - // Click outside to dismiss (same behavior as Escape) - // Use delay to prevent the click that opened the preview from immediately closing it - // Disable click-outside in tab mode - tabs should only close via explicit user action - useClickOutside(containerRef, handleEscapeRequest, !!file && !isTabMode, { delay: true }); - - // Click outside ToC overlay to dismiss (exclude both overlay and the toggle button) - // Use delay to prevent the click that opened it from immediately closing it - const closeTocOverlay = useCallback(() => setShowTocOverlay(false), []); - useClickOutside([tocOverlayRef, tocButtonRef], closeTocOverlay, showTocOverlay, { delay: true }); - - // Keep search input focused when search is open - useEffect(() => { - if (searchOpen && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [searchOpen, searchQuery]); - - // Highlight search matches in syntax-highlighted code - useEffect(() => { - if (!searchQuery.trim() || !codeContainerRef.current || isMarkdown || isImage) { - setTotalMatches(0); - setCurrentMatchIndex(0); - matchElementsRef.current = []; - return; - } - - const container = codeContainerRef.current; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - const textNodes: Text[] = []; - - // Collect all text nodes - let node; - while ((node = walker.nextNode())) { - textNodes.push(node as Text); - } - - // Escape regex special characters - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); - const matchElements: HTMLElement[] = []; - - // Highlight matches using safe DOM methods - textNodes.forEach((textNode) => { - const text = textNode.textContent || ''; - const matches = text.match(regex); - - if (matches) { - const fragment = document.createDocumentFragment(); - let lastIndex = 0; - - text.replace(regex, (match, offset) => { - // Add text before match - if (offset > lastIndex) { - fragment.appendChild(document.createTextNode(text.substring(lastIndex, offset))); - } - - // Add highlighted match - const mark = document.createElement('mark'); - mark.style.backgroundColor = '#ffd700'; - mark.style.color = '#000'; - mark.style.padding = '0 2px'; - mark.style.borderRadius = '2px'; - mark.className = 'search-match'; - mark.textContent = match; - fragment.appendChild(mark); - matchElements.push(mark); - - lastIndex = offset + match.length; - return match; + // Only restore if this is a new file and we have a scroll position to restore + if ( + initialScrollTop !== undefined && + initialScrollTop > 0 && + hasRestoredScrollRef.current !== file.path + ) { + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + contentEl.scrollTop = initialScrollTop; }); - - // Add remaining text - if (lastIndex < text.length) { - fragment.appendChild(document.createTextNode(text.substring(lastIndex))); - } - - textNode.parentNode?.replaceChild(fragment, textNode); + hasRestoredScrollRef.current = file.path; + } else if (hasRestoredScrollRef.current !== file.path) { + // New file without saved scroll position - reset to top + hasRestoredScrollRef.current = file.path; } + }, [file?.path, initialScrollTop]); + + // Auto-focus on mount and when file changes so keyboard shortcuts work immediately + useEffect(() => { + containerRef.current?.focus(); + // Close TOC overlay when file changes + setShowTocOverlay(false); + }, [file?.path]); // Run on mount and when navigating to a different file + + // Helper to handle escape key - shows confirmation modal if there are unsaved changes + // In tab mode: Escape only closes internal UI (search, TOC), not the tab itself + // Tabs close via Cmd+W or clicking the close button, not Escape + const handleEscapeRequest = useCallback(() => { + if (showTocOverlay) { + setShowTocOverlay(false); + containerRef.current?.focus(); + } else if (searchOpen) { + setSearchOpen(false); + setSearchQuery(''); + // Refocus container so keyboard navigation (arrow keys) still works + containerRef.current?.focus(); + } else if (!isTabMode) { + // Only close the preview if NOT in tab mode (overlay behavior) + // Tabs should not close on Escape - use Cmd+W or close button + if (hasChanges) { + // Show confirmation modal if there are unsaved changes + setShowUnsavedChangesModal(true); + } else { + onClose(); + } + } + // In tab mode with no internal UI open, Escape does nothing + }, [showTocOverlay, searchOpen, hasChanges, onClose, isTabMode]); + + // Register layer on mount - only for overlay mode (not tab mode) + // Tab mode: File preview is part of the main panel content, not an overlay + // It doesn't need layer registration since it doesn't block keyboard shortcuts or need focus trapping + // Note: handleEscapeRequest is intentionally NOT in the dependency array to prevent + // infinite re-registration loops when its dependencies (hasChanges, searchOpen) change. + // The subsequent useEffect with updateLayerHandler handles keeping the handler current. + useEffect(() => { + // Skip layer registration entirely in tab mode - tabs are main content, not overlays + if (isTabMode) { + return; + } + + layerIdRef.current = registerLayer({ + type: 'overlay', + priority: MODAL_PRIORITIES.FILE_PREVIEW, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'lenient', + ariaLabel: 'File Preview', + onEscape: handleEscapeRequest, + allowClickOutside: false, + }); + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer, isTabMode]); + + // Update handler when dependencies change (only for overlay mode) + useEffect(() => { + if (layerIdRef.current && !isTabMode) { + updateLayerHandler(layerIdRef.current, handleEscapeRequest); + } + }, [handleEscapeRequest, updateLayerHandler, isTabMode]); + + // Click outside to dismiss (same behavior as Escape) + // Use delay to prevent the click that opened the preview from immediately closing it + // Disable click-outside in tab mode - tabs should only close via explicit user action + useClickOutside(containerRef, handleEscapeRequest, !!file && !isTabMode, { delay: true }); + + // Click outside ToC overlay to dismiss (exclude both overlay and the toggle button) + // Use delay to prevent the click that opened it from immediately closing it + const closeTocOverlay = useCallback(() => setShowTocOverlay(false), []); + useClickOutside([tocOverlayRef, tocButtonRef], closeTocOverlay, showTocOverlay, { + delay: true, }); - // Store match elements and update count - matchElementsRef.current = matchElements; - setTotalMatches(matchElements.length); - setCurrentMatchIndex(matchElements.length > 0 ? 0 : -1); + // Keep search input focused when search is open + useEffect(() => { + if (searchOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [searchOpen, searchQuery]); - // Highlight first match with different color and scroll to it - if (matchElements.length > 0) { - matchElements[0].style.backgroundColor = theme.colors.accent; - matchElements[0].style.color = '#fff'; - matchElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - - // Cleanup function to remove highlights - return () => { - container.querySelectorAll('mark.search-match').forEach((mark) => { - const parent = mark.parentNode; - if (parent) { - parent.replaceChild(document.createTextNode(mark.textContent || ''), mark); - parent.normalize(); - } - }); - matchElementsRef.current = []; - }; - }, [searchQuery, file?.content, isMarkdown, isImage, theme.colors.accent]); - - // Search matches in markdown preview mode - use CSS Custom Highlight API - useEffect(() => { - if (!isMarkdown || markdownEditMode || !searchQuery.trim() || !markdownContainerRef.current) { - if (isMarkdown && !markdownEditMode) { + // Highlight search matches in syntax-highlighted code + useEffect(() => { + if (!searchQuery.trim() || !codeContainerRef.current || isMarkdown || isImage) { setTotalMatches(0); setCurrentMatchIndex(0); matchElementsRef.current = []; - // Clear any existing highlights - if ('highlights' in CSS) { - (CSS as any).highlights.delete('search-results'); - (CSS as any).highlights.delete('search-current'); - } + return; } - return; - } - const container = markdownContainerRef.current; - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const searchRegex = new RegExp(escapedQuery, 'gi'); - - // Check if CSS Custom Highlight API is available - if ('highlights' in CSS) { - const allRanges: Range[] = []; + const container = codeContainerRef.current; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; - // Find all text nodes and create ranges for matches - let textNode; - while ((textNode = walker.nextNode())) { + // Collect all text nodes + let node; + while ((node = walker.nextNode())) { + textNodes.push(node as Text); + } + + // Escape regex special characters + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedQuery, 'gi'); + const matchElements: HTMLElement[] = []; + + // Highlight matches using safe DOM methods + textNodes.forEach((textNode) => { const text = textNode.textContent || ''; - let match; - const localRegex = new RegExp(escapedQuery, 'gi'); - while ((match = localRegex.exec(text)) !== null) { - const range = document.createRange(); - range.setStart(textNode, match.index); - range.setEnd(textNode, match.index + match[0].length); - allRanges.push(range); + const matches = text.match(regex); + + if (matches) { + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + + text.replace(regex, (match, offset) => { + // Add text before match + if (offset > lastIndex) { + fragment.appendChild(document.createTextNode(text.substring(lastIndex, offset))); + } + + // Add highlighted match + const mark = document.createElement('mark'); + mark.style.backgroundColor = '#ffd700'; + mark.style.color = '#000'; + mark.style.padding = '0 2px'; + mark.style.borderRadius = '2px'; + mark.className = 'search-match'; + mark.textContent = match; + fragment.appendChild(mark); + matchElements.push(mark); + + lastIndex = offset + match.length; + return match; + }); + + // Add remaining text + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.substring(lastIndex))); + } + + textNode.parentNode?.replaceChild(fragment, textNode); } + }); + + // Store match elements and update count + matchElementsRef.current = matchElements; + setTotalMatches(matchElements.length); + setCurrentMatchIndex(matchElements.length > 0 ? 0 : -1); + + // Highlight first match with different color and scroll to it + if (matchElements.length > 0) { + matchElements[0].style.backgroundColor = theme.colors.accent; + matchElements[0].style.color = '#fff'; + matchElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); } - // Update match count - setTotalMatches(allRanges.length); - - // Create highlights - if (allRanges.length > 0) { - const targetIndex = Math.max(0, Math.min(currentMatchIndex, allRanges.length - 1)); - - // Create highlight for all matches (yellow) - const allHighlight = new (window as any).Highlight(...allRanges); - (CSS as any).highlights.set('search-results', allHighlight); - - // Create highlight for current match (accent color) - const currentHighlight = new (window as any).Highlight(allRanges[targetIndex]); - (CSS as any).highlights.set('search-current', currentHighlight); - - // Scroll to current match - const currentRange = allRanges[targetIndex]; - const rect = currentRange.getBoundingClientRect(); - const scrollParent = contentRef.current; - - if (scrollParent && rect) { - // 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'); - (CSS as any).highlights.delete('search-current'); - } - - // Cleanup function + // Cleanup function to remove highlights return () => { - (CSS as any).highlights.delete('search-results'); - (CSS as any).highlights.delete('search-current'); + container.querySelectorAll('mark.search-match').forEach((mark) => { + const parent = mark.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(mark.textContent || ''), mark); + parent.normalize(); + } + }); + matchElementsRef.current = []; }; - } else { - // Fallback: count matches and scroll to location (no highlighting) - const matches = file?.content?.match(searchRegex); - const count = matches ? matches.length : 0; - setTotalMatches(count); + }, [searchQuery, file?.content, isMarkdown, isImage, theme.colors.accent]); - if (count > 0) { + // Search matches in markdown preview mode - use CSS Custom Highlight API + useEffect(() => { + if (!isMarkdown || markdownEditMode || !searchQuery.trim() || !markdownContainerRef.current) { + if (isMarkdown && !markdownEditMode) { + setTotalMatches(0); + setCurrentMatchIndex(0); + matchElementsRef.current = []; + // Clear any existing highlights + if ('highlights' in CSS) { + (CSS as any).highlights.delete('search-results'); + (CSS as any).highlights.delete('search-current'); + } + } + return; + } + + const container = markdownContainerRef.current; + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const searchRegex = new RegExp(escapedQuery, 'gi'); + + // Check if CSS Custom Highlight API is available + if ('highlights' in CSS) { + const allRanges: Range[] = []; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - let matchCount = 0; - const targetIndex = Math.max(0, Math.min(currentMatchIndex, count - 1)); + // Find all text nodes and create ranges for matches let textNode; while ((textNode = walker.nextNode())) { const text = textNode.textContent || ''; - const nodeMatches = text.match(searchRegex); - if (nodeMatches) { - for (const _ of nodeMatches) { - if (matchCount === targetIndex) { - const parentElement = (textNode as Text).parentElement; - if (parentElement) { - parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + let match; + const localRegex = new RegExp(escapedQuery, 'gi'); + while ((match = localRegex.exec(text)) !== null) { + const range = document.createRange(); + range.setStart(textNode, match.index); + range.setEnd(textNode, match.index + match[0].length); + allRanges.push(range); + } + } + + // Update match count + setTotalMatches(allRanges.length); + + // Create highlights + if (allRanges.length > 0) { + const targetIndex = Math.max(0, Math.min(currentMatchIndex, allRanges.length - 1)); + + // Create highlight for all matches (yellow) + const allHighlight = new (window as any).Highlight(...allRanges); + (CSS as any).highlights.set('search-results', allHighlight); + + // Create highlight for current match (accent color) + const currentHighlight = new (window as any).Highlight(allRanges[targetIndex]); + (CSS as any).highlights.set('search-current', currentHighlight); + + // Scroll to current match + const currentRange = allRanges[targetIndex]; + const rect = currentRange.getBoundingClientRect(); + const scrollParent = contentRef.current; + + if (scrollParent && rect) { + // 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'); + (CSS as any).highlights.delete('search-current'); + } + + // Cleanup function + return () => { + (CSS as any).highlights.delete('search-results'); + (CSS as any).highlights.delete('search-current'); + }; + } else { + // Fallback: count matches and scroll to location (no highlighting) + const matches = file?.content?.match(searchRegex); + const count = matches ? matches.length : 0; + setTotalMatches(count); + + if (count > 0) { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + let matchCount = 0; + const targetIndex = Math.max(0, Math.min(currentMatchIndex, count - 1)); + + let textNode; + while ((textNode = walker.nextNode())) { + const text = textNode.textContent || ''; + const nodeMatches = text.match(searchRegex); + if (nodeMatches) { + for (const _ of nodeMatches) { + if (matchCount === targetIndex) { + const parentElement = (textNode as Text).parentElement; + if (parentElement) { + parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + return; } - return; + matchCount++; } - matchCount++; } } } } - } - matchElementsRef.current = []; - }, [ - searchQuery, - file?.content, - isMarkdown, - markdownEditMode, - currentMatchIndex, - theme.colors.accent, - ]); + matchElementsRef.current = []; + }, [ + searchQuery, + file?.content, + isMarkdown, + markdownEditMode, + currentMatchIndex, + theme.colors.accent, + ]); - const copyPathToClipboard = () => { - if (!file) return; - navigator.clipboard.writeText(file.path); - setCopyNotificationMessage('File Path Copied to Clipboard'); - setShowCopyNotification(true); - setTimeout(() => setShowCopyNotification(false), 2000); - }; - - const copyContentToClipboard = async () => { - if (!file) return; - if (isImage) { - // For images, copy the image to clipboard - try { - const response = await fetch(file.content); - const blob = await response.blob(); - await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); - setCopyNotificationMessage('Image Copied to Clipboard'); - } catch { - // Fallback: copy the data URL if image copy fails - navigator.clipboard.writeText(file.content); - setCopyNotificationMessage('Image URL Copied to Clipboard'); - } - } else { - // For text files, copy the content - navigator.clipboard.writeText(file.content); - setCopyNotificationMessage('Content Copied to Clipboard'); - } - setShowCopyNotification(true); - setTimeout(() => setShowCopyNotification(false), 2000); - }; - - // Navigate to next search match - const goToNextMatch = () => { - if (totalMatches === 0) return; - - // Move to next match (wrap around) - const nextIndex = (currentMatchIndex + 1) % totalMatches; - setCurrentMatchIndex(nextIndex); - - // For code files, handle DOM-based highlighting - const matches = matchElementsRef.current; - if (matches.length > 0) { - // Reset previous highlight - if (matches[currentMatchIndex]) { - matches[currentMatchIndex].style.backgroundColor = '#ffd700'; - matches[currentMatchIndex].style.color = '#000'; - } - // Highlight new current match and scroll to it - if (matches[nextIndex]) { - matches[nextIndex].style.backgroundColor = theme.colors.accent; - matches[nextIndex].style.color = '#fff'; - matches[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - // For markdown edit mode, the effect will handle selecting text - }; - - // Navigate to previous search match - const goToPrevMatch = () => { - if (totalMatches === 0) return; - - // Move to previous match (wrap around) - const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches; - setCurrentMatchIndex(prevIndex); - - // For code files, handle DOM-based highlighting - const matches = matchElementsRef.current; - if (matches.length > 0) { - // Reset previous highlight - if (matches[currentMatchIndex]) { - matches[currentMatchIndex].style.backgroundColor = '#ffd700'; - matches[currentMatchIndex].style.color = '#000'; - } - // Highlight new current match and scroll to it - if (matches[prevIndex]) { - matches[prevIndex].style.backgroundColor = theme.colors.accent; - matches[prevIndex].style.color = '#fff'; - matches[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - // For markdown edit mode, the effect will handle selecting text - }; - - // Format shortcut keys for display - const formatShortcut = (shortcutId: string): string => { - const shortcut = shortcuts[shortcutId]; - if (!shortcut) return ''; - return formatShortcutKeys(shortcut.keys); - }; - - // Track previous search query and match index for edit mode navigation - const prevSearchQueryRef = useRef(''); - const prevMatchIndexRef = useRef(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; - } - - const content = editContent; - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); - - // Find all matches and their positions - const matches: { start: number; end: number }[] = []; - let matchResult; - while ((matchResult = regex.exec(content)) !== null) { - matches.push({ start: matchResult.index, end: matchResult.index + matchResult[0].length }); - } - - setTotalMatches(matches.length); - if (matches.length === 0) { - setCurrentMatchIndex(0); - return; - } - - // Clamp current match index - const validIndex = Math.min(currentMatchIndex, matches.length - 1); - if (validIndex !== currentMatchIndex) { - setCurrentMatchIndex(validIndex); - return; - } - - // 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; - - // 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]); - - // Helper to check if a shortcut matches - const isShortcut = (e: React.KeyboardEvent, shortcutId: string) => { - const shortcut = shortcuts[shortcutId]; - if (!shortcut) return false; - - const hasModifier = (key: string) => { - if (key === 'Meta') return e.metaKey; - if (key === 'Ctrl') return e.ctrlKey; - if (key === 'Alt') return e.altKey; - if (key === 'Shift') return e.shiftKey; - return false; + const copyPathToClipboard = () => { + if (!file) return; + navigator.clipboard.writeText(file.path); + setCopyNotificationMessage('File Path Copied to Clipboard'); + setShowCopyNotification(true); + setTimeout(() => setShowCopyNotification(false), 2000); }; - const modifiers = shortcut.keys.filter((k: string) => - ['Meta', 'Ctrl', 'Alt', 'Shift'].includes(k) - ); - const mainKey = shortcut.keys.find( - (k: string) => !['Meta', 'Ctrl', 'Alt', 'Shift'].includes(k) - ); + const copyContentToClipboard = async () => { + if (!file) return; + if (isImage) { + // For images, copy the image to clipboard + try { + const response = await fetch(file.content); + const blob = await response.blob(); + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + setCopyNotificationMessage('Image Copied to Clipboard'); + } catch { + // Fallback: copy the data URL if image copy fails + navigator.clipboard.writeText(file.content); + setCopyNotificationMessage('Image URL Copied to Clipboard'); + } + } else { + // For text files, copy the content + navigator.clipboard.writeText(file.content); + setCopyNotificationMessage('Content Copied to Clipboard'); + } + setShowCopyNotification(true); + setTimeout(() => setShowCopyNotification(false), 2000); + }; - const modifiersMatch = modifiers.every((m: string) => hasModifier(m)); - const keyMatches = mainKey?.toLowerCase() === e.key.toLowerCase(); + // Navigate to next search match + const goToNextMatch = () => { + if (totalMatches === 0) return; - return modifiersMatch && keyMatches; - }; + // Move to next match (wrap around) + const nextIndex = (currentMatchIndex + 1) % totalMatches; + setCurrentMatchIndex(nextIndex); - // Handle keyboard events - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle Escape key - dismiss overlays in priority order - // In tab mode, layer system isn't registered, so we handle Escape directly here - if (e.key === 'Escape') { - if (showTocOverlay) { - e.preventDefault(); - e.stopPropagation(); - setShowTocOverlay(false); - containerRef.current?.focus(); + // For code files, handle DOM-based highlighting + const matches = matchElementsRef.current; + if (matches.length > 0) { + // Reset previous highlight + if (matches[currentMatchIndex]) { + matches[currentMatchIndex].style.backgroundColor = '#ffd700'; + matches[currentMatchIndex].style.color = '#000'; + } + // Highlight new current match and scroll to it + if (matches[nextIndex]) { + matches[nextIndex].style.backgroundColor = theme.colors.accent; + matches[nextIndex].style.color = '#fff'; + matches[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + // For markdown edit mode, the effect will handle selecting text + }; + + // Navigate to previous search match + const goToPrevMatch = () => { + if (totalMatches === 0) return; + + // Move to previous match (wrap around) + const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches; + setCurrentMatchIndex(prevIndex); + + // For code files, handle DOM-based highlighting + const matches = matchElementsRef.current; + if (matches.length > 0) { + // Reset previous highlight + if (matches[currentMatchIndex]) { + matches[currentMatchIndex].style.backgroundColor = '#ffd700'; + matches[currentMatchIndex].style.color = '#000'; + } + // Highlight new current match and scroll to it + if (matches[prevIndex]) { + matches[prevIndex].style.backgroundColor = theme.colors.accent; + matches[prevIndex].style.color = '#fff'; + matches[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + // For markdown edit mode, the effect will handle selecting text + }; + + // Format shortcut keys for display + const formatShortcut = (shortcutId: string): string => { + const shortcut = shortcuts[shortcutId]; + if (!shortcut) return ''; + return formatShortcutKeys(shortcut.keys); + }; + + // Track previous search query and match index for edit mode navigation + const prevSearchQueryRef = useRef(''); + const prevMatchIndexRef = useRef(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; } - if (searchOpen) { - e.preventDefault(); - e.stopPropagation(); - setSearchOpen(false); - setSearchQuery(''); - containerRef.current?.focus(); + + const content = editContent; + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedQuery, 'gi'); + + // Find all matches and their positions + const matches: { start: number; end: number }[] = []; + let matchResult; + while ((matchResult = regex.exec(content)) !== null) { + matches.push({ start: matchResult.index, end: matchResult.index + matchResult[0].length }); + } + + setTotalMatches(matches.length); + if (matches.length === 0) { + setCurrentMatchIndex(0); return; } - // If not in tab mode and nothing is open, let the layer system handle it - // (for overlay mode close behavior) - return; - } - if (e.key === 'f' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - e.stopPropagation(); - setSearchOpen(true); - setTimeout(() => searchInputRef.current?.focus(), 0); - } else if (e.key === 's' && (e.metaKey || e.ctrlKey) && isEditableText && markdownEditMode) { - // Cmd+S to save in edit mode - e.preventDefault(); - e.stopPropagation(); - handleSave(); - } else if (isShortcut(e, 'copyFilePath')) { - e.preventDefault(); - e.stopPropagation(); - copyPathToClipboard(); - onShortcutUsed?.('copyFilePath'); - } else if (isEditableText && isShortcut(e, 'toggleMarkdownMode')) { - e.preventDefault(); - e.stopPropagation(); - setMarkdownEditMode(!markdownEditMode); - } else if (e.key === 'ArrowUp') { - // In edit mode, let the textarea handle arrow keys for cursor movement - // Only intercept when NOT in edit mode (preview/code view) - if (isEditableText && markdownEditMode) return; - - e.preventDefault(); - const container = contentRef.current; - if (!container) return; - - if (e.metaKey || e.ctrlKey) { - // Cmd/Ctrl + Up: Jump to top - container.scrollTop = 0; - } else if (e.altKey) { - // Alt + Up: Page up - container.scrollTop -= container.clientHeight; - } else { - // Arrow Up: Scroll up - container.scrollTop -= 40; + // Clamp current match index + const validIndex = Math.min(currentMatchIndex, matches.length - 1); + if (validIndex !== currentMatchIndex) { + setCurrentMatchIndex(validIndex); + return; } - } else if (e.key === 'ArrowDown') { - // In edit mode, let the textarea handle arrow keys for cursor movement - // Only intercept when NOT in edit mode (preview/code view) - if (isEditableText && markdownEditMode) return; - e.preventDefault(); - const container = contentRef.current; - if (!container) return; + // 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; - if (e.metaKey || e.ctrlKey) { - // Cmd/Ctrl + Down: Jump to bottom - container.scrollTop = container.scrollHeight; - } else if (e.altKey) { - // Alt + Down: Page down - container.scrollTop += container.clientHeight; - } else { - // Arrow Down: Scroll down - container.scrollTop += 40; + // 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); + } } - } else if (e.key === 'ArrowLeft' && (e.metaKey || e.ctrlKey)) { - // Cmd+Left: Navigate back in history (disabled in edit mode) - if (isEditableText && markdownEditMode) return; - e.preventDefault(); - e.stopPropagation(); - if (canGoBack && onNavigateBack) { - onNavigateBack(); - onShortcutUsed?.('filePreviewBack'); - } - } else if (e.key === 'ArrowRight' && (e.metaKey || e.ctrlKey)) { - // Cmd+Right: Navigate forward in history (disabled in edit mode) - if (isEditableText && markdownEditMode) return; - e.preventDefault(); - e.stopPropagation(); - if (canGoForward && onNavigateForward) { - onNavigateForward(); - onShortcutUsed?.('filePreviewForward'); - } - } else if ( - e.key === 'g' && - (e.metaKey || e.ctrlKey) && - e.shiftKey && - isMarkdown && - onOpenInGraph - ) { - // Cmd+Shift+G: Open Document Graph focused on this file (markdown files only) - // Must come before fuzzyFileSearch check since isShortcut doesn't check for extra modifiers - e.preventDefault(); - e.stopPropagation(); - onOpenInGraph(); - } else if (isShortcut(e, 'fuzzyFileSearch') && onOpenFuzzySearch) { - // Cmd+G: Open fuzzy file search (only in preview mode, not edit mode) - if (isEditableText && markdownEditMode) return; - e.preventDefault(); - e.stopPropagation(); - onOpenFuzzySearch(); - } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && isImage) { - // Cmd+C: Copy image to clipboard when viewing an image - e.preventDefault(); - e.stopPropagation(); - copyContentToClipboard(); - } - }; + }, [searchQuery, currentMatchIndex, isEditableText, markdownEditMode, editContent]); - // Early return if no file - must be after all hooks - if (!file) return null; + // Helper to check if a shortcut matches + const isShortcut = (e: React.KeyboardEvent, shortcutId: string) => { + const shortcut = shortcuts[shortcutId]; + if (!shortcut) return false; - return ( -
- {/* CSS for Custom Highlight API */} - - {/* Header */} -
- {/* Main header row */} -
-
- -
-
- {file.name} -
-
- {directoryPath} + {/* Header */} +
+ {/* Main header row */} +
+
+ +
+
+ {file.name} +
+
+ {directoryPath} +
-
-
- {/* Save button - shown in edit mode with changes for any editable text file */} - {isEditableText && markdownEditMode && onSave && ( +
+ {/* Save button - shown in edit mode with changes for any editable text file */} + {isEditableText && markdownEditMode && onSave && ( + + )} + {/* Show remote images toggle - only for markdown in preview mode */} + {isMarkdown && !markdownEditMode && ( + + )} + {/* Toggle between edit and preview/view mode - for any editable text file */} + {isEditableText && ( + + )} - )} - {/* Show remote images toggle - only for markdown in preview mode */} - {isMarkdown && !markdownEditMode && ( - - )} - {/* Toggle between edit and preview/view mode - for any editable text file */} - {isEditableText && ( - - )} - - {/* Publish as Gist button - only show if gh CLI is available and not in edit mode */} - {ghCliAvailable && !markdownEditMode && onPublishGist && !isImage && ( - - )} - {/* Document Graph button - show for markdown files when callback is available */} - {isMarkdown && onOpenInGraph && ( - - )} - -
-
- {/* File Stats subbar - hidden on scroll */} - {((fileStats || tokenCount !== null || taskCounts) && showStatsBar) || - canGoBack || - canGoForward ? ( -
-
- {fileStats && ( -
- Size:{' '} - - {formatFileSize(fileStats.size)} - -
- )} - {tokenCount !== null && ( -
- Tokens:{' '} - {formatTokenCount(tokenCount)} -
- )} - {fileStats && ( - <> -
- Modified:{' '} - - {formatDateTime(fileStats.modifiedAt)} - -
-
- Created:{' '} - - {formatDateTime(fileStats.createdAt)} - -
- - )} - {taskCounts && ( -
- Tasks:{' '} - {taskCounts.closed} - - {' '} - of {taskCounts.open + taskCounts.closed} - -
- )} -
- {/* Navigation buttons - show when either direction is available, disabled in edit mode */} - {(canGoBack || canGoForward) && !markdownEditMode && ( -
- {/* Back button with popup */} -
{ - if (backPopupTimeoutRef.current) { - clearTimeout(backPopupTimeoutRef.current); - backPopupTimeoutRef.current = null; - } - if (canGoBack) setShowBackPopup(true); - }} - onMouseLeave={() => { - backPopupTimeoutRef.current = setTimeout(() => { - setShowBackPopup(false); - }, 150); - }} + {/* Publish as Gist button - only show if gh CLI is available and not in edit mode */} + {ghCliAvailable && !markdownEditMode && onPublishGist && !isImage && ( + + )} + {/* Document Graph button - show for markdown files when callback is available */} + {isMarkdown && onOpenInGraph && ( + + )} + +
+
+ {/* File Stats subbar - hidden on scroll */} + {((fileStats || tokenCount !== null || taskCounts) && showStatsBar) || + canGoBack || + canGoForward ? ( +
+
+ {fileStats && ( +
+ Size:{' '} + + {formatFileSize(fileStats.size)} + +
+ )} + {tokenCount !== null && ( +
+ Tokens:{' '} + + {formatTokenCount(tokenCount)} + +
+ )} + {fileStats && ( + <> +
+ Modified:{' '} + + {formatDateTime(fileStats.modifiedAt)} + +
+
+ Created:{' '} + + {formatDateTime(fileStats.createdAt)} + +
+ + )} + {taskCounts && ( +
+ Tasks:{' '} + {taskCounts.closed} + + {' '} + of {taskCounts.open + taskCounts.closed} + +
+ )} +
+ {/* Navigation buttons - show when either direction is available, disabled in edit mode */} + {(canGoBack || canGoForward) && !markdownEditMode && ( +
+ {/* Back button with popup */} +
{ + if (backPopupTimeoutRef.current) { + clearTimeout(backPopupTimeoutRef.current); + backPopupTimeoutRef.current = null; + } + if (canGoBack) setShowBackPopup(true); + }} + onMouseLeave={() => { + backPopupTimeoutRef.current = setTimeout(() => { + setShowBackPopup(false); + }, 150); + }} > - - - {/* Back history popup */} - {showBackPopup && backHistory && backHistory.length > 0 && ( -
- {backHistory - .slice() - .reverse() - .map((item, idx) => { - const actualIndex = backHistory.length - 1 - idx; + + + {/* Back history popup */} + {showBackPopup && backHistory && backHistory.length > 0 && ( +
+ {backHistory + .slice() + .reverse() + .map((item, idx) => { + const actualIndex = backHistory.length - 1 - idx; + return ( + + ); + })} +
+ )} +
+ {/* Forward button with popup */} +
{ + if (forwardPopupTimeoutRef.current) { + clearTimeout(forwardPopupTimeoutRef.current); + forwardPopupTimeoutRef.current = null; + } + if (canGoForward) setShowForwardPopup(true); + }} + onMouseLeave={() => { + forwardPopupTimeoutRef.current = setTimeout(() => { + setShowForwardPopup(false); + }, 150); + }} + > + + {/* Forward history popup */} + {showForwardPopup && forwardHistory && forwardHistory.length > 0 && ( +
+ {forwardHistory.map((item, idx) => { + const actualIndex = (currentHistoryIndex ?? 0) + 1 + idx; return ( ); })} -
- )} +
+ )} +
- {/* Forward button with popup */} -
{ - if (forwardPopupTimeoutRef.current) { - clearTimeout(forwardPopupTimeoutRef.current); - forwardPopupTimeoutRef.current = null; - } - if (canGoForward) setShowForwardPopup(true); - }} - onMouseLeave={() => { - forwardPopupTimeoutRef.current = setTimeout(() => { - setShowForwardPopup(false); - }, 150); - }} - > - - {/* Forward history popup */} - {showForwardPopup && forwardHistory && forwardHistory.length > 0 && ( -
- {forwardHistory.map((item, idx) => { - const actualIndex = (currentHistoryIndex ?? 0) + 1 + idx; - return ( - - ); - })} -
- )} -
-
- )} -
- ) : null} -
- - {/* Content - isolated scroll to prevent scroll chaining */} -
- {/* Floating Search */} - {searchOpen && ( -
-
- setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - setSearchOpen(false); - setSearchQuery(''); - // Refocus container so keyboard navigation still works - containerRef.current?.focus(); - } else if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - goToNextMatch(); - } else if (e.key === 'Enter' && e.shiftKey) { - e.preventDefault(); - goToPrevMatch(); - } - }} - placeholder="Search in file... (Enter: next, Shift+Enter: prev)" - className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm" - style={{ - borderColor: theme.colors.accent, - color: theme.colors.textMain, - backgroundColor: theme.colors.bgSidebar, - }} - autoFocus - /> - {searchQuery.trim() && ( - <> - - {totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : 'No matches'} - - - - )}
-
- )} - {isImage ? ( -
- {file.name} -
- ) : isBinary ? ( -
- -
-

- Binary File -

-

- This file cannot be displayed as text. -

- + ) : null} +
+ + {/* Content - isolated scroll to prevent scroll chaining */} +
+ {/* Floating Search */} + {searchOpen && ( +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setSearchOpen(false); + setSearchQuery(''); + // Refocus container so keyboard navigation still works + containerRef.current?.focus(); + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + goToNextMatch(); + } else if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + goToPrevMatch(); + } + }} + placeholder="Search in file... (Enter: next, Shift+Enter: prev)" + className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm" + style={{ + borderColor: theme.colors.accent, + color: theme.colors.textMain, + backgroundColor: theme.colors.bgSidebar, + }} + autoFocus + /> + {searchQuery.trim() && ( + <> + + {totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : 'No matches'} + + + + + )} +
-
- ) : isEditableText && markdownEditMode ? ( - // Edit mode - show editable textarea for any text file -