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)) +}