Optimize search highlighting: change from background colors to text color changes

- Replace background highlighting with text color changes for better visibility
- Current match highlighted in orange, other matches in blue
- Add font-semibold for better distinction of search results
- Improve readability by avoiding color overlay on text
This commit is contained in:
ZacharyZcR
2025-09-16 18:03:31 +08:00
parent f4f5e47e48
commit 1d79fd721e
2 changed files with 348 additions and 32 deletions

View File

@@ -9,9 +9,16 @@ import {
Code, Code,
AlertCircle, AlertCircle,
Download, Download,
Save Save,
RotateCcw,
Search,
X,
ChevronUp,
ChevronDown,
Replace
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface FileItem { interface FileItem {
name: string; name: string;
@@ -27,6 +34,7 @@ interface FileItem {
interface FileViewerProps { interface FileViewerProps {
file: FileItem; file: FileItem;
content?: string; content?: string;
savedContent?: string;
isLoading?: boolean; isLoading?: boolean;
isEditable?: boolean; isEditable?: boolean;
onContentChange?: (content: string) => void; onContentChange?: (content: string) => void;
@@ -70,6 +78,7 @@ function formatFileSize(bytes?: number): string {
export function FileViewer({ export function FileViewer({
file, file,
content = '', content = '',
savedContent = '',
isLoading = false, isLoading = false,
isEditable = false, isEditable = false,
onContentChange, onContentChange,
@@ -77,9 +86,16 @@ export function FileViewer({
onDownload onDownload
}: FileViewerProps) { }: FileViewerProps) {
const [editedContent, setEditedContent] = useState(content); const [editedContent, setEditedContent] = useState(content);
const [originalContent, setOriginalContent] = useState(savedContent || content);
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false); const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
const [forceShowAsText, setForceShowAsText] = useState(false); const [forceShowAsText, setForceShowAsText] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [searchText, setSearchText] = useState('');
const [replaceText, setReplaceText] = useState('');
const [showReplacePanel, setShowReplacePanel] = useState(false);
const [searchMatches, setSearchMatches] = useState<{ start: number; end: number }[]>([]);
const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
const fileTypeInfo = getFileType(file.name); const fileTypeInfo = getFileType(file.name);
@@ -100,7 +116,11 @@ export function FileViewer({
// 同步外部内容更改 // 同步外部内容更改
useEffect(() => { useEffect(() => {
setEditedContent(content); setEditedContent(content);
setHasChanges(false); // 只有在savedContent更新时才更新originalContent
if (savedContent) {
setOriginalContent(savedContent);
}
setHasChanges(content !== (savedContent || content));
// 如果是未知文件类型且文件较大,显示警告 // 如果是未知文件类型且文件较大,显示警告
if (fileTypeInfo.type === 'unknown' && isLargeFile && !forceShowAsText) { if (fileTypeInfo.type === 'unknown' && isLargeFile && !forceShowAsText) {
@@ -108,19 +128,137 @@ export function FileViewer({
} else { } else {
setShowLargeFileWarning(false); setShowLargeFileWarning(false);
} }
}, [content, fileTypeInfo.type, isLargeFile, forceShowAsText]); }, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
// 处理内容更改 // 处理内容更改
const handleContentChange = (newContent: string) => { const handleContentChange = (newContent: string) => {
setEditedContent(newContent); setEditedContent(newContent);
setHasChanges(newContent !== content); setHasChanges(newContent !== originalContent);
onContentChange?.(newContent); onContentChange?.(newContent);
}; };
// 保存文件 // 保存文件
const handleSave = () => { const handleSave = () => {
onSave?.(editedContent); onSave?.(editedContent);
// 注意不在这里更新originalContent因为它会通过savedContent prop更新
};
// 复原文件
const handleRevert = () => {
setEditedContent(originalContent);
setHasChanges(false); setHasChanges(false);
onContentChange?.(originalContent);
};
// 搜索匹配功能
const findMatches = (text: string) => {
if (!text) {
setSearchMatches([]);
setCurrentMatchIndex(-1);
return;
}
const matches: { start: number; end: number }[] = [];
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
let match;
while ((match = regex.exec(editedContent)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length
});
// 避免无限循环
if (match.index === regex.lastIndex) regex.lastIndex++;
}
setSearchMatches(matches);
setCurrentMatchIndex(matches.length > 0 ? 0 : -1);
};
// 搜索导航
const goToNextMatch = () => {
if (searchMatches.length === 0) return;
setCurrentMatchIndex((prev) => (prev + 1) % searchMatches.length);
};
const goToPrevMatch = () => {
if (searchMatches.length === 0) return;
setCurrentMatchIndex((prev) => (prev - 1 + searchMatches.length) % searchMatches.length);
};
// 替换功能
const handleFindReplace = (findText: string, replaceWithText: string, replaceAll: boolean = false) => {
if (!findText) return;
let newContent = editedContent;
if (replaceAll) {
newContent = newContent.replace(new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replaceWithText);
} else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) {
// 替换当前匹配项
const match = searchMatches[currentMatchIndex];
newContent = editedContent.substring(0, match.start) +
replaceWithText +
editedContent.substring(match.end);
}
setEditedContent(newContent);
setHasChanges(newContent !== originalContent);
onContentChange?.(newContent);
// 重新搜索以更新匹配项
setTimeout(() => findMatches(findText), 0);
};
const handleFind = () => {
setShowSearchPanel(true);
setShowReplacePanel(false);
};
const handleReplace = () => {
setShowSearchPanel(true);
setShowReplacePanel(true);
};
// 渲染带高亮的文本
const renderHighlightedText = (text: string) => {
if (!searchText || searchMatches.length === 0) {
return text;
}
const parts: React.ReactNode[] = [];
let lastIndex = 0;
searchMatches.forEach((match, index) => {
// 添加匹配前的文本
if (match.start > lastIndex) {
parts.push(text.substring(lastIndex, match.start));
}
// 添加高亮的匹配文本
const isCurrentMatch = index === currentMatchIndex;
parts.push(
<span
key={`match-${index}`}
className={cn(
"font-semibold",
isCurrentMatch
? "text-orange-500"
: "text-blue-600"
)}
>
{text.substring(match.start, match.end)}
</span>
);
lastIndex = match.end;
});
// 添加最后的文本
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts;
}; };
// 处理用户确认打开大文件 // 处理用户确认打开大文件
@@ -167,7 +305,39 @@ export function FileViewer({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isEditable && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleFind}
className="flex items-center gap-2"
>
<Search className="w-4 h-4" />
Find
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReplace}
className="flex items-center gap-2"
>
<Replace className="w-4 h-4" />
Replace
</Button>
</>
)}
{hasChanges && ( {hasChanges && (
<>
<Button
variant="outline"
size="sm"
onClick={handleRevert}
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Revert
</Button>
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
@@ -177,6 +347,7 @@ export function FileViewer({
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
Save Save
</Button> </Button>
</>
)} )}
{onDownload && ( {onDownload && (
<Button <Button
@@ -193,8 +364,87 @@ export function FileViewer({
</div> </div>
</div> </div>
{/* 搜索和替换面板 */}
{showSearchPanel && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-3">
<div className="flex items-center gap-2 mb-2">
<Input
placeholder="Find..."
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
findMatches(e.target.value);
}}
className="w-48 h-8"
/>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={goToPrevMatch}
disabled={searchMatches.length === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNextMatch}
disabled={searchMatches.length === 0}
>
<ChevronDown className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground min-w-[3rem]">
{searchMatches.length > 0
? `${currentMatchIndex + 1}/${searchMatches.length}`
: searchText ? '0/0' : ''
}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowSearchPanel(false);
setSearchText('');
setSearchMatches([]);
setCurrentMatchIndex(-1);
}}
>
<X className="w-4 h-4" />
</Button>
</div>
{showReplacePanel && (
<div className="flex items-center gap-2 mb-2">
<Input
placeholder="Replace with..."
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
className="w-48 h-8"
/>
<Button
variant="outline"
size="sm"
onClick={() => handleFindReplace(searchText, replaceText, false)}
disabled={!searchText}
>
Replace
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleFindReplace(searchText, replaceText, true)}
disabled={!searchText}
>
Replace All
</Button>
</div>
)}
</div>
)}
{/* 文件内容 */} {/* 文件内容 */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-hidden">
{/* 大文件警告对话框 */} {/* 大文件警告对话框 */}
{showLargeFileWarning && ( {showLargeFileWarning && (
<div className="h-full flex items-center justify-center bg-background"> <div className="h-full flex items-center justify-center bg-background">
@@ -270,25 +520,41 @@ export function FileViewer({
{/* 文本和代码文件预览 */} {/* 文本和代码文件预览 */}
{shouldShowAsText && !showLargeFileWarning && ( {shouldShowAsText && !showLargeFileWarning && (
<div className="h-full"> <div className="h-full flex flex-col">
{isEditable ? ( {isEditable ? (
<div className="relative h-full">
{/* 高亮背景层 */}
{searchText && (
<div
className={cn(
"absolute inset-0 p-4 font-mono text-sm whitespace-pre-wrap overflow-auto pointer-events-none",
"text-transparent z-0",
fileTypeInfo.type === 'code' && "bg-muted"
)}
>
{renderHighlightedText(editedContent)}
</div>
)}
{/* 编辑器文本区域 */}
<textarea <textarea
value={editedContent} value={editedContent}
onChange={(e) => handleContentChange(e.target.value)} onChange={(e) => handleContentChange(e.target.value)}
className={cn( className={cn(
"w-full h-full p-4 border-none resize-none outline-none", "relative w-full h-full p-4 border-none resize-none outline-none z-10",
"font-mono text-sm bg-background text-foreground", "font-mono text-sm overflow-auto",
fileTypeInfo.type === 'code' && "bg-muted text-foreground" searchText ? "bg-transparent text-foreground" : "bg-background text-foreground",
fileTypeInfo.type === 'code' && !searchText && "bg-muted text-foreground"
)} )}
placeholder="Start typing..." placeholder="Start typing..."
spellCheck={false} spellCheck={false}
/> />
</div>
) : ( ) : (
<div className={cn( <div className={cn(
"h-full p-4 font-mono text-sm whitespace-pre-wrap", "h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto",
fileTypeInfo.type === 'code' ? "bg-muted text-foreground" : "bg-background text-foreground" fileTypeInfo.type === 'code' ? "bg-muted text-foreground" : "bg-background text-foreground"
)}> )}>
{content || 'File is empty'} {content ? renderHighlightedText(content) : 'File is empty'}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { DraggableWindow } from './DraggableWindow'; import { DraggableWindow } from './DraggableWindow';
import { FileViewer } from './FileViewer'; import { FileViewer } from './FileViewer';
import { useWindowManager } from './WindowManager'; import { useWindowManager } from './WindowManager';
@@ -52,6 +52,8 @@ export function FileWindow({
const [content, setContent] = useState<string>(''); const [content, setContent] = useState<string>('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>('');
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find(w => w.id === windowId); const currentWindow = windows.find(w => w.id === windowId);
@@ -100,7 +102,15 @@ export function FileWindow({
await ensureSSHConnection(); await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path); const response = await readSSHFile(sshSessionId, file.path);
setContent(response.content || ''); const fileContent = response.content || '';
setContent(fileContent);
setPendingContent(fileContent); // 初始化待保存内容
// 如果文件大小未知,根据内容计算大小
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
// 根据文件类型决定是否可编辑 // 根据文件类型决定是否可编辑
const editableExtensions = [ const editableExtensions = [
@@ -140,6 +150,14 @@ export function FileWindow({
await writeSSHFile(sshSessionId, file.path, newContent); await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent); setContent(newContent);
setPendingContent(''); // 清除待保存内容
// 清除自动保存定时器
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success('File saved successfully'); toast.success('File saved successfully');
} catch (error: any) { } catch (error: any) {
console.error('Failed to save file:', error); console.error('Failed to save file:', error);
@@ -155,6 +173,37 @@ export function FileWindow({
} }
}; };
// 处理内容变更 - 设置1分钟自动保存
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
// 清除之前的定时器
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
// 设置新的1分钟自动保存定时器
autoSaveTimerRef.current = setTimeout(async () => {
try {
console.log('Auto-saving file...');
await handleSave(newContent);
toast.success('File auto-saved');
} catch (error) {
console.error('Auto-save failed:', error);
toast.error('Auto-save failed');
}
}, 60000); // 1分钟 = 60000毫秒
};
// 清理定时器
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, []);
// 下载文件 // 下载文件
const handleDownload = async () => { const handleDownload = async () => {
try { try {
@@ -235,11 +284,12 @@ export function FileWindow({
> >
<FileViewer <FileViewer
file={file} file={file}
content={content} content={pendingContent || content}
savedContent={content}
isLoading={isLoading} isLoading={isLoading}
isEditable={isEditable} isEditable={isEditable}
onContentChange={(newContent) => setContent(newContent)} onContentChange={handleContentChange}
onSave={handleSave} onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload} onDownload={handleDownload}
/> />
</DraggableWindow> </DraggableWindow>