diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2f394fc..eba7a57 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -602,7 +602,7 @@ export function App(): React.ReactElement { toast('Passed to agent', passPopup.ref) }} onCancel={() => setPassPopup(null)} />} - {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />} + {overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} />} {overlay === 'history' && setOverlay(null)} changeSet={changeSet} />} {overlay === 'help' && setOverlay(null)} />} {confirm && setConfirm(null)} />} diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 448b129..77ffe37 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -24,20 +24,27 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React return {text.split('').map((ch, i) => set.has(i) ? {ch} : {ch})} } -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 + 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('content') // active result column (⌘← / ⌘→ cycle) const inputRef = useRef(null) + const inFileRef = useRef(null) const leftRef = useRef(null) const rightRef = useRef(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
{Icon.search({ style: { color: 'var(--fg-3)' } })} setQ(e.target.value)} onKeyDown={onKey} - placeholder="Search content and file names…" spellCheck={false} /> - {totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'} + placeholder="Search this file, the project, and file names…" spellCheck={false} /> + {hasInFile && <>{inFile.length} here · }{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}
+ {hasInFile && ( +
+
+ + {(activePath as string).split('/').pop()} + {inFile.length > 0 && {inFile.length}} + ⌘← +
+ {term.length < 1 &&
Type to search this file
} + {term.length >= 1 && inFile.length === 0 &&
No matches in this file
} + {inFile.slice(0, 200).map((h, i) => ( +
{ setCol('infile'); setInFileSel(i) }} + onClick={() => { if (activePath) { onOpenAt(activePath, h.no); onClose() } }}> + {h.no} + {renderLine(h.ln, h.ix, term.length)} +
+ ))} +
+ )}
-
Content {totalHits > 0 && {totalHits}} ⌘←
+
Project {totalHits > 0 && {totalHits}} {!hasInFile && ⌘←}
{term.length < 2 &&
Type at least 2 characters
} {term.length >= 2 && content.length === 0 &&
No content matches
} {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' }, diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index c0c4aec..8481d27 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -317,12 +317,16 @@ body { .pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; } /* 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; } +.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-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-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; } .srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; } @@ -495,6 +499,7 @@ body { /* 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-left.active .sc-head, .sc-right.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, .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, .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); } diff --git a/test/app.test.tsx b/test/app.test.tsx index 3d2a09e..d387b47 100644 --- a/test/app.test.tsx +++ b/test/app.test.tsx @@ -101,7 +101,7 @@ describe('App (mock data, jsdom)', () => { if (!m) throw new Error('modal not open') 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' } }) await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0)) })