search update
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-17 14:07:19 +02:00
parent 42defcc7cd
commit e55f4e714e
4 changed files with 75 additions and 16 deletions

View File

@@ -602,7 +602,7 @@ export function App(): React.ReactElement {
toast('Passed to agent', passPopup.ref) toast('Passed to agent', passPopup.ref)
}} }}
onCancel={() => setPassPopup(null)} />} onCancel={() => setPassPopup(null)} />}
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />} {overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />} {overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />} {overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />} {confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}

View File

@@ -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> 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 initialQuery?: string
onOpen: OpenFile onOpen: OpenFile
onOpenAt: (path: string, line: number) => void onOpenAt: (path: string, line: number) => void
onClose: () => void onClose: () => void
changeSet: Set<string> changeSet: Set<string>
activePath?: string | null
activeText?: string
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const bridge = window.helder const bridge = window.helder
const [q, setQ] = useState(() => initialQuery ?? '') const [q, setQ] = useState(() => initialQuery ?? '')
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const [fileSel, setFileSel] = 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 inputRef = useRef<HTMLInputElement>(null)
const inFileRef = useRef<HTMLDivElement>(null)
const leftRef = useRef<HTMLDivElement>(null) const leftRef = useRef<HTMLDivElement>(null)
const rightRef = useRef<HTMLDivElement>(null) const rightRef = useRef<HTMLDivElement>(null)
@@ -75,6 +82,19 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
return return
}, [q]) }, [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) // file-name matches (right)
const files = useMemo(() => { const files = useMemo(() => {
const term = q.trim() const term = q.trim()
@@ -100,7 +120,8 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
const totalHits = flat.length const totalHits = flat.length
const fileCount = Math.min(files.length, 40) 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(() => { useEffect(() => {
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel') const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
if (el) el.scrollIntoView({ block: 'nearest' }) 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') const el = rightRef.current && rightRef.current.querySelector('.fres.sel')
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' })
}, [fileSel]) }, [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 { 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 === 'ArrowLeft' || e.key === 'ArrowRight')) {
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') { e.preventDefault(); setCol('files'); return } 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') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1)) 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 setSel((s) => Math.min(s + 1, flat.length - 1))
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0)) 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 setSel((s) => Math.max(s - 1, 0))
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() 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() } if (files[fileSel]) { onOpen(files[fileSel].path); onClose() }
else if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() } else if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() }
} else { } else {
@@ -147,12 +181,32 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
<div className="pi"> <div className="pi">
{Icon.search({ style: { color: 'var(--fg-3)' } })} {Icon.search({ style: { color: 'var(--fg-3)' } })}
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} <input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
placeholder="Search content and file names…" spellCheck={false} /> placeholder="Search this file, the project, and file names…" spellCheck={false} />
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span> <span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
</div> </div>
<div className="search-cols"> <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-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 && <div className="pempty">Type at least 2 characters</div>}
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>} {term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
{content.map((g) => ( {content.map((g) => (
@@ -272,8 +326,8 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', '↑'], label: 'Navigate a list up' }, { keys: ['⌘', '↑'], label: 'Navigate a list up' },
{ keys: ['⌘', '↓'], label: 'Navigate a list down' }, { keys: ['⌘', '↓'], label: 'Navigate a list down' },
{ keys: ['↵'], label: 'Open the selected list item' }, { keys: ['↵'], label: 'Open the selected list item' },
{ keys: ['⌘', '←'], label: 'Search: focus the content results' }, { keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' },
{ keys: ['⌘', '→'], label: 'Search: focus the file-name results' }, { keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
{ keys: ['⌘', '→'], label: 'Pass the selected text to the agent' }, { keys: ['⌘', '→'], label: 'Pass the selected text to the agent' },
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, { keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' }, { keys: ['⌘', 'C'], label: 'Focus the commit message' },

View File

@@ -317,12 +317,16 @@ body {
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; } .pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
/* combined search modal (content + files) */ /* combined search modal (content + files) */
.search-modal { width:940px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; } .search-modal { width:1040px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.search-cols { display:flex; min-height:0; } .search-cols { display:flex; min-height:0; }
.sc-infile { flex:0 0 290px; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; background:rgba(0,0,0,0.18); }
.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; } .sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); } .sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; } .sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
.sc-right .sc-head { background:#1e2024; } .sc-right .sc-head { background:#1e2024; }
.sc-infile .sc-head { background:#1c1e22; text-transform:none; letter-spacing:0; }
.sc-infile .sc-head .scf-name { flex:1 1 auto; min-width:0; font-size:11.5px; color:var(--fg-1); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sc-infile .sc-head svg { flex:0 0 auto; }
.sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; } .sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; }
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; } .pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
@@ -495,6 +499,7 @@ body {
/* search: active result column + file-name selection */ /* search: active result column + file-name selection */
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; } .sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; }
.sc-left.active .sc-head, .sc-right.active .sc-head { color:var(--accent); } .sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; } .sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
.fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); } .fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }

View File

@@ -101,7 +101,7 @@ describe('App (mock data, jsdom)', () => {
if (!m) throw new Error('modal not open') if (!m) throw new Error('modal not open')
return m as HTMLElement return m as HTMLElement
}) })
const input = within(modal).getByPlaceholderText(/Search content/i) const input = within(modal).getByPlaceholderText(/Search this file/i)
fireEvent.change(input, { target: { value: 'balance' } }) fireEvent.change(input, { target: { value: 'balance' } })
await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0)) await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0))
}) })