From fa4d1d7db51ab36714880a4ad66db97a777f3051 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Thu, 3 Sep 2026 08:12:09 +0200 Subject: [PATCH] search improvements --- src/renderer/src/App.tsx | 46 +++++++++++++++++++++------------ src/renderer/src/components.tsx | 4 +-- src/renderer/src/editor.tsx | 24 ++++++++++++++--- src/renderer/src/overlays.tsx | 42 +++++++++++++++++++++++++++--- src/renderer/src/styles.css | 30 ++++++++++++++++----- 5 files changed, 114 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6684774..92272fc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) + const [treeSelPath, setTreeSelPath] = useState(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 | 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
),
       // 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} />
         
          { setAutoResize(false); setRightW((w) => {
diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx
index b13bded..916feed 100644
--- a/src/renderer/src/components.tsx
+++ b/src/renderer/src/components.tsx
@@ -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 }: {
diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx
index 2f533bd..769c98f 100644
--- a/src/renderer/src/editor.tsx
+++ b/src/renderer/src/editor.tsx
@@ -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 }: {
       )}
       
+ {flash && flashBox &&
}