global search

This commit is contained in:
2026-03-03 21:07:24 +10:00
parent 63842b8805
commit 636c0f378f
13 changed files with 733 additions and 10 deletions

View File

@@ -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

View 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);
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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>
)
}