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

This commit is contained in:
2026-09-03 08:12:09 +02:00
parent 1b212e2fb6
commit fa4d1d7db5
5 changed files with 114 additions and 32 deletions

View File

@@ -190,8 +190,11 @@ export function App(): React.ReactElement {
// Which column currently has focus — drives the active-panel tint and keyboard
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
const [activePanel, setActivePanel] = useState<'git' | 'tree' | 'editor' | 'terminal' | null>(null)
const [gitSel, setGitSel] = useState(0)
const [treeSel, setTreeSel] = useState(0)
// The row cursors are kept as identities, not indexes: both lists reorder and
// grow under the cursor (a git refresh, a folder that reveal() expands), and an
// index then silently points at a different row.
const [gitSelId, setGitSelId] = useState<string | null>(null)
const [treeSelPath, setTreeSelPath] = useState<string | null>(null)
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
// splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits).
@@ -252,6 +255,8 @@ export function App(): React.ReactElement {
if (proj.tree) walk(proj.tree)
return out
}, [proj.tree, openDirs, showHidden])
const gitSel = gitNav.findIndex((r) => r.id === gitSelId)
const treeSel = treeNav.findIndex((r) => r.path === treeSelPath)
const gitSelRow = gitNav[gitSel] ?? null
const gitSelPath = gitSelRow?.path ?? null
const treeSelItem = treeNav[treeSel] ?? null
@@ -262,21 +267,13 @@ export function App(): React.ReactElement {
const syncGitSel = (target: EventTarget): void => {
const row = (target as HTMLElement).closest?.('.git-row') as HTMLElement | null
const id = row?.dataset.rowId
if (!id) return
const i = gitNav.findIndex((r) => r.id === id)
if (i >= 0) setGitSel(i)
if (id) setGitSelId(id)
}
const syncTreeSel = (target: EventTarget): void => {
const row = (target as HTMLElement).closest?.('.tree-row') as HTMLElement | null
const path = row?.dataset.rowPath
if (path == null) return
const i = treeNav.findIndex((r) => r.path === path)
if (i >= 0) setTreeSel(i)
if (path != null) setTreeSelPath(path)
}
// Keep the row cursors in range as the lists shrink/grow.
useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length])
useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length])
// Scroll the selected row into view when navigating with the keyboard.
useEffect(() => {
if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' })
@@ -522,6 +519,17 @@ export function App(): React.ReactElement {
actions.unstage(p)
}
// A search result opens the file at its line and paints that line orange for a
// second, so the eye finds it after the scroll. `id` restarts a repeat jump.
const [lineFlash, setLineFlash] = useState<{ path: string; line: number; id: number } | null>(null)
const lineFlashTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function flashLine(path: string, line: number): void {
if (lineFlashTimer.current) clearTimeout(lineFlashTimer.current)
setLineFlash({ path, line, id: Date.now() })
lineFlashTimer.current = setTimeout(() => { lineFlashTimer.current = null; setLineFlash(null) }, 1000)
}
useEffect(() => () => { if (lineFlashTimer.current) clearTimeout(lineFlashTimer.current) }, [])
function openFile(path: string, opts: { diff?: boolean; line?: number; side?: DiffSide } = {}): void {
const changed = !!proj.diffs[path]
setFocusZone('editor')
@@ -541,6 +549,7 @@ export function App(): React.ReactElement {
return n
})
reveal(path)
setTreeSelPath(path)
if (opts.line) {
// The updated/code views render in the CodeEditor (a textarea over a <pre>),
// so scroll its container to centre the target line. Line height is 20px with
@@ -551,6 +560,7 @@ export function App(): React.ReactElement {
setCursor({ path, line: opts.line, col: 1 })
setSelection(null)
scrollEditorToLine(opts.line)
flashLine(path, opts.line)
}
}
@@ -809,10 +819,13 @@ export function App(): React.ReactElement {
// Arrow up/down move the row cursor in the focused Git/Explorer panel.
if (!meta && !inField && inPanel && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault()
const len = activePanel === 'git' ? gitNav.length : treeNav.length
if (len === 0) return
const set = activePanel === 'git' ? setGitSel : setTreeSel
set((s) => e.key === 'ArrowDown' ? Math.min(s + 1, len - 1) : Math.max(s - 1, 0))
const list: { id?: string; path: string }[] = activePanel === 'git' ? gitNav : treeNav
if (list.length === 0) return
// A cursor whose row is gone (folder collapsed, file staged away) starts over.
const cur = activePanel === 'git' ? gitSel : treeSel
const next = cur < 0 ? 0 : e.key === 'ArrowDown' ? Math.min(cur + 1, list.length - 1) : Math.max(cur - 1, 0)
if (activePanel === 'git') setGitSelId(gitNav[next].id)
else setTreeSelPath(treeNav[next].path)
return
}
// ↵ opens the selected row (git → diff, file → open, folder → toggle).
@@ -1038,6 +1051,7 @@ export function App(): React.ReactElement {
onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {

View File

@@ -71,9 +71,9 @@ export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */
/** True for files under the project root's `tests/` folder. The git list dims these. */
/** True for files under the project root's `tests/` or `cypress/` folder. The git list dims these. */
function isTestFile(path: string): boolean {
return path.startsWith('tests/')
return path.startsWith('tests/') || path.startsWith('cypress/')
}
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {

View File

@@ -1,5 +1,5 @@
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import React, { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types'
import { useProject } from './project'
@@ -9,6 +9,9 @@ import { FileIcon, Icon } from './components'
import type { OnContext } from './components'
export interface Cursor { path: string; line: number; col: number }
/** A one-second line highlight after a jump. `id` changes per jump, so the same
* line twice restarts the animation. */
export interface FlashLine { line: number; id: number }
export interface Selection { path: string; start: number; end: number; anchor: number }
export type Mode = 'original' | 'updated' | 'diff' | 'code' | 'preview'
@@ -29,11 +32,13 @@ function climbToLine(node: Node | null): HTMLElement | null {
* the number is a CSS counter on `::before`, which keeps it on the first
* visual row and leaves the folded rows unnumbered. Highlighting is then per
* line (like the diff views), so multi-line tokens don't carry over. */
function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext }: {
path: string
text: string
lang: string | null
wrap: boolean
/** Line to paint orange for a second after a jump. */
flash: FlashLine | null
onChange: (text: string) => void
onContext: OnContext
}): React.ReactElement {
@@ -48,6 +53,14 @@ function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
[text, lang, wrap],
)
const count = useMemo(() => text.split('\n').length, [text])
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
// that is arithmetic; wrapped, a line can be several rows tall, so measure it.
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
useLayoutEffect(() => {
if (!flash) { setFlashBox(null); return }
const row = wrap ? (preRef.current?.children[flash.line - 1] as HTMLElement | undefined) : undefined
setFlashBox(row ? { top: row.offsetTop, height: row.offsetHeight } : { top: 6 + (flash.line - 1) * 20, height: 20 })
}, [flash, wrap, text])
function onScroll(): void {
const s = scrollRef.current
@@ -132,6 +145,7 @@ function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
)}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-inner">
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
@@ -313,7 +327,7 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
return segs
}
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit }: {
active: string | null
mode: Mode
/** Which git row opened this tab. Only Diff and Split follow it. */
@@ -326,6 +340,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
selection: Selection | null
setCursor: (c: Cursor) => void
setSelection: (s: Selection | null) => void
/** Line to flash after a jump, when it belongs to the open file. */
flash: FlashLine | null
bufferText: string
onEdit: (text: string) => void
}): React.ReactElement {
@@ -435,7 +451,7 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} onChange={onEdit} onContext={onContext} />
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} />
) : (
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}

View File

@@ -4,6 +4,7 @@ import { useProject } from './project'
import type { RecentProject } from './project'
import { fuzzy } from './fuzzy'
import { FileIcon, Icon } from './components'
import { HL } from './highlight'
import type { OpenFile } from './components'
export interface MenuItem {
@@ -60,6 +61,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
const [sel, setSel] = useState(0)
const [fileSel, setFileSel] = useState(0)
const [inFileSel, setInFileSel] = useState(0)
const [extSel, setExtSel] = useState<string | null>(null) // null = every file type
const hasInFile = !!activePath
type Col = 'infile' | 'content' | 'files'
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
@@ -104,10 +106,14 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
}, [q])
// hide dotfile content hits unless the Hidden toggle is on
const visibleContent = useMemo(
const shownContent = useMemo(
() => (showHidden ? content : content.filter((g) => !isHiddenPath(g.path))),
[content, showHidden],
)
const visibleContent = useMemo(
() => (extSel ? shownContent.filter((g) => HL.ext(g.path) === extSel) : shownContent),
[shownContent, extSel],
)
// in-file matches (leftmost): substring grep within the currently open file's buffer
const inFile = useMemo(() => {
@@ -123,7 +129,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
}, [q, activeText, hasInFile])
// file-name matches (right)
const files = useMemo(() => {
const allFiles = useMemo(() => {
const term = q.trim()
if (!term) return []
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
@@ -138,6 +144,26 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length)
return out
}, [q, allPaths, showHidden])
const files = useMemo(
() => (extSel ? allFiles.filter((r) => HL.ext(r.path) === extSel) : allFiles),
[allFiles, extSel],
)
// file-type chips: every extension the unfiltered results carry, most matches first
const extList = useMemo(() => {
const n = new Map<string, number>()
const add = (path: string): void => {
const e = HL.ext(path)
if (e) n.set(e, (n.get(e) ?? 0) + 1)
}
shownContent.forEach((g) => add(g.path))
allFiles.forEach((r) => add(r.path))
return [...n].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12)
}, [shownContent, allFiles])
// a chosen type that the new results no longer carry falls back to "all types"
useEffect(() => {
if (extSel && !extList.some(([e]) => e === extSel)) setExtSel(null)
}, [extList, extSel])
// flat list of content hits for keyboard nav
const flat = useMemo(() => {
@@ -149,7 +175,7 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
const totalHits = flat.length
const fileCount = Math.min(files.length, 40)
const inFileCount = Math.min(inFile.length, 200)
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q])
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q, extSel])
useEffect(() => {
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
if (el) el.scrollIntoView({ block: 'nearest' })
@@ -210,6 +236,16 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
{Icon.search({ style: { color: 'var(--fg-3)' } })}
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
placeholder="Search this file, the project, and file names…" spellCheck={false} />
{extList.length > 1 && (
<div className="ext-filters">
{extList.map(([e, n]) => (
<button key={e} className={'ext-chip' + (extSel === null || extSel === e ? ' on' : '')}
title={`${n} result${n === 1 ? '' : 's'} · ${extSel === e ? 'click to show every type' : 'click to show only this type'}`}
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => setExtSel((cur) => (cur === e ? null : e))}>{e}</button>
))}
</div>
)}
<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">

View File

@@ -191,12 +191,14 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.git-row.ctx .git-act { visibility:visible; }
.git-row.active { background:var(--sel); }
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.git-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
/* Three states must stay apart: hover is background only, keyboard focus adds a
1px orange rule top and bottom, and the open file keeps the left accent bar. */
.git-row.kbd { background:var(--hover); box-shadow:inset 0 1px 0 var(--accent), inset 0 -1px 0 var(--accent); }
.git-row.kbd .git-act { visibility:visible; }
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* Files in the root `tests/` folder stay a step greyer, so real source changes read first. Git list only. */
/* Files in the root `tests/` or `cypress/` folder stay a step greyer, so real source changes read first. Git list only. */
.git-row.test .git-name { color:var(--fg-3); }
.git-row.active .git-name { color:var(--fg-0); }
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
@@ -215,7 +217,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.tree-row:hover, .tree-row.ctx { background:var(--hover); }
.tree-row.active { background:var(--sel); }
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.tree-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
.tree-row.kbd { background:var(--hover); box-shadow:inset 0 1px 0 var(--accent), inset 0 -1px 0 var(--accent); }
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
.tree-row.active .tree-label { color:var(--fg-0); }
@@ -403,6 +405,12 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.palette .pi input::placeholder, .search-modal .pi input::placeholder { color:var(--fg-3); }
.palette .pi svg, .search-modal .pi svg { flex:0 0 auto; }
.palette .pi .mode-chip, .search-modal .pi .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
/* File-type filter chips. All chips read as "on" while no type is chosen, so the
* default state says "every type" without a separate All control. */
.search-modal .pi .ext-filters { flex:0 1 auto; min-width:0; display:flex; align-items:center; gap:4px; overflow:hidden; }
.search-modal .ext-chip { flex:0 0 auto; font-family:var(--mono); font-size:10px; line-height:16px; padding:1px 7px; border:1px solid var(--border-2); border-radius:5px; background:transparent; color:var(--fg-3); cursor:pointer; opacity:.5; }
.search-modal .ext-chip:hover { opacity:1; }
.search-modal .ext-chip.on { opacity:1; color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); }
.palette .results { max-height:380px; overflow:auto; padding:6px; }
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
.pres.sel { background:var(--accent-soft); }
@@ -414,9 +422,13 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
/* combined search modal (content + files) */
.search-modal { width:min(1680px, 92vw); max-width:92vw; 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 20%; min-width:0; max-height:min(72vh, 720px); overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; background:rgba(0,0,0,0.18); }
.sc-left { flex:0 0 60%; min-width:0; max-height:min(72vh, 720px); overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
.sc-right { flex:0 0 20%; min-width:0; max-height:min(72vh, 720px); overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
/* The column that holds the selection takes 70%; the rest divide what is left,
* so two columns give 70/30 and three give 70/15/15. */
.search-cols > div { flex:1 1 0; min-width:0; max-height:min(72vh, 720px); overflow:auto; padding-bottom:8px; transition:flex .14s ease; }
.search-cols > div.active { flex:0 0 70%; }
.sc-infile { border-right:1px solid var(--border); background:rgba(0,0,0,0.18); }
.sc-left { border-right:1px solid var(--border); }
.sc-right { 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; }
@@ -517,12 +529,16 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
white-space:pre; tab-size:4; -moz-tab-size:4; letter-spacing:0;
}
.ce-pre { display:block; pointer-events:none; color:var(--fg-0); }
.ce-pre { display:block; pointer-events:none; color:var(--fg-0); position:relative; z-index:1; }
.ce-ta {
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
background:transparent; color:transparent; caret-color:var(--accent);
}
.ce-ta::selection { background:rgba(241,159,63,0.30); }
/* jump target — the line a search result opened, orange for a second.
Behind both layers, so the code itself keeps its own colours. */
.ce-flash { position:absolute; left:0; right:0; min-width:100%; z-index:0; pointer-events:none; background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); animation:ce-flash-fade 1s ease forwards; }
@keyframes ce-flash-fade { 0%{opacity:1;} 65%{opacity:1;} 100%{opacity:0;} }
/* wrapped buffer — no separate gutter column: the layer must fold at the pane
width (max-content would never break), so the number rides along as a counter