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:"); + }); +});