|
|
|
|
@@ -24,20 +24,27 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React
|
|
|
|
|
return <span>{text.split('').map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <Fragment key={i}>{ch}</Fragment>)}</span>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet }: {
|
|
|
|
|
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet, activePath, activeText }: {
|
|
|
|
|
initialQuery?: string
|
|
|
|
|
onOpen: OpenFile
|
|
|
|
|
onOpenAt: (path: string, line: number) => void
|
|
|
|
|
onClose: () => void
|
|
|
|
|
changeSet: Set<string>
|
|
|
|
|
activePath?: string | null
|
|
|
|
|
activeText?: string
|
|
|
|
|
}): React.ReactElement {
|
|
|
|
|
const PROJECT = useProject()
|
|
|
|
|
const bridge = window.helder
|
|
|
|
|
const [q, setQ] = useState(() => initialQuery ?? '')
|
|
|
|
|
const [sel, setSel] = useState(0)
|
|
|
|
|
const [fileSel, setFileSel] = useState(0)
|
|
|
|
|
const [col, setCol] = useState<'content' | 'files'>('content') // active result column (⌘← / ⌘→)
|
|
|
|
|
const [inFileSel, setInFileSel] = useState(0)
|
|
|
|
|
const hasInFile = !!activePath
|
|
|
|
|
type Col = 'infile' | 'content' | 'files'
|
|
|
|
|
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
|
|
|
|
|
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
const inFileRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const leftRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const rightRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
|
|
|
|
|
@@ -75,6 +82,19 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|
|
|
|
return
|
|
|
|
|
}, [q])
|
|
|
|
|
|
|
|
|
|
// in-file matches (leftmost): substring grep within the currently open file's buffer
|
|
|
|
|
const inFile = useMemo(() => {
|
|
|
|
|
const term = q.trim()
|
|
|
|
|
if (!hasInFile || term.length < 1 || !activeText) return [] as ContentHit[]
|
|
|
|
|
const low = term.toLowerCase()
|
|
|
|
|
const hits: ContentHit[] = []
|
|
|
|
|
activeText.split('\n').forEach((ln, i) => {
|
|
|
|
|
const ix = ln.toLowerCase().indexOf(low)
|
|
|
|
|
if (ix >= 0) hits.push({ no: i + 1, ln, ix })
|
|
|
|
|
})
|
|
|
|
|
return hits
|
|
|
|
|
}, [q, activeText, hasInFile])
|
|
|
|
|
|
|
|
|
|
// file-name matches (right)
|
|
|
|
|
const files = useMemo(() => {
|
|
|
|
|
const term = q.trim()
|
|
|
|
|
@@ -100,7 +120,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|
|
|
|
|
|
|
|
|
const totalHits = flat.length
|
|
|
|
|
const fileCount = Math.min(files.length, 40)
|
|
|
|
|
useEffect(() => { setSel(0); setFileSel(0) }, [q])
|
|
|
|
|
const inFileCount = Math.min(inFile.length, 200)
|
|
|
|
|
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q])
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
|
|
|
|
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
|
|
|
|
@@ -109,21 +130,34 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|
|
|
|
const el = rightRef.current && rightRef.current.querySelector('.fres.sel')
|
|
|
|
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
|
|
|
|
}, [fileSel])
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = inFileRef.current && inFileRef.current.querySelector('.sr-line.sel')
|
|
|
|
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
|
|
|
|
}, [inFileSel])
|
|
|
|
|
|
|
|
|
|
function onKey(e: React.KeyboardEvent): void {
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowLeft') { e.preventDefault(); setCol('content'); return }
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') { e.preventDefault(); setCol('files'); return }
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
const i = Math.max(0, cols.indexOf(col))
|
|
|
|
|
const ni = e.key === 'ArrowLeft' ? Math.max(0, i - 1) : Math.min(cols.length - 1, i + 1)
|
|
|
|
|
setCol(cols[ni])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
|
|
|
|
|
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
|
|
|
|
|
else setSel((s) => Math.min(s + 1, flat.length - 1))
|
|
|
|
|
} else if (e.key === 'ArrowUp') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
|
|
|
|
|
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
|
|
|
|
|
else setSel((s) => Math.max(s - 1, 0))
|
|
|
|
|
} else if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (col === 'files') {
|
|
|
|
|
if (col === 'infile') {
|
|
|
|
|
if (activePath && inFile[inFileSel]) { onOpenAt(activePath, inFile[inFileSel].no); onClose() }
|
|
|
|
|
} else if (col === 'files') {
|
|
|
|
|
if (files[fileSel]) { onOpen(files[fileSel].path); onClose() }
|
|
|
|
|
else if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() }
|
|
|
|
|
} else {
|
|
|
|
|
@@ -147,12 +181,32 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
|
|
|
|
|
<div className="pi">
|
|
|
|
|
{Icon.search({ style: { color: 'var(--fg-3)' } })}
|
|
|
|
|
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
|
|
|
|
placeholder="Search content and file names…" spellCheck={false} />
|
|
|
|
|
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
|
|
|
|
placeholder="Search this file, the project, and file names…" spellCheck={false} />
|
|
|
|
|
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="search-cols">
|
|
|
|
|
{hasInFile && (
|
|
|
|
|
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
|
|
|
|
|
<div className="sc-head">
|
|
|
|
|
<FileIcon path={activePath as string} />
|
|
|
|
|
<span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span>
|
|
|
|
|
{inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>}
|
|
|
|
|
<kbd className="col-kbd">⌘←</kbd>
|
|
|
|
|
</div>
|
|
|
|
|
{term.length < 1 && <div className="pempty sm">Type to search this file</div>}
|
|
|
|
|
{term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file</div>}
|
|
|
|
|
{inFile.slice(0, 200).map((h, i) => (
|
|
|
|
|
<div key={h.no} className={'sr-line' + (i === inFileSel && col === 'infile' ? ' sel' : '')}
|
|
|
|
|
onMouseEnter={() => { setCol('infile'); setInFileSel(i) }}
|
|
|
|
|
onClick={() => { if (activePath) { onOpenAt(activePath, h.no); onClose() } }}>
|
|
|
|
|
<span className="no">{h.no}</span>
|
|
|
|
|
{renderLine(h.ln, h.ix, term.length)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
|
|
|
|
|
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} <kbd className="col-kbd">⌘←</kbd></div>
|
|
|
|
|
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} {!hasInFile && <kbd className="col-kbd">⌘←</kbd>}</div>
|
|
|
|
|
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
|
|
|
|
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
|
|
|
|
{content.map((g) => (
|
|
|
|
|
@@ -272,8 +326,8 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
|
|
|
|
|
{ keys: ['⌘', '↑'], label: 'Navigate a list up' },
|
|
|
|
|
{ keys: ['⌘', '↓'], label: 'Navigate a list down' },
|
|
|
|
|
{ keys: ['↵'], label: 'Open the selected list item' },
|
|
|
|
|
{ keys: ['⌘', '←'], label: 'Search: focus the content results' },
|
|
|
|
|
{ keys: ['⌘', '→'], label: 'Search: focus the file-name results' },
|
|
|
|
|
{ keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' },
|
|
|
|
|
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
|
|
|
|
|
{ keys: ['⌘', '→'], label: 'Pass the selected text to the agent' },
|
|
|
|
|
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
|
|
|
|
|
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
|
|
|
|
|