feat: in-document find with live highlight (TASK-007)
7c8bb9ddf45ad58e79852607f3a437a239c13592
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 13:47
parent 56e8e447
feat: in-document find with live highlight (TASK-007) Ctrl+G opens a find bar over the editor buffer (Ctrl+F is the picker). Case-insensitive, non-overlapping substring matching, recomputed live as the query is typed. All matches are highlighted; the active match is styled more strongly and the cursor parks on it with the viewport scrolling to keep it visible. Enter / Down cycle to the next match, Shift+Tab / Up to the previous, both wrapping; Esc closes and clears. Shift+Enter/N (suggested in the task) aren't usable — terminals can't distinguish Shift+Enter and n/N collide with live query entry — so prev is Shift+Tab/Up and next is Enter/Down. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
8 files changed
README.md +1 −0
@@ -56,6 +56,7 @@ | `Ctrl+Z` / `Ctrl+Y` | undo / redo |
| `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
| `Ctrl+P` | toggle the Glamour read preview |
| `Ctrl+F` | fuzzy file picker (with live preview) |
+| `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
| `Ctrl+D` | today's daily note |
| `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
| `Ctrl+B` | new note in the inbox |
- → Find-in-document.md +11 −5
@@ -1,10 +1,10 @@
---
id: TASK-007
title: Find in document
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 20:46'
labels:
- feature
- release-1
@@ -21,7 +21,13 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 A keybind opens a find prompt; typing filters/highlights matches
-- [ ] #2 Next/prev cycle through matches, wrapping; view scrolls to the match
-- [ ] #3 Esc closes the find bar; case-insensitive matching
+- [x] #1 A keybind opens a find prompt; typing filters/highlights matches
+- [x] #2 Next/prev cycle through matches, wrapping; view scrolls to the match
+- [x] #3 Esc closes the find bar; case-insensitive matching
<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented. Editor: internal/editor/find.go (case-insensitive non-overlapping substring matches, active-match cursor parking + scroll-to-match, all-matches highlight in View; Highlight bg for matches, SelBg for the active one). App: ModeFind + find bar (textinput), opened with Ctrl+G (Ctrl+F is the picker). Enter/Down = next, Shift+Tab/Up = prev (wrapping); Esc closes and clears. Note: description suggested Shift+Enter/N for prev and n for next, but terminals can't distinguish Shift+Enter and n/N collide with live query typing, so prev is Shift+Tab/Up and next is Enter/Down. 16 new tests (12 editor, 4 app); all green, go vet + golangci-lint clean.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +72 −1
@@ -30,6 +30,7 @@ ModeEditor Mode = iota
ModePicker
ModePreview
ModeSaveAs
+ ModeFind
)
// pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -60,6 +61,7 @@ editor *editor.Editor
preview *preview.Model
picker *picker.Model
saveInput textinput.Model // one-line "save as" prompt for unnamed buffers
+ findInput textinput.Model // one-line in-document find prompt (TASK-007)
path string
pickerRoot string // directory the current picker is browsing
saveDir string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -79,12 +81,16 @@ ed.SetTheme(th)
ti := textinput.New()
ti.Prompt = "save as › "
ti.Placeholder = "name…"
+ fi := textinput.New()
+ fi.Prompt = "find › "
+ fi.Placeholder = "text…"
a := &App{
mode: ModeEditor,
cfg: cfg,
theme: th,
editor: ed,
saveInput: ti,
+ findInput: fi,
}
a.preview = preview.New(a.glamourStyle())
a.preview.SetColors(previewColors(th))
@@ -251,7 +257,12 @@ case tea.KeyCtrlX:
return a.copySelection(true)
case tea.KeyCtrlV:
return a.paste()
+ case tea.KeyCtrlG:
+ return a.openFind()
case tea.KeyEsc:
+ if a.mode == ModeFind {
+ a.editor.ClearFind()
+ }
a.mode = ModeEditor
return a, nil
}
@@ -266,6 +277,8 @@ }
var cmd tea.Cmd
a.saveInput, cmd = a.saveInput.Update(msg)
return a, cmd
+ case ModeFind:
+ return a.handleFindKey(msg)
case ModePreview:
return a, a.preview.Update(msg)
case ModePicker:
@@ -334,6 +347,51 @@ a.status = "Saved " + p
return a, nil
}
+// openFind opens the in-document find bar over the current editor buffer.
+// Ctrl+F is already the file picker, so find is Ctrl+G.
+func (a *App) openFind() (tea.Model, tea.Cmd) {
+ if a.mode != ModeEditor {
+ return a, nil
+ }
+ a.findInput.SetValue("")
+ a.findInput.Focus()
+ a.editor.ClearFind()
+ a.mode = ModeFind
+ a.status = "Find — type to search, Enter/↓ next, Shift+Tab/↑ prev, Esc to close"
+ return a, nil
+}
+
+// handleFindKey drives the find bar: Enter/Down cycle to the next match,
+// Shift+Tab/Up to the previous, and any other key edits the query live.
+func (a *App) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.Type {
+ case tea.KeyEnter, tea.KeyDown:
+ a.editor.FindNext()
+ a.status = a.findStatus()
+ return a, nil
+ case tea.KeyShiftTab, tea.KeyUp:
+ a.editor.FindPrev()
+ a.status = a.findStatus()
+ return a, nil
+ }
+ var cmd tea.Cmd
+ a.findInput, cmd = a.findInput.Update(msg)
+ a.editor.SetFindQuery(a.findInput.Value())
+ a.status = a.findStatus()
+ return a, cmd
+}
+
+// findStatus summarizes the current match count for the status bar.
+func (a *App) findStatus() string {
+ if a.findInput.Value() == "" {
+ return "Find — type to search"
+ }
+ if a.editor.FindCount() == 0 {
+ return "No matches"
+ }
+ return fmt.Sprintf("%d matches", a.editor.FindCount())
+}
+
// newFile is the Ctrl+N / Ctrl+I handler: start a new note in dir. From the
// picker with a typed query it creates dir/<query>.md; otherwise it opens a
// blank buffer whose save-as targets dir (confirming discard if the editor is
@@ -608,10 +666,23 @@ } else {
body = a.editor.View() // editor stays visible beneath the save-as prompt
}
bottom := a.statusBar()
- if a.mode == ModeSaveAs {
+ switch a.mode {
+ case ModeSaveAs:
bottom = a.saveBar()
+ case ModeFind:
+ bottom = a.findBar()
}
return a.paintCanvas(body) + bottom
+}
+
+// findBar renders the find prompt as a themed full-width bottom bar, with the
+// match count trailing.
+func (a *App) findBar() string {
+ bar := lipgloss.NewStyle().
+ Foreground(a.theme.StatusFg).
+ Background(a.theme.StatusBg).
+ Width(maxInt(a.width, 1))
+ return bar.Render(" " + a.findInput.View() + " " + a.findStatus() + " ")
}
// saveBar renders the save-as prompt as a themed full-width bottom bar.
internal/app/app_test.go +50 −0
@@ -639,3 +639,53 @@ if a.editor.Lines[0] != "hello" {
t.Errorf("Ctrl+Y should redo, got %q", a.editor.Lines[0])
}
}
+
+func TestCtrlGOpensFind(t *testing.T) {
+ a := newApp()
+ a.editor.SetContent([]byte("alpha beta alpha"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+ if a.mode != ModeFind {
+ t.Fatalf("mode = %d, want ModeFind", a.mode)
+ }
+}
+
+func TestFindQueryHighlightsAndJumps(t *testing.T) {
+ a := newApp()
+ a.editor.SetContent([]byte("alpha\nbeta\nalpha"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+ for _, r := range "alpha" {
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ if a.editor.FindCount() != 2 {
+ t.Fatalf("FindCount = %d, want 2", a.editor.FindCount())
+ }
+}
+
+func TestFindEnterCyclesNext(t *testing.T) {
+ a := newApp()
+ a.editor.SetContent([]byte("x\nx\nx"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ row, _, _, _ := a.editor.ActiveMatch()
+ if row != 0 {
+ t.Fatalf("first active row = %d, want 0", row)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if row, _, _, _ := a.editor.ActiveMatch(); row != 1 {
+ t.Fatalf("after Enter, active row = %d, want 1", row)
+ }
+}
+
+func TestFindEscClosesAndClears(t *testing.T) {
+ a := newApp()
+ a.editor.SetContent([]byte("alpha alpha"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlG})
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if a.mode != ModeEditor {
+ t.Fatalf("mode = %d, want ModeEditor after Esc", a.mode)
+ }
+ if a.editor.FindCount() != 0 {
+ t.Errorf("Esc should clear find, got %d", a.editor.FindCount())
+ }
+}
internal/editor/editor.go +14 −0
@@ -30,6 +30,10 @@
undo []snapshot // edit checkpoints, oldest first (TASK-006)
redo []snapshot // undone checkpoints awaiting redo
lastKind editKind // kind of the last recorded group, for coalescing
+
+ find []match // in-document find matches, document order (TASK-007)
+ findActive int // index of the active match in find, -1 = none
+ findQuery string // current find query
}
// New returns an empty editor with one blank line and the default theme.
@@ -57,6 +61,7 @@ e.Scroll = 0
e.goalCol = 0
e.Dirty = false
e.resetHistory()
+ e.ClearFind()
}
// Bytes serializes the buffer with \n line separators.
@@ -587,6 +592,8 @@ // the cursor's visual row. Output is e.Width columns wide; the app adds margins.
func (e *Editor) View() string {
cursorStyle := lipgloss.NewStyle().Foreground(e.theme.Background).Background(e.theme.Pointer)
selStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg)
+ findStyle := lipgloss.NewStyle().Foreground(e.theme.Text).Background(e.theme.Highlight)
+ findActiveStyle := lipgloss.NewStyle().Foreground(e.theme.SelFg).Background(e.theme.SelBg)
rows := e.buildVisual()
ci := cursorVIndex(rows, e.Cursor)
var b strings.Builder
@@ -598,6 +605,13 @@ for r := e.Scroll; r < end; r++ {
spans := rows[r].spans
if a, bb, ok := e.selectionForRow(rows[r]); ok {
spans = overlaySelection(spans, a, bb, selStyle)
+ }
+ for _, m := range e.matchRangesForRow(rows[r]) {
+ st := findStyle
+ if m.active {
+ st = findActiveStyle
+ }
+ spans = overlaySelection(spans, m.a, m.b, st)
}
if r == ci {
b.WriteString(renderSpansCursor(spans, e.Cursor.Col-rows[r].start, cursorStyle))
internal/editor/find.go +138 −0
@@ -0,0 +1,138 @@
+package editor
+
+import "strings"
+
+// In-document find (TASK-007): a case-insensitive substring search over the
+// buffer. Matches are kept in document order; one is "active" (the cursor parks
+// on it) and View highlights all of them, the active one more strongly. The app
+// drives this from a find bar — live SetFindQuery as the query is typed, then
+// FindNext/FindPrev to cycle.
+
+// match is a found range within a single logical line, in rune columns.
+type match struct {
+ row int
+ startCol int
+ endCol int
+}
+
+// SetFindQuery recomputes matches for q (case-insensitive) and activates the
+// first match at or after the cursor, wrapping to the first otherwise. It moves
+// the cursor onto that match and scrolls it into view. Returns the match count.
+func (e *Editor) SetFindQuery(q string) int {
+ e.findQuery = q
+ e.find = nil
+ e.findActive = -1
+ if q == "" {
+ return 0
+ }
+ qr := []rune(strings.ToLower(q))
+ for row, line := range e.Lines {
+ lr := []rune(strings.ToLower(line))
+ for i := 0; i+len(qr) <= len(lr); {
+ if runesEqual(lr[i:i+len(qr)], qr) {
+ e.find = append(e.find, match{row: row, startCol: i, endCol: i + len(qr)})
+ i += len(qr) // non-overlapping
+ } else {
+ i++
+ }
+ }
+ }
+ if len(e.find) == 0 {
+ return 0
+ }
+ e.findActive = 0
+ for idx, m := range e.find {
+ if m.row > e.Cursor.Row || (m.row == e.Cursor.Row && m.startCol >= e.Cursor.Col) {
+ e.findActive = idx
+ break
+ }
+ }
+ e.moveToActive()
+ return len(e.find)
+}
+
+// FindNext activates the next match, wrapping around.
+func (e *Editor) FindNext() {
+ if len(e.find) == 0 {
+ return
+ }
+ e.findActive = (e.findActive + 1) % len(e.find)
+ e.moveToActive()
+}
+
+// FindPrev activates the previous match, wrapping around.
+func (e *Editor) FindPrev() {
+ if len(e.find) == 0 {
+ return
+ }
+ e.findActive = (e.findActive - 1 + len(e.find)) % len(e.find)
+ e.moveToActive()
+}
+
+// ClearFind drops all find state (called on Esc and when the buffer is replaced).
+func (e *Editor) ClearFind() {
+ e.find = nil
+ e.findActive = -1
+ e.findQuery = ""
+}
+
+// FindCount returns the number of current matches.
+func (e *Editor) FindCount() int { return len(e.find) }
+
+// ActiveMatch reports the active match's location, or ok=false if none.
+func (e *Editor) ActiveMatch() (row, startCol, endCol int, ok bool) {
+ if e.findActive < 0 || e.findActive >= len(e.find) {
+ return 0, 0, 0, false
+ }
+ m := e.find[e.findActive]
+ return m.row, m.startCol, m.endCol, true
+}
+
+// moveToActive parks the cursor at the active match start and scrolls to it.
+func (e *Editor) moveToActive() {
+ if e.findActive < 0 || e.findActive >= len(e.find) {
+ return
+ }
+ m := e.find[e.findActive]
+ e.Cursor = Position{Row: m.row, Col: m.startCol}
+ e.setGoal()
+ e.followCursor()
+}
+
+// rowMatch is a find match clipped to one visual row, in row-local rune columns.
+type rowMatch struct {
+ a, b int
+ active bool
+}
+
+// matchRangesForRow returns the find matches intersecting visual row vr.
+func (e *Editor) matchRangesForRow(vr vrow) []rowMatch {
+ if len(e.find) == 0 {
+ return nil
+ }
+ rowStart, rowEnd := vr.start, vr.start+vr.runes
+ var out []rowMatch
+ for i, m := range e.find {
+ if m.row != vr.logRow {
+ continue
+ }
+ lo := max(m.startCol, rowStart)
+ hi := min(m.endCol, rowEnd)
+ if lo < hi {
+ out = append(out, rowMatch{a: lo - rowStart, b: hi - rowStart, active: i == e.findActive})
+ }
+ }
+ return out
+}
+
+func runesEqual(a, b []rune) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
internal/editor/find_test.go +131 −0
@@ -0,0 +1,131 @@
+package editor
+
+import "testing"
+
+func TestSetFindQueryFindsAllMatches(t *testing.T) {
+ e := newEditorWith("foo bar foo", "baz foo")
+ if n := e.SetFindQuery("foo"); n != 3 {
+ t.Fatalf("want 3 matches, got %d", n)
+ }
+ if e.FindCount() != 3 {
+ t.Errorf("FindCount = %d, want 3", e.FindCount())
+ }
+}
+
+func TestFindIsCaseInsensitive(t *testing.T) {
+ e := newEditorWith("Foo foo FOO")
+ if n := e.SetFindQuery("foo"); n != 3 {
+ t.Fatalf("case-insensitive find: want 3, got %d", n)
+ }
+}
+
+func TestFindMatchesAreNonOverlapping(t *testing.T) {
+ e := newEditorWith("aaaa")
+ if n := e.SetFindQuery("aa"); n != 2 {
+ t.Fatalf("non-overlapping: want 2, got %d", n)
+ }
+}
+
+func TestSetFindQueryActivatesFirstMatchFromCursor(t *testing.T) {
+ e := newEditorWith("foo", "foo", "foo")
+ e.Cursor = Position{Row: 1, Col: 0}
+ e.SetFindQuery("foo")
+ row, _, _, ok := e.ActiveMatch()
+ if !ok || row != 1 {
+ t.Fatalf("active match row = %d ok=%v, want row 1", row, ok)
+ }
+}
+
+func TestSetFindQueryMovesCursorToActiveMatch(t *testing.T) {
+ e := newEditorWith("alpha", "beta target", "gamma")
+ e.SetFindQuery("target")
+ if e.Cursor.Row != 1 || e.Cursor.Col != 5 {
+ t.Fatalf("cursor = %+v, want {1,5}", e.Cursor)
+ }
+}
+
+func TestFindNextWraps(t *testing.T) {
+ e := newEditorWith("x", "x", "x")
+ e.SetFindQuery("x") // active 0
+ e.FindNext() // 1
+ e.FindNext() // 2
+ if r, _, _, _ := e.ActiveMatch(); r != 2 {
+ t.Fatalf("after 2 next, active row = %d, want 2", r)
+ }
+ e.FindNext() // wraps to 0
+ if r, _, _, _ := e.ActiveMatch(); r != 0 {
+ t.Fatalf("FindNext should wrap to row 0, got %d", r)
+ }
+}
+
+func TestFindPrevWraps(t *testing.T) {
+ e := newEditorWith("x", "x", "x")
+ e.Cursor = Position{Row: 0, Col: 0}
+ e.SetFindQuery("x") // active 0
+ e.FindPrev() // wraps to last
+ if r, _, _, _ := e.ActiveMatch(); r != 2 {
+ t.Fatalf("FindPrev should wrap to row 2, got %d", r)
+ }
+}
+
+func TestFindNextScrollsToMatch(t *testing.T) {
+ lines := make([]string, 40)
+ for i := range lines {
+ lines[i] = "line"
+ }
+ lines[30] = "needle"
+ e := newEditorWith(lines...)
+ e.SetSize(80, 5) // only 5 visible rows
+ e.SetFindQuery("needle")
+ if e.Scroll == 0 {
+ t.Fatalf("view should scroll so the match is visible, Scroll=%d", e.Scroll)
+ }
+ ci := cursorVIndex(e.buildVisual(), e.Cursor)
+ if ci < e.Scroll || ci >= e.Scroll+e.Height {
+ t.Fatalf("active match (vrow %d) not in viewport [%d,%d)", ci, e.Scroll, e.Scroll+e.Height)
+ }
+}
+
+func TestEmptyQueryClearsMatches(t *testing.T) {
+ e := newEditorWith("foo foo")
+ e.SetFindQuery("foo")
+ if e.SetFindQuery(""); e.FindCount() != 0 {
+ t.Fatalf("empty query should clear matches, got %d", e.FindCount())
+ }
+ if _, _, _, ok := e.ActiveMatch(); ok {
+ t.Error("empty query should clear the active match")
+ }
+}
+
+func TestClearFindResetsState(t *testing.T) {
+ e := newEditorWith("foo foo")
+ e.SetFindQuery("foo")
+ e.ClearFind()
+ if e.FindCount() != 0 {
+ t.Fatalf("ClearFind should drop matches, got %d", e.FindCount())
+ }
+ if _, _, _, ok := e.ActiveMatch(); ok {
+ t.Error("ClearFind should clear the active match")
+ }
+}
+
+func TestFindNoMatches(t *testing.T) {
+ e := newEditorWith("hello world")
+ if n := e.SetFindQuery("zzz"); n != 0 {
+ t.Fatalf("want 0 matches, got %d", n)
+ }
+ if _, _, _, ok := e.ActiveMatch(); ok {
+ t.Error("no matches should mean no active match")
+ }
+ e.FindNext() // must not panic
+ e.FindPrev()
+}
+
+func TestSetContentClearsFind(t *testing.T) {
+ e := newEditorWith("foo foo")
+ e.SetFindQuery("foo")
+ e.SetContent([]byte("bar"))
+ if e.FindCount() != 0 {
+ t.Fatalf("SetContent should clear find state, got %d", e.FindCount())
+ }
+}
main.go +1 −0
@@ -48,6 +48,7 @@ EDITOR KEYS
Ctrl+S save (an unnamed buffer prompts for a name)
Ctrl+P toggle the read preview
Ctrl+F fuzzy file picker
+ Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
Ctrl+D today's daily note
Ctrl+N new note in the current directory
Ctrl+B new note in the inbox