From afd3506eb85e12b78d2d192b60cb95f03ad13679 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:58:49 -0700 Subject: [PATCH] feat(preview): jump to a markdown heading in the preview Reading a long markdown file in the preview panel meant scrolling by hand to reach a section. Press "t" on a markdown file to open a heading list, move the selection with j/k (or the arrows, g/G, home/end), and press Enter to scroll straight to that heading. Esc closes the list without moving the viewport. Headings are parsed from the raw source so fenced code blocks are skipped. When markdown rendering is on (the default), the rendered display strips the leading hashes, so each raw heading is mapped forward onto its rendered line by matching the ansi-stripped heading text in document order. With rendering off the raw line index is used directly. Closes #316 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 1 + internal/keybindings/keybindings.json | 1 + internal/panels/preview/editor.go | 6 +- internal/panels/preview/preview.go | 27 +- internal/panels/preview/preview_test.go | 3 +- internal/panels/preview/toc.go | 335 +++++++++++++++++++++++ internal/panels/preview/toc_test.go | 345 ++++++++++++++++++++++++ 7 files changed, 714 insertions(+), 4 deletions(-) create mode 100644 internal/panels/preview/toc.go create mode 100644 internal/panels/preview/toc_test.go diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3b..09ea1e8 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -185,6 +185,7 @@ Preview panel (panel 5). | `j/k` | Scroll content | | `g/G` | Jump to top/bottom | | `L` | Go to line | +| `t` | Jump to markdown heading | | `PgDn/PgUp` | Page down/up | | `W` | Toggle word wrap | | `n` | Toggle line numbers | diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e704..c760aa2 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -161,6 +161,7 @@ { "key": "j/k", "action": "Scroll content" }, { "key": "g/G", "action": "Jump to top/bottom" }, { "key": "L", "action": "Go to line" }, + { "key": "t", "action": "Jump to markdown heading" }, { "key": "PgDn/PgUp", "action": "Page down/up" }, { "key": "W", "action": "Toggle word wrap" }, { "key": "n", "action": "Toggle line numbers" }, diff --git a/internal/panels/preview/editor.go b/internal/panels/preview/editor.go index 4e0b3db..5085c66 100644 --- a/internal/panels/preview/editor.go +++ b/internal/panels/preview/editor.go @@ -22,6 +22,8 @@ const ( keyDown = "down" keyEsc = "esc" keyEscape = "escape" + keyEnter = "enter" + keyEnd = "end" // clipboardTimeout bounds how long clipboard subprocess calls may block. clipboardTimeout = 2 * time.Second @@ -283,7 +285,7 @@ func handleEditKeyPress(p *Preview, msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) ensureCursorVisible(p) } - case "enter": + case keyEnter: if p.editBuf != nil { p.editBuf.SplitLine(p.cursorLine, p.cursorCol, p.editCfg.AutoIndent) p.cursorLine++ @@ -551,7 +553,7 @@ func handleEditKeyPress(p *Preview, msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) clearEditSelection(p) p.cursorCol = 0 - case "end", "ctrl+e": //nolint:goconst // key name, not a magic string + case keyEnd, "ctrl+e": clearEditSelection(p) if p.editBuf != nil { line := p.editBuf.Line(p.cursorLine) diff --git a/internal/panels/preview/preview.go b/internal/panels/preview/preview.go index 9ef8dd3..a515458 100644 --- a/internal/panels/preview/preview.go +++ b/internal/panels/preview/preview.go @@ -64,6 +64,13 @@ type Preview struct { // as a line-number entry and scrolls to the entered line on Enter. gotoLineActive bool gotoLineInput string + // Markdown heading-jump overlay state. mdHeadings is populated on load for + // markdown files; tocTargets holds the resolved display line per heading + // while the overlay is open. + tocActive bool + tocCursor int + mdHeadings []tocHeading + tocTargets []int // GitHub content mode – when a GitHub item (issue/PR/action run) is // selected, the preview shows the item detail instead of a file. ghMode bool // true when showing GitHub content instead of file @@ -96,6 +103,7 @@ type fileLoadedMsg struct { err error path string lines []string + headings []tocHeading isBinary bool isLarge bool } @@ -263,6 +271,10 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { p.blameMode = false p.blameLines = nil p.diffLines = nil + p.tocActive = false + p.tocCursor = 0 + p.mdHeadings = nil + p.tocTargets = nil cmds := []tea.Cmd{p.loadFileCmd(msg.Path)} if p.gitClient != nil { cmds = append(cmds, p.loadContextDiffCmd(msg.Path, p.diffContext)) @@ -273,6 +285,7 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { if msg.path == p.filePath { p.loading = false p.lines = msg.lines + p.mdHeadings = msg.headings p.err = msg.err p.isBinary = msg.isBinary p.isLarge = msg.isLarge @@ -471,6 +484,10 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { if p.gotoLineActive { return p.handleGotoLineKey(msg) } + // The heading-jump overlay captures all input while it is open. + if p.tocActive { + return p.handleTOCKey(msg) + } switch msg.String() { case "e": // Edit is only allowed in file mode (not diff) and for local files. @@ -496,6 +513,8 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { p.scrollToBottom() case "L": return p, p.openGotoLine() + case "t": + return p, p.openTOC() case "W": p.wordWrap = !p.wordWrap case "n": @@ -561,7 +580,7 @@ func (p *Preview) handleGotoLineKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) switch msg.String() { case keyEscape, keyEsc: return p, p.closeGotoLine() - case "enter": + case keyEnter: p.commitGotoLine() return p, p.closeGotoLine() case "backspace": @@ -636,6 +655,10 @@ func (p *Preview) View(width, height int) string { if p.editMode && p.editBuf != nil { return renderEditContent(p, width, height) } + // Heading-jump overlay takes over the content area while it is open. + if p.tocActive { + return p.renderTOC(width, height) + } // Blame mode if p.blameMode && len(p.blameLines) > 0 { return p.renderBlameContent(width, height) @@ -717,6 +740,7 @@ func (p *Preview) KeyBindings() []panels.KeyBinding { {Key: "g", Description: "Go to top", Action: "goto_top"}, {Key: "G", Description: "Go to bottom", Action: "goto_bottom"}, {Key: "L", Description: "Go to line", Action: "goto_line"}, + {Key: "t", Description: "Jump to heading", Action: "goto_heading"}, {Key: "W", Description: "Toggle word wrap", Action: "toggle_wrap"}, {Key: "n", Description: "Toggle line numbers", Action: "toggle_line_numbers"}, {Key: "m", Description: "Toggle markdown render", Action: "toggle_markdown_render"}, @@ -794,6 +818,7 @@ func (p *Preview) loadFileCmd(path string) tea.Cmd { // Render based on file type switch ext { case extMD, extMarkdown, extMdown, extMkd: + result.headings = parseMarkdownHeadings(source) if renderMD { result.lines = markdown.RenderStatic(source, width) } else if cfg.GetSyntaxHighlighting() { diff --git a/internal/panels/preview/preview_test.go b/internal/panels/preview/preview_test.go index e2b4040..37a996d 100644 --- a/internal/panels/preview/preview_test.go +++ b/internal/panels/preview/preview_test.go @@ -778,7 +778,7 @@ func TestKeyBindings(t *testing.T) { bindings := p.KeyBindings() assert.NotEmpty(t, bindings) - assert.Len(t, bindings, 15) + assert.Len(t, bindings, 16) // Verify all expected bindings are present actions := make([]string, len(bindings)) @@ -795,6 +795,7 @@ func TestKeyBindings(t *testing.T) { assert.Contains(t, actions, "goto_top") assert.Contains(t, actions, "goto_bottom") assert.Contains(t, actions, "goto_line") + assert.Contains(t, actions, "goto_heading") assert.Contains(t, actions, "toggle_wrap") assert.Contains(t, actions, "toggle_line_numbers") assert.Contains(t, actions, "toggle_markdown_render") diff --git a/internal/panels/preview/toc.go b/internal/panels/preview/toc.go new file mode 100644 index 0000000..7d15969 --- /dev/null +++ b/internal/panels/preview/toc.go @@ -0,0 +1,335 @@ +package preview + +import ( + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/charmbracelet/x/ansi" + "github.com/jongio/grut/internal/panels" +) + +// tocHeading is a single markdown ATX heading discovered in the raw source. +// rawLine is the 0-based index of the heading within the file's source lines, +// which maps directly to the display line when markdown rendering is off. +type tocHeading struct { + text string + level int + rawLine int +} + +// tocMaxLevel is the deepest ATX heading level markdown recognizes (######). +const tocMaxLevel = 6 + +// Fenced code block markers. Headings inside a fence are skipped. +const ( + fenceBacktick = "```" + fenceTilde = "~~~" +) + +// parseMarkdownHeadings extracts ATX headings (# ... through ###### ...) from +// markdown source. Headings inside fenced code blocks (``` or ~~~) are ignored +// so that commented shell lines or diff hunks are not mistaken for headings. +// Setext headings (underlined with = or -) are intentionally not detected; +// only the ATX form has an unambiguous single-line anchor. +func parseMarkdownHeadings(source string) []tocHeading { + lines := strings.Split(source, "\n") + headings := make([]tocHeading, 0, 8) + inFence := false + fenceMarker := "" + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if inFence { + if fenceMarker != "" && strings.HasPrefix(trimmed, fenceMarker) { + inFence = false + fenceMarker = "" + } + continue + } + if strings.HasPrefix(trimmed, fenceBacktick) { + inFence = true + fenceMarker = fenceBacktick + continue + } + if strings.HasPrefix(trimmed, fenceTilde) { + inFence = true + fenceMarker = fenceTilde + continue + } + level, text, ok := parseHeadingLine(line) + if !ok { + continue + } + headings = append(headings, tocHeading{text: text, level: level, rawLine: i}) + } + return headings +} + +// parseHeadingLine reports whether a single line is an ATX heading and, if so, +// returns its level (1-6) and display text with the leading hashes, optional +// closing hashes, and surrounding whitespace stripped. A line like "#tag" is +// not a heading because CommonMark requires a space after the hash run. +func parseHeadingLine(line string) (level int, text string, ok bool) { + i := 0 + // Up to three leading spaces are allowed before the hash run. + for i < len(line) && i < 3 && line[i] == ' ' { + i++ + } + start := i + for i < len(line) && line[i] == '#' { + i++ + } + level = i - start + if level < 1 || level > tocMaxLevel { + return 0, "", false + } + // A space or tab must separate the hashes from the heading text (or the + // line ends right after the hashes, which is an empty heading we skip). + if i < len(line) && line[i] != ' ' && line[i] != '\t' { + return 0, "", false + } + text = strings.TrimSpace(line[i:]) + text = stripClosingHashes(text) + if text == "" { + return 0, "", false + } + return level, text, true +} + +// stripClosingHashes removes an optional trailing run of hashes used to close +// an ATX heading, e.g. "## Title ##" becomes "Title". A trailing hash run that +// is not preceded by whitespace (e.g. "Title#") is treated as part of the text. +func stripClosingHashes(text string) string { + trimmed := strings.TrimRight(text, " \t") + end := len(trimmed) + for end > 0 && trimmed[end-1] == '#' { + end-- + } + if end == len(trimmed) { + return trimmed + } + if end == 0 { + // The line was only hashes after the opening run; nothing to show. + return "" + } + if trimmed[end-1] != ' ' && trimmed[end-1] != '\t' { + // The hashes were glued to a word, so keep the original text. + return trimmed + } + return strings.TrimRight(trimmed[:end], " \t") +} + +// openTOC activates the heading-jump overlay for the current markdown file. +// It returns a command that routes key presses to the preview until the overlay +// closes. It is a no-op (returns nil) when the view is not a plain markdown file +// with headings, mirroring openGotoLine's guards. +func (p *Preview) openTOC() tea.Cmd { + if p.ghMode || p.blameMode || p.diffMode || p.isBinary || p.isLarge { + return nil + } + if !isMarkdownExt(filepath.Ext(p.filePath)) { + return nil + } + if len(p.mdHeadings) == 0 { + return nil + } + p.tocTargets = p.mapHeadingDisplayLines() + p.tocCursor = p.nearestHeadingIndex() + p.tocActive = true + return func() tea.Msg { return panels.PreviewInputStartedMsg{} } +} + +// closeTOC deactivates the overlay and resumes normal key routing. +func (p *Preview) closeTOC() tea.Cmd { + p.tocActive = false + return func() tea.Msg { return panels.PreviewInputEndedMsg{} } +} + +// handleTOCKey processes a key press while the heading overlay is open. +// Up/down (and j/k) move the selection, Enter jumps to the heading, and Esc +// cancels without moving the viewport. +func (p *Preview) handleTOCKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { + switch msg.String() { + case keyEscape, keyEsc: + return p, p.closeTOC() + case keyEnter: + p.commitTOC() + return p, p.closeTOC() + case "j", keyDown: + if p.tocCursor < len(p.mdHeadings)-1 { + p.tocCursor++ + } + return p, nil + case "k", "up": + if p.tocCursor > 0 { + p.tocCursor-- + } + return p, nil + case "g", "home": + p.tocCursor = 0 + return p, nil + case "G", keyEnd: + p.tocCursor = len(p.mdHeadings) - 1 + return p, nil + } + return p, nil +} + +// commitTOC scrolls so the selected heading sits at the top of the viewport. +func (p *Preview) commitTOC() { + if p.tocCursor < 0 || p.tocCursor >= len(p.tocTargets) { + return + } + p.scrollY = p.tocTargets[p.tocCursor] + p.clampScroll() +} + +// nearestHeadingIndex returns the index of the heading at or just above the +// current scroll position so the overlay opens with the visible section +// preselected. +func (p *Preview) nearestHeadingIndex() int { + idx := 0 + for i, target := range p.tocTargets { + if target <= p.scrollY { + idx = i + } else { + break + } + } + return idx +} + +// mapHeadingDisplayLines resolves each heading to a display-line index in the +// currently rendered content. When markdown rendering is off, the display lines +// are a 1:1 transform of the source, so the raw line index is used directly. +// When glamour rendering is on, the hash prefixes are gone, so headings are +// located by scanning the rendered lines for their text in document order. +func (p *Preview) mapHeadingDisplayLines() []int { + targets := make([]int, len(p.mdHeadings)) + lineCount := len(p.lines) + if !p.renderMarkdown { + for i, h := range p.mdHeadings { + targets[i] = clampIndex(h.rawLine, lineCount) + } + return targets + } + cursor := 0 + for i, h := range p.mdHeadings { + target := clampIndex(cursor, lineCount) + if found := findHeadingLine(p.lines, h.text, cursor); found >= 0 { + target = found + cursor = found + 1 + } + targets[i] = target + } + return targets +} + +// findHeadingLine scans rendered lines starting at from for the first line whose +// visible text matches the heading. It tries exact (trimmed) equality first, +// then a substring match, then a case-insensitive substring match, so that +// glamour styling (padding, background blocks, letter casing) still resolves. +// Returns -1 when no line matches. +func findHeadingLine(lines []string, text string, from int) int { + if from < 0 { + from = 0 + } + lower := strings.ToLower(text) + fallback := -1 + for j := from; j < len(lines); j++ { + plain := strings.TrimSpace(ansi.Strip(lines[j])) + if plain == "" { + continue + } + if plain == text { + return j + } + if fallback == -1 && strings.Contains(plain, text) { + fallback = j + } + if fallback == -1 && strings.Contains(strings.ToLower(plain), lower) { + fallback = j + } + } + return fallback +} + +// clampIndex keeps a line index within [0, count-1], returning 0 for empty +// content. +func clampIndex(idx, count int) int { + if count == 0 { + return 0 + } + if idx < 0 { + return 0 + } + if idx >= count { + return count - 1 + } + return idx +} + +// renderTOC draws the heading overlay: a scrollable, indented list of headings +// with the selected entry marked. It fills exactly height lines so the outer +// container never reflows. +func (p *Preview) renderTOC(width, height int) string { + if height < 1 { + height = 1 + } + title := p.newDimStyle().Render("Jump to heading") + footerText := "\u2191/\u2193 select \u00b7 enter jump \u00b7 esc cancel" + footer := ansi.Truncate(p.newDimStyle().Render(footerText), width, "") + + listHeight := height - 2 + if listHeight < 1 { + listHeight = 1 + } + + start := tocWindowStart(p.tocCursor, len(p.mdHeadings), listHeight) + end := start + listHeight + if end > len(p.mdHeadings) { + end = len(p.mdHeadings) + } + + selectedStyle := lipgloss.NewStyle(). + Foreground(panels.ColorOf(p.themeColors().BrightCyan, "#8BE9FD")). + Bold(true) + + rows := make([]string, 0, height) + rows = append(rows, ansi.Truncate(title, width, "")) + for i := start; i < end; i++ { + h := p.mdHeadings[i] + indent := strings.Repeat(" ", h.level-1) + marker := " " + label := indent + h.text + if i == p.tocCursor { + marker = "\u25b8 " + label = selectedStyle.Render(indent + h.text) + } + row := ansi.Truncate(marker+label, width, "") + rows = append(rows, row) + } + for len(rows) < height-1 { + rows = append(rows, "") + } + rows = append(rows, footer) + return strings.Join(rows, "\n") +} + +// tocWindowStart returns the first visible index for the heading list so that +// the cursor stays within the visible window of listHeight rows. +func tocWindowStart(cursor, total, listHeight int) int { + if total <= listHeight { + return 0 + } + start := cursor - listHeight/2 + if start < 0 { + start = 0 + } + if start > total-listHeight { + start = total - listHeight + } + return start +} diff --git a/internal/panels/preview/toc_test.go b/internal/panels/preview/toc_test.go new file mode 100644 index 0000000..3f852ee --- /dev/null +++ b/internal/panels/preview/toc_test.go @@ -0,0 +1,345 @@ +package preview + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/jongio/grut/internal/markdown" + "github.com/jongio/grut/internal/panels" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMarkdownHeadings_Basic(t *testing.T) { + source := strings.Join([]string{ + "# Title", + "", + "Intro paragraph.", + "", + "## Section One", + "Body text.", + "### Subsection", + "###### Deep", + }, "\n") + + got := parseMarkdownHeadings(source) + require.Len(t, got, 4) + + assert.Equal(t, "Title", got[0].text) + assert.Equal(t, 1, got[0].level) + assert.Equal(t, 0, got[0].rawLine) + + assert.Equal(t, "Section One", got[1].text) + assert.Equal(t, 2, got[1].level) + assert.Equal(t, 4, got[1].rawLine) + + assert.Equal(t, "Subsection", got[2].text) + assert.Equal(t, 3, got[2].level) + assert.Equal(t, 6, got[2].rawLine) + + assert.Equal(t, "Deep", got[3].text) + assert.Equal(t, 6, got[3].level) + assert.Equal(t, 7, got[3].rawLine) +} + +func TestParseMarkdownHeadings_SkipsFencedCode(t *testing.T) { + source := strings.Join([]string{ + "# Real Heading", + "```sh", + "# not a heading, a shell comment", + "## also not a heading", + "```", + "## After Fence", + "~~~", + "### hidden in tilde fence", + "~~~", + "#### Final", + }, "\n") + + got := parseMarkdownHeadings(source) + require.Len(t, got, 3) + assert.Equal(t, "Real Heading", got[0].text) + assert.Equal(t, "After Fence", got[1].text) + assert.Equal(t, "Final", got[2].text) +} + +func TestParseMarkdownHeadings_ClosingHashes(t *testing.T) { + source := strings.Join([]string{ + "## Balanced ##", + "### Trailing hashes #####", + "# Kept#", + }, "\n") + + got := parseMarkdownHeadings(source) + require.Len(t, got, 3) + assert.Equal(t, "Balanced", got[0].text) + assert.Equal(t, "Trailing hashes", got[1].text) + // A hash glued to a word is part of the text, not a closing sequence. + assert.Equal(t, "Kept#", got[2].text) +} + +func TestParseMarkdownHeadings_Rejects(t *testing.T) { + source := strings.Join([]string{ + "#no-space-after-hash", + "####### too many hashes", + "Regular line with # in middle", + " ## Indented up to three spaces", + " # Four spaces is a code block indent", + "## ", + }, "\n") + + got := parseMarkdownHeadings(source) + require.Len(t, got, 1) + assert.Equal(t, "Indented up to three spaces", got[0].text) + assert.Equal(t, 3, got[0].rawLine) +} + +func TestMapHeadingDisplayLines_RawIdentity(t *testing.T) { + source := strings.Join([]string{ + "# One", + "text", + "## Two", + "more", + "### Three", + }, "\n") + + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.renderMarkdown = false + p.lines = strings.Split(source, "\n") + p.mdHeadings = parseMarkdownHeadings(source) + + targets := p.mapHeadingDisplayLines() + require.Len(t, targets, 3) + assert.Equal(t, 0, targets[0]) + assert.Equal(t, 2, targets[1]) + assert.Equal(t, 4, targets[2]) +} + +func TestMapHeadingDisplayLines_Glamour(t *testing.T) { + source := strings.Join([]string{ + "# Overview", + "", + "Some introduction text that spans a little.", + "", + "## Installation", + "", + "Run the installer.", + "", + "## Usage", + "", + "Use it well.", + "", + "### Advanced Usage", + "", + "Details here.", + }, "\n") + + width := 80 + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.renderMarkdown = true + p.lines = markdown.RenderStatic(source, width) + p.mdHeadings = parseMarkdownHeadings(source) + require.Len(t, p.mdHeadings, 4) + + targets := p.mapHeadingDisplayLines() + require.Len(t, targets, 4) + + // Targets must be strictly increasing (document order preserved). + for i := 1; i < len(targets); i++ { + assert.Greater(t, targets[i], targets[i-1], + "heading %d target should come after heading %d", i, i-1) + } + // Each resolved line must actually contain the heading text. + for i, h := range p.mdHeadings { + require.Less(t, targets[i], len(p.lines)) + plain := strings.TrimSpace(ansi.Strip(p.lines[targets[i]])) + assert.Contains(t, plain, h.text, + "rendered line for %q should contain the heading text", h.text) + } +} + +func TestOpenTOC_NonMarkdownReturnsNil(t *testing.T) { + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "main.go" + p.mdHeadings = nil + + cmd := p.openTOC() + assert.Nil(t, cmd) + assert.False(t, p.tocActive) +} + +func TestOpenTOC_NoHeadingsReturnsNil(t *testing.T) { + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "README.md" + p.lines = []string{"just body text", "no headings"} + p.mdHeadings = nil + + cmd := p.openTOC() + assert.Nil(t, cmd) + assert.False(t, p.tocActive) +} + +func TestOpenTOC_ActivatesAndRoutesInput(t *testing.T) { + source := "# A\ntext\n## B\nmore\n" + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "README.md" + p.renderMarkdown = false + p.lines = strings.Split(source, "\n") + p.mdHeadings = parseMarkdownHeadings(source) + + cmd := p.openTOC() + require.NotNil(t, cmd) + assert.True(t, p.tocActive) + assert.Len(t, p.tocTargets, 2) + + msg := cmd() + _, ok := msg.(panels.PreviewInputStartedMsg) + assert.True(t, ok, "openTOC should emit PreviewInputStartedMsg") +} + +func TestOpenTOC_GuardsForNonFileViews(t *testing.T) { + source := "# A\n## B\n" + base := func() *Preview { + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "README.md" + p.renderMarkdown = false + p.lines = strings.Split(source, "\n") + p.mdHeadings = parseMarkdownHeadings(source) + return p + } + + cases := map[string]func(*Preview){ + "blame": func(p *Preview) { p.blameMode = true }, + "diff": func(p *Preview) { p.diffMode = true }, + "binary": func(p *Preview) { p.isBinary = true }, + "large": func(p *Preview) { p.isLarge = true }, + "github": func(p *Preview) { p.ghMode = true }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + p := base() + mutate(p) + assert.Nil(t, p.openTOC()) + assert.False(t, p.tocActive) + }) + } +} + +func TestHandleTOCKey_NavigationAndJump(t *testing.T) { + source := strings.Join([]string{ + "# A", "l1", "l2", "## B", "l4", "l5", "### C", + "l7", "l8", "l9", "l10", "l11", "l12", "l13", + }, "\n") + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "README.md" + p.renderMarkdown = false + p.lines = strings.Split(source, "\n") + p.mdHeadings = parseMarkdownHeadings(source) + // Viewport of 7 (height-1) with 14 content lines leaves room to scroll + // to line 6 without clamping. + p.SetSize(40, 8) + + require.NotNil(t, p.openTOC()) + require.True(t, p.tocActive) + assert.Equal(t, 0, p.tocCursor) + + // Move down twice to the third heading (### C at raw line 6). + p.handleTOCKey(keyMsg("j")) + p.handleTOCKey(keyMsg(keyDown)) + assert.Equal(t, 2, p.tocCursor) + require.Equal(t, 6, p.tocTargets[2]) + + // Enter jumps and closes the overlay. + _, cmd := p.handleTOCKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.False(t, p.tocActive) + require.NotNil(t, cmd) + _, ok := cmd().(panels.PreviewInputEndedMsg) + assert.True(t, ok) + + // Heading C is at display line 6 and the viewport can reach it. + assert.Equal(t, 6, p.scrollY) +} + +func TestHandleTOCKey_EscCancels(t *testing.T) { + source := "# A\nl1\n## B\n" + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "README.md" + p.renderMarkdown = false + p.lines = strings.Split(source, "\n") + p.mdHeadings = parseMarkdownHeadings(source) + p.SetSize(40, 20) + p.scrollY = 0 + + require.NotNil(t, p.openTOC()) + p.handleTOCKey(keyMsg("j")) // move selection + _, cmd := p.handleTOCKey(tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.False(t, p.tocActive) + assert.Equal(t, 0, p.scrollY, "esc must not move the viewport") + require.NotNil(t, cmd) + _, ok := cmd().(panels.PreviewInputEndedMsg) + assert.True(t, ok) +} + +func TestRenderTOC_ShowsHeadingsAndMarker(t *testing.T) { + source := "# Alpha\n## Bravo\n### Charlie\n" + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.filePath = "README.md" + p.renderMarkdown = false + p.lines = strings.Split(source, "\n") + p.mdHeadings = parseMarkdownHeadings(source) + p.SetSize(40, 10) + require.NotNil(t, p.openTOC()) + p.tocCursor = 1 + + out := ansi.Strip(p.renderTOC(40, 10)) + assert.Contains(t, out, "Jump to heading") + assert.Contains(t, out, "Alpha") + assert.Contains(t, out, "Bravo") + assert.Contains(t, out, "Charlie") + assert.Contains(t, out, "\u25b8") // selection marker on the active row +} + +func TestPreview_TKeyOpensTOCForMarkdown(t *testing.T) { + dir := t.TempDir() + content := "# Heading One\n\nbody\n\n## Heading Two\n\nmore body\n" + path := writeFile(t, dir, "doc.md", content) + + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.SetSize(60, 20) + p.Focus() + loadFile(t, p, path) + require.NotEmpty(t, p.mdHeadings, "headings should load for markdown files") + + _, cmd := p.Update(keyMsg("t")) + assert.True(t, p.tocActive) + require.NotNil(t, cmd) + _, ok := cmd().(panels.PreviewInputStartedMsg) + assert.True(t, ok) +} + +func TestPreview_TKeyIgnoredForNonMarkdown(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "main.go", "package main\n\nfunc main() {}\n") + + p := New(defaultCfg(), defaultEditorCfg(), nil) + p.SetSize(60, 20) + p.Focus() + loadFile(t, p, path) + + p.Update(keyMsg("t")) + assert.False(t, p.tocActive) + assert.Empty(t, p.mdHeadings) +} + +func TestTOCWindowStart(t *testing.T) { + // Fits entirely: always start at 0. + assert.Equal(t, 0, tocWindowStart(3, 4, 10)) + // Cursor near the top. + assert.Equal(t, 0, tocWindowStart(0, 20, 6)) + // Cursor in the middle centers the window. + assert.Equal(t, 7, tocWindowStart(10, 20, 6)) + // Cursor near the bottom clamps to the last full window. + assert.Equal(t, 14, tocWindowStart(19, 20, 6)) +}