From 4320f138e1afde9b29a6c8233920fba9f40f3351 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Mon, 15 Jun 2026 21:37:30 -0700 Subject: [PATCH] feat: add markdown preview with goldmark AST rendering Adds a read-only preview tab for .md files using goldmark for proper markdown parsing. Supports headers, bold, italic, code spans, fenced code blocks with syntax highlighting, lists, blockquotes, links, and horizontal rules. Content is word-wrapped at 80 chars and centered. Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 + go.sum | 2 + internal/app/callbacks.go | 3 + internal/app/commands.go | 1 + internal/app/commands_preview.go | 41 +++ internal/markdown/preview.go | 301 ++++++++++++++++++++++ internal/markdown/preview_test.go | 244 ++++++++++++++++++ internal/ui/editor_group.go | 27 ++ internal/ui/markdown_preview.go | 128 +++++++++ tests/e2e/markdown_preview_test.go | 101 ++++++++ tests/functional/markdown-preview.test.js | 65 +++++ 11 files changed, 914 insertions(+) create mode 100644 internal/app/commands_preview.go create mode 100644 internal/markdown/preview.go create mode 100644 internal/markdown/preview_test.go create mode 100644 internal/ui/markdown_preview.go create mode 100644 tests/e2e/markdown_preview_test.go create mode 100644 tests/functional/markdown-preview.test.js diff --git a/go.mod b/go.mod index 1ef41ac8..141509a1 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/eugenioenko/vt10x v0.0.0-20260527232601-d49d2c8355db github.com/fsnotify/fsnotify v1.10.1 github.com/gdamore/tcell/v2 v2.13.7 + github.com/yuin/goldmark v1.8.2 ) require ( diff --git a/go.sum b/go.sum index 3ddfa8c3..f5894d75 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/app/callbacks.go b/internal/app/callbacks.go index adc96283..e54b3283 100644 --- a/internal/app/callbacks.go +++ b/internal/app/callbacks.go @@ -486,6 +486,9 @@ func registerWidgetCallbacks(app *App) { {Label: "Rename", Command: "explorer.rename"}, {Label: "Delete", Command: "explorer.delete"}, } + if strings.HasSuffix(node.Path, ".md") { + items = append(items, ui.MenuSep(), ui.ContextMenuItem{Label: "Preview", Command: "editor.openPreview"}) + } openContextMenu(app, items, sx, sy) } diff --git a/internal/app/commands.go b/internal/app/commands.go index 255ad739..5fd1da10 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -20,6 +20,7 @@ func RegisterCommands(app *App) { registerHelpCommands(app) registerOptionsCommands(app) registerSettingsCommands(app) + registerPreviewCommands(app) registerWidgetCallbacks(app) RegisterEscapeDismissers(app) } diff --git a/internal/app/commands_preview.go b/internal/app/commands_preview.go new file mode 100644 index 00000000..c66b4bc3 --- /dev/null +++ b/internal/app/commands_preview.go @@ -0,0 +1,41 @@ +package app + +import ( + "os" + "strings" + + "github.com/eugenioenko/ttt/internal/command" +) + +func registerPreviewCommands(app *App) { + reg := app.Reg + + reg.Register(command.Command{ + ID: "editor.openPreview", + Title: "Open Preview", + Keywords: []string{"markdown", "preview", "md"}, + Handler: func() { + path := app.EditorGroup.ActiveFilePath() + // Fallback: if triggered from the explorer context menu, use the selected node + if path == "" || !strings.HasSuffix(path, ".md") { + if node := app.Explorer.SelectedNode(); node != nil && strings.HasSuffix(node.Path, ".md") { + path = node.Path + } + } + if path == "" { + app.StatusWarn("No active file") + return + } + if !strings.HasSuffix(path, ".md") { + app.StatusWarn("Preview is only available for .md files") + return + } + data, err := os.ReadFile(path) + if err != nil { + app.StatusWarn("Cannot read file: " + err.Error()) + return + } + app.EditorGroup.OpenPreview(path, string(data)) + }, + }) +} diff --git a/internal/markdown/preview.go b/internal/markdown/preview.go new file mode 100644 index 00000000..412e6d87 --- /dev/null +++ b/internal/markdown/preview.go @@ -0,0 +1,301 @@ +package markdown + +import ( + "bytes" + "fmt" + "strings" + + "github.com/eugenioenko/ttt/internal/core/highlight" + "github.com/eugenioenko/ttt/internal/term" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +const PreviewWrapWidth = 80 + +// RenderPreview parses markdown content using goldmark and produces styled lines +// suitable for a read-only preview panel. maxWidth controls word wrapping. +func RenderPreview(content string, maxWidth int) []Line { + if maxWidth <= 0 { + maxWidth = PreviewWrapWidth + } + source := []byte(content) + md := goldmark.New() + doc := md.Parser().Parse(text.NewReader(source)) + + var lines []Line + walkNode(doc, source, maxWidth, &lines, 0, false, false) + if lines == nil { + lines = []Line{} + } + return lines +} + +func walkNode(node ast.Node, source []byte, maxWidth int, lines *[]Line, depth int, inBlockquote bool, inList bool) { + switch n := node.(type) { + case *ast.Document: + walkChildren(n, source, maxWidth, lines, depth, inBlockquote, inList) + + case *ast.Heading: + var spans []Span + collectInlineSpans(n, source, &spans, term.StyleHoverBold) + prefix := strings.Repeat("#", n.Level) + " " + headerSpans := []Span{{Text: prefix, Style: term.StyleHoverBold}} + headerSpans = append(headerSpans, spans...) + line := Line{Spans: headerSpans} + wrapped := wrapLine(line, maxWidth) + addBlankLine(lines) + *lines = append(*lines, wrapped...) + addBlankLine(lines) + + case *ast.Paragraph: + var spans []Span + collectInlineSpans(n, source, &spans, term.StyleDefault) + if len(spans) == 0 { + spans = []Span{{Text: "", Style: term.StyleDefault}} + } + line := Line{Spans: spans} + if inBlockquote { + wrapped := wrapLine(line, maxWidth-4) + for _, w := range wrapped { + quoted := prependBlockquote(w) + *lines = append(*lines, quoted) + } + } else if inList { + wrapped := wrapLine(line, maxWidth) + *lines = append(*lines, wrapped...) + } else { + wrapped := wrapLine(line, maxWidth) + *lines = append(*lines, wrapped...) + addBlankLine(lines) + } + + case *ast.FencedCodeBlock, *ast.CodeBlock: + var codeLines []string + lang := "" + if fcb, ok := n.(*ast.FencedCodeBlock); ok { + lang = string(fcb.Language(source)) + } + for i := 0; i < n.Lines().Len(); i++ { + seg := n.Lines().At(i) + line := string(seg.Value(source)) + line = strings.TrimRight(line, "\n") + codeLines = append(codeLines, line) + } + rendered := renderCodeBlockPreview(codeLines, lang) + *lines = append(*lines, rendered...) + addBlankLine(lines) + + case *ast.Blockquote: + walkChildren(n, source, maxWidth, lines, depth+1, true, inList) + + case *ast.List: + addBlankLineIfNeeded(lines) + itemIndex := 1 + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + if listItem, ok := child.(*ast.ListItem); ok { + var bullet string + if n.IsOrdered() { + bullet = fmt.Sprintf(" %d. ", itemIndex) + itemIndex++ + } else { + bullet = " • " + } + renderListItem(listItem, source, maxWidth, lines, bullet, depth) + } + } + addBlankLine(lines) + + case *ast.ListItem: + // Handled by the List case above + return + + case *ast.ThematicBreak: + rule := strings.Repeat("─", min(maxWidth, 40)) + *lines = append(*lines, Line{Spans: []Span{{Text: rule, Style: term.StyleMuted}}}) + addBlankLine(lines) + + case *ast.TextBlock: + var spans []Span + collectInlineSpans(n, source, &spans, term.StyleDefault) + if len(spans) > 0 { + line := Line{Spans: spans} + wrapped := wrapLine(line, maxWidth) + *lines = append(*lines, wrapped...) + } + + case *ast.HTMLBlock: + // Render raw HTML as plain text + for i := 0; i < n.Lines().Len(); i++ { + seg := n.Lines().At(i) + text := strings.TrimRight(string(seg.Value(source)), "\n") + *lines = append(*lines, Line{Spans: []Span{{Text: text, Style: term.StyleMuted}}}) + } + + default: + // For unknown block nodes, try walking children + if node.HasChildren() { + walkChildren(node, source, maxWidth, lines, depth, inBlockquote, inList) + } + } +} + +func walkChildren(node ast.Node, source []byte, maxWidth int, lines *[]Line, depth int, inBlockquote bool, inList bool) { + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + walkNode(child, source, maxWidth, lines, depth, inBlockquote, inList) + } +} + +func renderListItem(item *ast.ListItem, source []byte, maxWidth int, lines *[]Line, bullet string, depth int) { + bulletLen := len([]rune(bullet)) + indent := strings.Repeat(" ", bulletLen) + + // Collect inline content from the first paragraph (or text block) of the list item + first := true + for child := item.FirstChild(); child != nil; child = child.NextSibling() { + switch c := child.(type) { + case *ast.Paragraph, *ast.TextBlock: + var spans []Span + collectInlineSpans(c, source, &spans, term.StyleDefault) + if len(spans) == 0 { + continue + } + line := Line{Spans: spans} + wrapped := wrapLine(line, maxWidth-bulletLen) + for i, w := range wrapped { + var prefix string + if first && i == 0 { + prefix = bullet + } else { + prefix = indent + } + prefixed := Line{Spans: append([]Span{{Text: prefix, Style: term.StyleDefault}}, w.Spans...)} + *lines = append(*lines, prefixed) + } + first = false + case *ast.List: + // Nested list + walkNode(c, source, maxWidth-bulletLen, lines, depth+1, false, true) + default: + walkNode(c, source, maxWidth, lines, depth+1, false, true) + } + } +} + +func collectInlineSpans(node ast.Node, source []byte, spans *[]Span, defaultStyle term.Style) { + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + collectInlineNode(child, source, spans, defaultStyle) + } +} + +func collectInlineNode(node ast.Node, source []byte, spans *[]Span, defaultStyle term.Style) { + switch n := node.(type) { + case *ast.Text: + text := string(n.Segment.Value(source)) + if len(text) > 0 { + *spans = append(*spans, Span{Text: text, Style: defaultStyle}) + } + if n.SoftLineBreak() { + *spans = append(*spans, Span{Text: " ", Style: defaultStyle}) + } + if n.HardLineBreak() { + *spans = append(*spans, Span{Text: " ", Style: defaultStyle}) + } + + case *ast.String: + text := string(n.Value) + if len(text) > 0 { + *spans = append(*spans, Span{Text: text, Style: defaultStyle}) + } + + case *ast.CodeSpan: + var buf bytes.Buffer + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + if t, ok := child.(*ast.Text); ok { + buf.Write(t.Segment.Value(source)) + } + } + *spans = append(*spans, Span{Text: buf.String(), Style: term.StyleHoverCode}) + + case *ast.Emphasis: + style := term.StyleHoverBold + collectInlineSpans(n, source, spans, style) + + case *ast.Link: + // Render link text then URL in muted style + collectInlineSpans(n, source, spans, defaultStyle) + url := string(n.Destination) + if url != "" { + *spans = append(*spans, Span{Text: " (" + url + ")", Style: term.StyleMuted}) + } + + case *ast.Image: + // Show alt text + *spans = append(*spans, Span{Text: "[image: ", Style: term.StyleMuted}) + collectInlineSpans(n, source, spans, term.StyleMuted) + *spans = append(*spans, Span{Text: "]", Style: term.StyleMuted}) + + case *ast.AutoLink: + url := string(n.URL(source)) + *spans = append(*spans, Span{Text: url, Style: term.StyleMuted}) + + case *ast.RawHTML: + var buf bytes.Buffer + for i := 0; i < n.Segments.Len(); i++ { + seg := n.Segments.At(i) + buf.Write(seg.Value(source)) + } + *spans = append(*spans, Span{Text: buf.String(), Style: term.StyleMuted}) + + default: + // For unknown inline nodes with children, recurse + if node.HasChildren() { + collectInlineSpans(node, source, spans, defaultStyle) + } + } +} + +func renderCodeBlockPreview(block []string, lang string) []Line { + var h *highlight.Highlighter + if lang != "" { + h = highlight.New("file." + lang) + } + lines := make([]Line, len(block)) + for i, text := range block { + if h != nil { + spans := h.HighlightLine(text) + lines[i] = highlightToLine(text, spans) + } else { + lines[i] = Line{Spans: []Span{{Text: text, Style: term.StyleHoverCode}}} + } + } + return lines +} + +func prependBlockquote(line Line) Line { + prefix := Span{Text: " │ ", Style: term.StyleMuted} + newSpans := make([]Span, 0, len(line.Spans)+1) + newSpans = append(newSpans, prefix) + // Apply muted style to all spans in blockquote + for _, s := range line.Spans { + newSpans = append(newSpans, Span{Text: s.Text, Style: term.StyleMuted}) + } + return Line{Spans: newSpans} +} + +func addBlankLine(lines *[]Line) { + *lines = append(*lines, Line{Spans: []Span{{Text: "", Style: term.StyleDefault}}}) +} + +func addBlankLineIfNeeded(lines *[]Line) { + if len(*lines) == 0 { + return + } + last := (*lines)[len(*lines)-1] + if last.Text() != "" { + addBlankLine(lines) + } +} + diff --git a/internal/markdown/preview_test.go b/internal/markdown/preview_test.go new file mode 100644 index 00000000..9bee38f8 --- /dev/null +++ b/internal/markdown/preview_test.go @@ -0,0 +1,244 @@ +package markdown + +import ( + "strings" + "testing" + + "github.com/eugenioenko/ttt/internal/term" +) + +func TestRenderPreviewHeading(t *testing.T) { + lines := RenderPreview("# Hello World", 80) + // Should have blank line, heading line, blank line + found := false + for _, l := range lines { + text := l.Text() + if strings.Contains(text, "# Hello World") { + found = true + // Check that heading spans use StyleHoverBold + for _, s := range l.Spans { + if s.Style != term.StyleHoverBold { + t.Errorf("expected StyleHoverBold for heading span %q, got %d", s.Text, s.Style) + } + } + } + } + if !found { + t.Error("expected heading text '# Hello World' in output") + } +} + +func TestRenderPreviewH2(t *testing.T) { + lines := RenderPreview("## Subtitle", 80) + found := false + for _, l := range lines { + if strings.Contains(l.Text(), "## Subtitle") { + found = true + } + } + if !found { + t.Error("expected '## Subtitle' in output") + } +} + +func TestRenderPreviewBold(t *testing.T) { + lines := RenderPreview("this is **bold** text", 80) + found := false + for _, l := range lines { + for _, s := range l.Spans { + if s.Text == "bold" && s.Style == term.StyleHoverBold { + found = true + } + } + } + if !found { + t.Error("expected bold span with StyleHoverBold") + } +} + +func TestRenderPreviewItalic(t *testing.T) { + lines := RenderPreview("this is *italic* text", 80) + found := false + for _, l := range lines { + for _, s := range l.Spans { + if s.Text == "italic" && s.Style == term.StyleHoverBold { + found = true + } + } + } + if !found { + t.Error("expected italic span with StyleHoverBold") + } +} + +func TestRenderPreviewInlineCode(t *testing.T) { + lines := RenderPreview("use `fmt.Println` here", 80) + found := false + for _, l := range lines { + for _, s := range l.Spans { + if s.Text == "fmt.Println" && s.Style == term.StyleHoverCode { + found = true + } + } + } + if !found { + t.Error("expected inline code span with StyleHoverCode") + } +} + +func TestRenderPreviewCodeBlock(t *testing.T) { + input := "before\n\n```go\nfunc main() {}\n```\n\nafter" + lines := RenderPreview(input, 80) + foundCode := false + for _, l := range lines { + if strings.Contains(l.Text(), "func main()") { + foundCode = true + } + } + if !foundCode { + t.Error("expected code block content 'func main() {}' in output") + } +} + +func TestRenderPreviewUnorderedList(t *testing.T) { + input := "- item one\n- item two\n- item three" + lines := RenderPreview(input, 80) + foundBullet := false + for _, l := range lines { + text := l.Text() + if strings.Contains(text, "•") && strings.Contains(text, "item one") { + foundBullet = true + } + } + if !foundBullet { + t.Error("expected bullet character in unordered list") + } +} + +func TestRenderPreviewOrderedList(t *testing.T) { + input := "1. first\n2. second\n3. third" + lines := RenderPreview(input, 80) + foundOrdered := false + for _, l := range lines { + text := l.Text() + if strings.Contains(text, "1.") && strings.Contains(text, "first") { + foundOrdered = true + } + } + if !foundOrdered { + t.Error("expected numbered item in ordered list") + } +} + +func TestRenderPreviewBlockquote(t *testing.T) { + input := "> This is a quote" + lines := RenderPreview(input, 80) + foundQuote := false + for _, l := range lines { + text := l.Text() + if strings.Contains(text, "│") && strings.Contains(text, "This is a quote") { + foundQuote = true + // All spans in blockquote should be muted + for _, s := range l.Spans { + if s.Style != term.StyleMuted { + t.Errorf("expected StyleMuted for blockquote span %q, got %d", s.Text, s.Style) + } + } + } + } + if !foundQuote { + t.Error("expected blockquote with '│' prefix") + } +} + +func TestRenderPreviewHorizontalRule(t *testing.T) { + input := "above\n\n---\n\nbelow" + lines := RenderPreview(input, 80) + foundRule := false + for _, l := range lines { + text := l.Text() + if strings.Contains(text, "────") { + foundRule = true + for _, s := range l.Spans { + if strings.Contains(s.Text, "─") && s.Style != term.StyleMuted { + t.Errorf("expected StyleMuted for horizontal rule, got %d", s.Style) + } + } + } + } + if !foundRule { + t.Error("expected horizontal rule with '─' characters") + } +} + +func TestRenderPreviewLink(t *testing.T) { + input := "see [docs](https://example.com) here" + lines := RenderPreview(input, 80) + foundLink := false + foundURL := false + for _, l := range lines { + for _, s := range l.Spans { + if s.Text == "docs" { + foundLink = true + } + if strings.Contains(s.Text, "https://example.com") && s.Style == term.StyleMuted { + foundURL = true + } + } + } + if !foundLink { + t.Error("expected link text 'docs' in output") + } + if !foundURL { + t.Error("expected URL in muted style") + } +} + +func TestRenderPreviewWordWrap(t *testing.T) { + long := strings.Repeat("word ", 30) // 150 chars + lines := RenderPreview(long, 80) + if len(lines) < 2 { + t.Errorf("expected word wrapping to produce multiple lines, got %d", len(lines)) + } + // Each line should be at most 80 runes + for i, l := range lines { + text := l.Text() + if len([]rune(text)) > 80 { + t.Errorf("line %d exceeds 80 chars: %d", i, len([]rune(text))) + } + } +} + +func TestRenderPreviewEmpty(t *testing.T) { + lines := RenderPreview("", 80) + // Should produce at least an empty line, not panic + if lines == nil { + t.Error("expected non-nil result for empty input") + } +} + +func TestRenderPreviewMultipleParagraphs(t *testing.T) { + input := "First paragraph.\n\nSecond paragraph." + lines := RenderPreview(input, 80) + foundFirst := false + foundSecond := false + for _, l := range lines { + text := l.Text() + if strings.Contains(text, "First paragraph") { + foundFirst = true + } + if strings.Contains(text, "Second paragraph") { + foundSecond = true + } + } + if !foundFirst { + t.Error("expected first paragraph") + } + if !foundSecond { + t.Error("expected second paragraph") + } + // There should be blank lines separating them + if len(lines) < 3 { + t.Errorf("expected at least 3 lines (2 paragraphs + separator), got %d", len(lines)) + } +} diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index 087fae8b..a6374e3a 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -250,6 +250,24 @@ func (g *EditorGroupWidget) OpenDiff(path string, fd diff.FileDiff, oldLines, ne g.SwitchTab(len(g.tabs) - 1) } +func (g *EditorGroupWidget) OpenPreview(path string, content string) { + tabName := "Preview: " + filepath.Base(path) + for i, t := range g.tabs { + if t.FilePath == tabName { + t.Content = NewMarkdownPreviewWidget(path, content) + g.tabs[i] = t + g.SwitchTab(i) + return + } + } + widget := NewMarkdownPreviewWidget(path, content) + g.tabs = append(g.tabs, editorTab{ + FilePath: tabName, + Content: widget, + }) + g.SwitchTab(len(g.tabs) - 1) +} + func (g *EditorGroupWidget) ReloadFile(path string) { for i := range g.tabs { if g.tabs[i].FilePath == path && g.tabs[i].Buf != nil { @@ -506,6 +524,15 @@ func (g *EditorGroupWidget) ActiveFilePath() string { return "" } +// TabNames returns the file path (display name) of all open tabs. +func (g *EditorGroupWidget) TabNames() []string { + names := make([]string, len(g.tabs)) + for i, t := range g.tabs { + names[i] = t.FilePath + } + return names +} + // ActiveBuffer returns the buffer backing the active tab, or nil if the active // tab is not a text buffer (e.g. a diff view). func (g *EditorGroupWidget) ActiveBuffer() *buffer.Buffer { diff --git a/internal/ui/markdown_preview.go b/internal/ui/markdown_preview.go new file mode 100644 index 00000000..b3f00940 --- /dev/null +++ b/internal/ui/markdown_preview.go @@ -0,0 +1,128 @@ +package ui + +import ( + "github.com/eugenioenko/ttt/internal/markdown" + "github.com/eugenioenko/ttt/internal/term" + + "github.com/gdamore/tcell/v2" +) + +const markdownMaxWidth = 80 + +// MarkdownPreviewWidget renders parsed markdown content as styled text +// in a read-only, scrollable preview tab. +type MarkdownPreviewWidget struct { + BaseWidget + FilePath string + lines []markdown.Line + topLine int + viewH int +} + +// NewMarkdownPreviewWidget creates a preview widget from the given markdown content. +func NewMarkdownPreviewWidget(path string, content string) *MarkdownPreviewWidget { + lines := markdown.RenderPreview(content, markdownMaxWidth) + return &MarkdownPreviewWidget{ + FilePath: path, + lines: lines, + } +} + +func (m *MarkdownPreviewWidget) Focusable() bool { return true } + +func (m *MarkdownPreviewWidget) Render(surface *RenderSurface) { + w, h := surface.Size() + m.viewH = h + + // Calculate left padding to center content if viewport is wider than maxWidth + padding := 0 + if w > markdownMaxWidth { + padding = (w - markdownMaxWidth) / 2 + } + + // Clear the surface + surface.Fill(term.Cell{Ch: ' ', Style: term.StyleDefault}) + + for y := 0; y < h; y++ { + idx := m.topLine + y + if idx >= len(m.lines) { + break + } + line := m.lines[idx] + x := padding + for _, span := range line.Spans { + for _, ch := range span.Text { + if x >= w { + break + } + surface.SetCell(x, y, term.Cell{Ch: ch, Style: span.Style}) + x++ + } + } + } +} + +func (m *MarkdownPreviewWidget) HandleEvent(ev tcell.Event) EventResult { + switch tev := ev.(type) { + case *tcell.EventKey: + switch tev.Key() { + case tcell.KeyUp: + if m.topLine > 0 { + m.topLine-- + } + return EventConsumed + case tcell.KeyDown: + max := m.maxTop() + if m.topLine < max { + m.topLine++ + } + return EventConsumed + case tcell.KeyPgUp: + m.topLine -= m.viewH + if m.topLine < 0 { + m.topLine = 0 + } + return EventConsumed + case tcell.KeyPgDn: + max := m.maxTop() + m.topLine += m.viewH + if m.topLine > max { + m.topLine = max + } + return EventConsumed + case tcell.KeyHome: + m.topLine = 0 + return EventConsumed + case tcell.KeyEnd: + m.topLine = m.maxTop() + return EventConsumed + } + + case *tcell.EventMouse: + btn := tev.Buttons() + if btn&tcell.WheelUp != 0 { + m.topLine -= 3 + if m.topLine < 0 { + m.topLine = 0 + } + return EventConsumed + } + if btn&tcell.WheelDown != 0 { + max := m.maxTop() + m.topLine += 3 + if m.topLine > max { + m.topLine = max + } + return EventConsumed + } + } + return EventIgnored +} + +func (m *MarkdownPreviewWidget) maxTop() int { + max := len(m.lines) - m.viewH + if max < 0 { + max = 0 + } + return max +} diff --git a/tests/e2e/markdown_preview_test.go b/tests/e2e/markdown_preview_test.go new file mode 100644 index 00000000..87750d5c --- /dev/null +++ b/tests/e2e/markdown_preview_test.go @@ -0,0 +1,101 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMarkdownPreview_OpenViaCommand(t *testing.T) { + h := newTestHarness(t, 80, 30) + defer h.stop() + + f := filepath.Join(h.dir, "readme.md") + os.WriteFile(f, []byte("# Hello World\n\nSome text here.\n"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + h.exec("editor.openPreview") + h.redraw() + + // Tab name should be "Preview: readme.md" + h.assertContains("Preview: readme.md") +} + +func TestMarkdownPreview_TabDedup(t *testing.T) { + h := newTestHarness(t, 80, 30) + defer h.stop() + + f := filepath.Join(h.dir, "readme.md") + os.WriteFile(f, []byte("# Hello\n"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + h.exec("editor.openPreview") + h.redraw() + + // Open preview again — should switch to existing tab, not create a new one + h.app.EditorGroup.OpenFile(f) + h.redraw() + h.exec("editor.openPreview") + h.redraw() + + // Count tabs with "Preview:" prefix — there should be exactly one + count := 0 + for _, name := range h.app.EditorGroup.TabNames() { + if name == "Preview: readme.md" { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 preview tab, got %d", count) + } +} + +func TestMarkdownPreview_HeadingRendered(t *testing.T) { + h := newTestHarness(t, 80, 30) + defer h.stop() + + f := filepath.Join(h.dir, "test.md") + os.WriteFile(f, []byte("# My Title\n\nSome content.\n"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + h.exec("editor.openPreview") + h.redraw() + + // The rendered preview should show the heading text + h.assertContains("# My Title") +} + +func TestMarkdownPreview_NonMdFile(t *testing.T) { + h := newTestHarness(t, 80, 30) + defer h.stop() + + f := filepath.Join(h.dir, "code.go") + os.WriteFile(f, []byte("package main\n"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + h.exec("editor.openPreview") + h.redraw() + + // Should NOT open a preview tab + h.assertNotContains("Preview:") +} + +func TestMarkdownPreview_ListRendered(t *testing.T) { + h := newTestHarness(t, 80, 30) + defer h.stop() + + f := filepath.Join(h.dir, "list.md") + os.WriteFile(f, []byte("- item one\n- item two\n"), 0644) + h.app.EditorGroup.OpenFile(f) + h.redraw() + + h.exec("editor.openPreview") + h.redraw() + + h.assertContains("item one") + h.assertContains("item two") +} diff --git a/tests/functional/markdown-preview.test.js b/tests/functional/markdown-preview.test.js new file mode 100644 index 00000000..4446ebf7 --- /dev/null +++ b/tests/functional/markdown-preview.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as tui from "./tui.js"; +import { createTempDir, createTempFile, cleanupDir } from "./helpers.js"; + +let dir; + +afterEach(() => { + tui.kill(); + if (dir) cleanupDir(dir); +}); + +describe("markdown preview", () => { + it("should open preview for a .md file", () => { + dir = createTempDir(); + const file = createTempFile( + dir, + "readme.md", + "# Hello World\n\nSome text here.\n\n- item one\n- item two\n" + ); + + tui.start(file); + tui.waitFor("Hello World"); + + tui.exec("Open Preview"); + tui.waitStable(); + + const snap = tui.snapshot(); + expect(snap).toContain("Preview: readme.md"); + expect(snap).toContain("Hello World"); + }); + + it("should render list bullets in preview", () => { + dir = createTempDir(); + const file = createTempFile( + dir, + "list.md", + "- alpha\n- beta\n- gamma\n" + ); + + tui.start(file); + tui.waitFor("alpha"); + + tui.exec("Open Preview"); + tui.waitStable(); + + const snap = tui.snapshot(); + expect(snap).toContain("alpha"); + expect(snap).toContain("beta"); + expect(snap).toContain("gamma"); + }); + + it("should not open preview for non-md files", () => { + dir = createTempDir(); + const file = createTempFile(dir, "code.go", "package main\n"); + + tui.start(file); + tui.waitFor("package"); + + tui.exec("Open Preview"); + tui.waitStable(); + + const snap = tui.snapshot(); + expect(snap).not.toContain("Preview:"); + }); +});