✨ global search
This commit is contained in:
@@ -439,7 +439,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null)
|
||||
const [commentDraft, setCommentDraft] = useState('')
|
||||
const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore()
|
||||
const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions, pendingScrollToLine, clearPendingScrollToLine } = useEditorStore()
|
||||
|
||||
function saveComment(): void {
|
||||
if (!pendingComment || !commentDraft.trim()) return
|
||||
@@ -625,6 +625,18 @@ export function MarkdownEditor(): JSX.Element {
|
||||
})
|
||||
}, [annotations])
|
||||
|
||||
// Scroll to line requested by project search navigation
|
||||
useEffect(() => {
|
||||
if (pendingScrollToLine === null) return
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const lineCount = view.state.doc.lines
|
||||
const lineNum = Math.max(1, Math.min(pendingScrollToLine, lineCount))
|
||||
const line = view.state.doc.line(lineNum)
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(line.from, { y: 'center' }) })
|
||||
clearPendingScrollToLine()
|
||||
}, [pendingScrollToLine])
|
||||
|
||||
// Reconfigure theme when font size or colour theme changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
|
||||
281
src/renderer/components/Search/ProjectSearch.css
Normal file
281
src/renderer/components/Search/ProjectSearch.css
Normal file
@@ -0,0 +1,281 @@
|
||||
/* ─── Project Search Modal ───────────────────────────────────────── */
|
||||
|
||||
.ps-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.ps-modal {
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: 640px;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Header inputs ──────────────────────────────────────────────── */
|
||||
|
||||
.ps-header {
|
||||
padding: 12px 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ps-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ps-input {
|
||||
flex: 1;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
padding: 5px 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.ps-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ps-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Option toggle buttons (Aa / W / .*) */
|
||||
.ps-opt {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ps-opt:hover {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ps-opt.active {
|
||||
background: var(--active-bg);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.ps-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
transition: color 0.12s;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.ps-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Replace All button */
|
||||
.ps-replace-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.ps-replace-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ps-replace-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ─── Results ────────────────────────────────────────────────────── */
|
||||
|
||||
.ps-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ps-empty {
|
||||
padding: 24px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* File group */
|
||||
.ps-file-group {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ps-file-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ps-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px 5px;
|
||||
background: var(--toolbar-bg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ps-file-header:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.ps-chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
width: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ps-file-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ps-match-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ps-file-replace-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-sans);
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.ps-file-replace-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Match rows */
|
||||
.ps-matches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ps-match-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
padding: 3px 10px 3px 20px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ps-match-row:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.ps-line-num {
|
||||
color: var(--text-muted);
|
||||
min-width: 36px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ps-line-text {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ps-line-text mark {
|
||||
background: rgba(167, 139, 95, 0.35);
|
||||
color: var(--accent-hover);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* ─── Footer ─────────────────────────────────────────────────────── */
|
||||
|
||||
.ps-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ps-stats {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.ps-error {
|
||||
color: #c0624a;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
275
src/renderer/components/Search/ProjectSearchModal.tsx
Normal file
275
src/renderer/components/Search/ProjectSearchModal.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { SearchFileResult } from '../../types/editor'
|
||||
import './ProjectSearch.css'
|
||||
|
||||
interface SearchOptions {
|
||||
caseSensitive: boolean
|
||||
wholeWord: boolean
|
||||
isRegex: boolean
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function buildMatchLine(lineText: string, matchStart: number, matchEnd: number): string {
|
||||
const before = escapeHtml(lineText.slice(0, matchStart))
|
||||
const match = escapeHtml(lineText.slice(matchStart, matchEnd))
|
||||
const after = escapeHtml(lineText.slice(matchEnd))
|
||||
return `${before}<mark>${match}</mark>${after}`
|
||||
}
|
||||
|
||||
interface FileGroupProps {
|
||||
result: SearchFileResult
|
||||
hasReplace: boolean
|
||||
onMatchClick: (filePath: string, lineNumber: number) => void
|
||||
onFileReplace: (filePath: string) => void
|
||||
}
|
||||
|
||||
function FileGroup({ result, hasReplace, onMatchClick, onFileReplace }: FileGroupProps): JSX.Element {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const matchWord = result.matches.length === 1 ? 'match' : 'matches'
|
||||
|
||||
return (
|
||||
<div className="ps-file-group">
|
||||
<div className="ps-file-header" onClick={() => setCollapsed((v) => !v)}>
|
||||
<span className="ps-chevron">{collapsed ? '▶' : '▼'}</span>
|
||||
<span className="ps-file-name" title={result.relativePath}>{result.relativePath}</span>
|
||||
<span className="ps-match-count">{result.matches.length} {matchWord}</span>
|
||||
{hasReplace && (
|
||||
<button
|
||||
className="ps-file-replace-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onFileReplace(result.filePath) }}
|
||||
title={`Replace in ${result.relativePath}`}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="ps-matches">
|
||||
{result.matches.map((match, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="ps-match-row"
|
||||
onClick={() => onMatchClick(result.filePath, match.lineNumber)}
|
||||
title={`Line ${match.lineNumber}: ${match.lineText.trim()}`}
|
||||
>
|
||||
<span className="ps-line-num">{match.lineNumber}</span>
|
||||
<span
|
||||
className="ps-line-text"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: buildMatchLine(match.lineText, match.matchStart, match.matchEnd) }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectSearchModal(): JSX.Element | null {
|
||||
const { projectSearchOpen, closeProjectSearch, setActiveFile, scrollEditorToLine, activeFilePath } = useEditorStore()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [replaceValue, setReplaceValue] = useState('')
|
||||
const [opts, setOpts] = useState<SearchOptions>({ caseSensitive: false, wholeWord: false, isRegex: false })
|
||||
const [results, setResults] = useState<SearchFileResult[]>([])
|
||||
const [regexError, setRegexError] = useState<string | null>(null)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Focus search input when modal opens
|
||||
useEffect(() => {
|
||||
if (projectSearchOpen) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 30)
|
||||
} else {
|
||||
setResults([])
|
||||
setRegexError(null)
|
||||
}
|
||||
}, [projectSearchOpen])
|
||||
|
||||
// Run search with debounce
|
||||
const runSearch = useCallback((q: string, options: SearchOptions) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (!q.trim()) {
|
||||
setResults([])
|
||||
setRegexError(null)
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Validate regex if enabled
|
||||
if (options.isRegex) {
|
||||
try {
|
||||
new RegExp(q)
|
||||
} catch (e) {
|
||||
setRegexError(e instanceof Error ? e.message : 'Invalid regex')
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
}
|
||||
setRegexError(null)
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const found = await window.api.searchFiles(q, options)
|
||||
setResults(found)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
const handleQueryChange = (value: string): void => {
|
||||
setQuery(value)
|
||||
runSearch(value, opts)
|
||||
}
|
||||
|
||||
const toggleOpt = (key: keyof SearchOptions): void => {
|
||||
const next = { ...opts, [key]: !opts[key] }
|
||||
setOpts(next)
|
||||
runSearch(query, next)
|
||||
}
|
||||
|
||||
const handleMatchClick = async (filePath: string, lineNumber: number): Promise<void> => {
|
||||
closeProjectSearch()
|
||||
if (filePath !== activeFilePath) {
|
||||
const content = await window.api.readFile(filePath)
|
||||
setActiveFile(filePath, content)
|
||||
}
|
||||
// Small delay to let the editor mount/settle before scrolling
|
||||
setTimeout(() => scrollEditorToLine(lineNumber), 50)
|
||||
}
|
||||
|
||||
const doReplace = async (filePaths: string[]): Promise<void> => {
|
||||
if (!query.trim()) return
|
||||
const modified = await window.api.replaceInFiles(query, replaceValue, opts, filePaths)
|
||||
if (modified.length > 0) {
|
||||
// Re-run search to refresh results
|
||||
const found = await window.api.searchFiles(query, opts)
|
||||
setResults(found)
|
||||
// Reload active file if it was modified
|
||||
if (activeFilePath && modified.includes(activeFilePath)) {
|
||||
const content = await window.api.readFile(activeFilePath)
|
||||
setActiveFile(activeFilePath, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleReplaceAll = (): void => {
|
||||
const allPaths = results.map((r) => r.filePath)
|
||||
doReplace(allPaths)
|
||||
}
|
||||
|
||||
const handleFileReplace = (filePath: string): void => {
|
||||
doReplace([filePath])
|
||||
}
|
||||
|
||||
// Keyboard: Escape closes
|
||||
useEffect(() => {
|
||||
if (!projectSearchOpen) return
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') closeProjectSearch()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [projectSearchOpen, closeProjectSearch])
|
||||
|
||||
if (!projectSearchOpen) return null
|
||||
|
||||
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0)
|
||||
const hasReplace = replaceValue !== '' || false // show replace buttons whenever replace field has content
|
||||
|
||||
return (
|
||||
<div className="ps-backdrop" onClick={(e) => { if (e.target === e.currentTarget) closeProjectSearch() }}>
|
||||
<div className="ps-modal">
|
||||
<div className="ps-header">
|
||||
{/* Search row */}
|
||||
<div className="ps-row">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="ps-input"
|
||||
placeholder="Search across all files…"
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
className={`ps-opt${opts.caseSensitive ? ' active' : ''}`}
|
||||
onClick={() => toggleOpt('caseSensitive')}
|
||||
title="Match case"
|
||||
>Aa</button>
|
||||
<button
|
||||
className={`ps-opt${opts.wholeWord ? ' active' : ''}`}
|
||||
onClick={() => toggleOpt('wholeWord')}
|
||||
title="Match whole word"
|
||||
>W</button>
|
||||
<button
|
||||
className={`ps-opt${opts.isRegex ? ' active' : ''}`}
|
||||
onClick={() => toggleOpt('isRegex')}
|
||||
title="Use regular expression"
|
||||
>.*</button>
|
||||
<button className="ps-close" onClick={closeProjectSearch} title="Close (Esc)">×</button>
|
||||
</div>
|
||||
{/* Replace row */}
|
||||
<div className="ps-row">
|
||||
<input
|
||||
className="ps-input"
|
||||
placeholder="Replace with…"
|
||||
value={replaceValue}
|
||||
onChange={(e) => setReplaceValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
className="ps-replace-btn"
|
||||
onClick={handleReplaceAll}
|
||||
disabled={results.length === 0 || !query.trim()}
|
||||
title="Replace all matches across all files"
|
||||
>
|
||||
Replace All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ps-results">
|
||||
{regexError ? (
|
||||
<div className="ps-empty">Invalid regex: {regexError}</div>
|
||||
) : isSearching ? (
|
||||
<div className="ps-empty">Searching…</div>
|
||||
) : query.trim() && results.length === 0 ? (
|
||||
<div className="ps-empty">No results</div>
|
||||
) : results.length > 0 ? (
|
||||
results.map((r) => (
|
||||
<FileGroup
|
||||
key={r.filePath}
|
||||
result={r}
|
||||
hasReplace={hasReplace}
|
||||
onMatchClick={handleMatchClick}
|
||||
onFileReplace={handleFileReplace}
|
||||
/>
|
||||
))
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ps-footer">
|
||||
{regexError ? (
|
||||
<span className="ps-error">Regex error</span>
|
||||
) : (
|
||||
<span className="ps-stats">
|
||||
{query.trim() && !isSearching
|
||||
? totalMatches > 0
|
||||
? `${totalMatches} match${totalMatches === 1 ? '' : 'es'} in ${results.length} file${results.length === 1 ? '' : 's'}`
|
||||
: ''
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user