From 98fdd5fdd138c7c7118e9577c10d84d0e1e8c70d Mon Sep 17 00:00:00 2001 From: makingmusic Date: Sat, 14 Feb 2026 09:51:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20pager=20navigation=20=E2=80=94=20?= =?UTF-8?q?search,=20jump-to-line,=20page=20indicator,=20arrow=20key=20pag?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add several navigation features to the pager view: - In-document search (/ to search, n/N for next/prev match) - Jump to line (:42) or percentage (:50%) - Page indicator (pg X/Y) in status bar when content exceeds one page - Left/right arrow keys for page back/forward - Comprehensive test suite covering utils, UI logic, and E2E CLI tests Co-Authored-By: Claude Opus 4.6 --- tests/e2e_test.go | 98 +++++++++ tests/testdata/test.md | 23 +++ ui/markdown_test.go | 114 +++++++++++ ui/pager.go | 403 ++++++++++++++++++++++++++++++------ ui/pager_test.go | 453 +++++++++++++++++++++++++++++++++++++++++ ui/sort_test.go | 61 ++++++ ui/stash_test.go | 311 ++++++++++++++++++++++++++++ ui/ui.go | 12 +- utils/utils_test.go | 199 ++++++++++++++++++ 9 files changed, 1611 insertions(+), 63 deletions(-) create mode 100644 tests/e2e_test.go create mode 100644 tests/testdata/test.md create mode 100644 ui/markdown_test.go create mode 100644 ui/pager_test.go create mode 100644 ui/sort_test.go create mode 100644 ui/stash_test.go create mode 100644 utils/utils_test.go diff --git a/tests/e2e_test.go b/tests/e2e_test.go new file mode 100644 index 0000000..988e664 --- /dev/null +++ b/tests/e2e_test.go @@ -0,0 +1,98 @@ +package tests + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var glowBin string + +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "glow-e2e-*") + if err != nil { + panic("failed to create temp dir: " + err.Error()) + } + defer os.RemoveAll(tmp) + + glowBin = filepath.Join(tmp, "glow-test") + cmd := exec.Command("go", "build", "-o", glowBin, "..") + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + panic("failed to build glow: " + err.Error()) + } + + os.Exit(m.Run()) +} + +func TestRenderMarkdownFile(t *testing.T) { + out, err := exec.Command(glowBin, "testdata/test.md").CombinedOutput() + if err != nil { + t.Fatalf("glow testdata/test.md failed: %v\n%s", err, out) + } + if len(out) == 0 { + t.Error("expected non-empty output") + } +} + +func TestRenderWithStyle(t *testing.T) { + out, err := exec.Command(glowBin, "-s", "dark", "testdata/test.md").CombinedOutput() + if err != nil { + t.Fatalf("glow -s dark failed: %v\n%s", err, out) + } + if len(out) == 0 { + t.Error("expected non-empty output") + } +} + +func TestRenderWithWidth(t *testing.T) { + out, err := exec.Command(glowBin, "-w", "40", "testdata/test.md").CombinedOutput() + if err != nil { + t.Fatalf("glow -w 40 failed: %v\n%s", err, out) + } + if len(out) == 0 { + t.Error("expected non-empty output") + } +} + +func TestRenderWithLineNumbers(t *testing.T) { + out, err := exec.Command(glowBin, "-l", "testdata/test.md").CombinedOutput() + if err != nil { + t.Fatalf("glow -l failed: %v\n%s", err, out) + } + if len(out) == 0 { + t.Error("expected non-empty output") + } +} + +func TestStdinPipe(t *testing.T) { + cmd := exec.Command(glowBin) + cmd.Stdin = strings.NewReader("# Hello\n\nWorld") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("echo | glow failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "Hello") { + t.Errorf("expected output to contain 'Hello', got: %s", out) + } +} + +func TestInvalidFile(t *testing.T) { + err := exec.Command(glowBin, "nonexistent.md").Run() + if err == nil { + t.Error("expected non-zero exit for nonexistent file") + } +} + +func TestHelpFlag(t *testing.T) { + out, err := exec.Command(glowBin, "--help").CombinedOutput() + if err != nil { + t.Fatalf("glow --help failed: %v\n%s", err, out) + } + output := string(out) + if !strings.Contains(strings.ToLower(output), "glow") { + t.Errorf("expected help output to contain 'glow', got: %s", output) + } +} diff --git a/tests/testdata/test.md b/tests/testdata/test.md new file mode 100644 index 0000000..d737879 --- /dev/null +++ b/tests/testdata/test.md @@ -0,0 +1,23 @@ +# Test Document + +This is a **bold** and *italic* test document. + +## Features + +- Item one +- Item two +- Item three + +## Code Example + +```go +package main + +import "fmt" + +func main() { + fmt.Println("Hello, world!") +} +``` + +That's all folks. diff --git a/ui/markdown_test.go b/ui/markdown_test.go new file mode 100644 index 0000000..99a7b44 --- /dev/null +++ b/ui/markdown_test.go @@ -0,0 +1,114 @@ +package ui + +import ( + "testing" + "time" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"diacritics cafe", "café", "cafe", false}, + {"diacritics naive", "naïve", "naive", false}, + {"diacritics Munchen", "München", "Munchen", false}, + {"ASCII unchanged", "hello world", "hello world", false}, + {"empty string", "", "", false}, + {"mixed diacritics", "résumé.md", "resume.md", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalize(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("normalize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("normalize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestRelativeTime(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + when time.Time + want string + }{ + { + name: "just now", + when: now.Add(-10 * time.Second), + want: "just now", + }, + { + name: "minutes ago", + when: now.Add(-5 * time.Minute), + want: "5 minutes ago", + }, + { + name: "hours ago", + when: now.Add(-3 * time.Hour), + want: "3 hours ago", + }, + { + name: "days ago", + when: now.Add(-2 * 24 * time.Hour), + want: "2 days ago", + }, + { + name: "old date uses formatted date", + when: time.Date(2020, 1, 15, 10, 30, 0, 0, time.UTC), + want: "15 Jan 2020 10:30 UTC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := relativeTime(tt.when) + if got != tt.want { + t.Errorf("relativeTime() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildFilterValue(t *testing.T) { + tests := []struct { + name string + note string + want string + }{ + { + name: "plain text", + note: "readme", + want: "readme", + }, + { + name: "diacritics stripped", + note: "café résumé", + want: "cafe resume", + }, + { + name: "empty note", + note: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + md := &markdown{Note: tt.note} + md.buildFilterValue() + if md.filterValue != tt.want { + t.Errorf("buildFilterValue() filterValue = %q, want %q", md.filterValue, tt.want) + } + }) + } +} diff --git a/ui/pager.go b/ui/pager.go index 5c522e7..38fbe17 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -4,10 +4,12 @@ import ( "fmt" "math" "path/filepath" + "strconv" "strings" "time" "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" @@ -87,6 +89,8 @@ type pagerState int const ( pagerStateBrowse pagerState = iota pagerStateStatusMessage + pagerStateSearch + pagerStateJumpToLine ) type pagerModel struct { @@ -102,6 +106,15 @@ type pagerModel struct { // it here so we can re-render it on resize. currentDocument markdown + // Search + searchInput textinput.Model + searchQuery string // active search term (persists after input is confirmed) + searchMatches []int // line numbers with matches (0-indexed into raw Body lines) + searchIndex int // current match index (-1 = none) + + // Jump to line + lineInput textinput.Model + watcher *fsnotify.Watcher } @@ -111,10 +124,23 @@ func newPagerModel(common *commonModel) pagerModel { vp.YPosition = 0 vp.HighPerformanceRendering = config.HighPerformancePager + si := textinput.New() + si.Prompt = "/" + si.PromptStyle = lipgloss.NewStyle().Foreground(yellowGreen) + si.Focus() + + li := textinput.New() + li.Prompt = ":" + li.PromptStyle = lipgloss.NewStyle().Foreground(yellowGreen) + li.Focus() + m := pagerModel{ - common: common, - state: pagerStateBrowse, - viewport: vp, + common: common, + state: pagerStateBrowse, + viewport: vp, + searchInput: si, + searchIndex: -1, + lineInput: li, } m.initWatcher() return m @@ -164,6 +190,21 @@ func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd { return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer) } +// inInputMode returns true when the pager is in a state that consumes +// arbitrary key input (search prompt, jump prompt) or has active search +// results that esc should clear before unloading the document. +func (m pagerModel) inInputMode() bool { + return m.state == pagerStateSearch || + m.state == pagerStateJumpToLine || + m.searchQuery != "" +} + +func (m *pagerModel) clearSearch() { + m.searchQuery = "" + m.searchMatches = nil + m.searchIndex = -1 +} + func (m *pagerModel) unload() { log.Debug("unload") if m.showHelp { @@ -173,11 +214,26 @@ func (m *pagerModel) unload() { m.statusMessageTimer.Stop() } m.state = pagerStateBrowse + m.clearSearch() m.viewport.SetContent("") m.viewport.YOffset = 0 m.unwatchFile() } +// findMatches finds all line numbers in content that contain the query +// (case-insensitive). Returns 0-indexed line numbers. +func findMatches(content string, query string) []int { + lines := strings.Split(content, "\n") + lowerQuery := strings.ToLower(query) + var matches []int + for i, line := range lines { + if strings.Contains(strings.ToLower(line), lowerQuery) { + matches = append(matches, i) + } + } + return matches +} + func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { var ( cmd tea.Cmd @@ -186,62 +242,22 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "q", keyEsc: - if m.state != pagerStateBrowse { - m.state = pagerStateBrowse - return m, nil - } - case "home", "g": - m.viewport.GotoTop() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } - case "end", "G": - m.viewport.GotoBottom() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } + switch m.state { + case pagerStateSearch: + cmds = append(cmds, m.handleSearchInput(msg)) + return m, tea.Batch(cmds...) - case "d": - m.viewport.HalfViewDown() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } + case pagerStateJumpToLine: + cmds = append(cmds, m.handleJumpInput(msg)) + return m, tea.Batch(cmds...) - case "u": - m.viewport.HalfViewUp() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } + case pagerStateStatusMessage: + // Any key returns to browse + m.state = pagerStateBrowse + return m, nil - case "e": - lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent())) - if m.viewport.AtTop() { - lineno = 0 - } - log.Info( - "opening editor", - "file", m.currentDocument.localPath, - "line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()), - ) - return m, openEditor(m.currentDocument.localPath, lineno) - - case "c": - // Copy using OSC 52 - termenv.Copy(m.currentDocument.Body) - // Copy using native system clipboard - _ = clipboard.WriteAll(m.currentDocument.Body) - cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false})) - - case "r": - return m, loadLocalMarkdown(&m.currentDocument) - - case "?": - m.toggleHelp() - if m.viewport.HighPerformanceRendering { - cmds = append(cmds, viewport.Sync(m.viewport)) - } + case pagerStateBrowse: + cmds = append(cmds, m.handleBrowseKeys(msg)) } // Glow has rendered the content @@ -270,7 +286,11 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { return m, renderWithGlamour(m, m.currentDocument.Body) case statusMessageTimeoutMsg: - m.state = pagerStateBrowse + // Only transition to browse if we're actually showing a status message. + // Ignore if in search/jump input mode. + if m.state == pagerStateStatusMessage { + m.state = pagerStateBrowse + } } m.viewport, cmd = m.viewport.Update(msg) @@ -279,6 +299,207 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *pagerModel) handleBrowseKeys(msg tea.KeyMsg) tea.Cmd { + var cmds []tea.Cmd + + switch msg.String() { + case keyEsc: + // If search results are active, clear them + if m.searchQuery != "" { + m.clearSearch() + return nil + } + + case "home", "g": + m.viewport.GotoTop() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + case "end", "G": + m.viewport.GotoBottom() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "d": + m.viewport.HalfViewDown() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "u": + m.viewport.HalfViewUp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "right": + m.viewport.ViewDown() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "left": + m.viewport.ViewUp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "e": + lineno := int(math.RoundToEven(float64(m.viewport.TotalLineCount()) * m.viewport.ScrollPercent())) + if m.viewport.AtTop() { + lineno = 0 + } + log.Info( + "opening editor", + "file", m.currentDocument.localPath, + "line", fmt.Sprintf("%d/%d", lineno, m.viewport.TotalLineCount()), + ) + return openEditor(m.currentDocument.localPath, lineno) + + case "c": + // Copy using OSC 52 + termenv.Copy(m.currentDocument.Body) + // Copy using native system clipboard + _ = clipboard.WriteAll(m.currentDocument.Body) + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false})) + + case "r": + return loadLocalMarkdown(&m.currentDocument) + + case "?": + m.toggleHelp() + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + + case "/": + m.state = pagerStateSearch + m.searchInput.SetValue("") + m.searchInput.Focus() + return textinput.Blink + + case ":": + m.state = pagerStateJumpToLine + m.lineInput.SetValue("") + m.lineInput.Focus() + return textinput.Blink + + case "n": + if m.searchQuery != "" && len(m.searchMatches) > 0 { + m.searchIndex++ + if m.searchIndex >= len(m.searchMatches) { + m.searchIndex = 0 + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"search wrapped", false})) + } + m.viewport.SetYOffset(m.searchMatches[m.searchIndex]) + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + + case "N": + if m.searchQuery != "" && len(m.searchMatches) > 0 { + m.searchIndex-- + if m.searchIndex < 0 { + m.searchIndex = len(m.searchMatches) - 1 + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"search wrapped", false})) + } + m.viewport.SetYOffset(m.searchMatches[m.searchIndex]) + if m.viewport.HighPerformanceRendering { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + } + + return tea.Batch(cmds...) +} + +func (m *pagerModel) handleSearchInput(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case keyEnter: + query := m.searchInput.Value() + if query == "" { + m.state = pagerStateBrowse + return m.showStatusMessage(pagerStatusMessage{"no pattern", false}) + } + m.searchQuery = query + m.searchMatches = findMatches(m.currentDocument.Body, query) + if len(m.searchMatches) == 0 { + m.searchQuery = "" + m.state = pagerStateBrowse + return m.showStatusMessage(pagerStatusMessage{"no matches", false}) + } + m.searchIndex = 0 + m.viewport.SetYOffset(m.searchMatches[0]) + m.state = pagerStateBrowse + if m.viewport.HighPerformanceRendering { + return viewport.Sync(m.viewport) + } + return nil + + case keyEsc: + m.clearSearch() + m.state = pagerStateBrowse + return nil + } + + // Delegate to the text input + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return cmd +} + +func (m *pagerModel) handleJumpInput(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case keyEnter: + input := m.lineInput.Value() + m.state = pagerStateBrowse + if input == "" { + return nil + } + + // Check for percentage jump (e.g., "50%") + if strings.HasSuffix(input, "%") { + pct, err := strconv.Atoi(strings.TrimSuffix(input, "%")) + if err != nil { + return m.showStatusMessage(pagerStatusMessage{"invalid number", false}) + } + pct = max(0, min(100, pct)) + if pct == 0 { + m.viewport.GotoTop() + } else if pct == 100 { + m.viewport.GotoBottom() + } else { + totalLines := m.viewport.TotalLineCount() + target := int(math.Round(float64(totalLines) * float64(pct) / 100)) + m.viewport.SetYOffset(target) + } + } else { + n, err := strconv.Atoi(input) + if err != nil { + return m.showStatusMessage(pagerStatusMessage{"invalid line number", false}) + } + n = max(1, min(n, m.viewport.TotalLineCount())) + m.viewport.SetYOffset(n - 1) // convert 1-indexed to 0-indexed + } + + if m.viewport.HighPerformanceRendering { + return viewport.Sync(m.viewport) + } + return nil + + case keyEsc: + m.state = pagerStateBrowse + return nil + } + + // Delegate to the text input + var cmd tea.Cmd + m.lineInput, cmd = m.lineInput.Update(msg) + return cmd +} + func (m pagerModel) View() string { var b strings.Builder fmt.Fprint(&b, m.viewport.View()+"\n") @@ -294,6 +515,26 @@ func (m pagerModel) View() string { } func (m pagerModel) statusBarView(b *strings.Builder) { + // When in search or jump input mode, replace the entire status bar + // with the input prompt (like less/vim). + if m.state == pagerStateSearch { + fmt.Fprint(b, m.searchInput.View()) + // Pad to full width + inputWidth := ansi.PrintableRuneWidth(m.searchInput.View()) + if pad := m.common.width - inputWidth; pad > 0 { + fmt.Fprint(b, statusBarNoteStyle(strings.Repeat(" ", pad))) + } + return + } + if m.state == pagerStateJumpToLine { + fmt.Fprint(b, m.lineInput.View()) + inputWidth := ansi.PrintableRuneWidth(m.lineInput.View()) + if pad := m.common.width - inputWidth; pad > 0 { + fmt.Fprint(b, statusBarNoteStyle(strings.Repeat(" ", pad))) + } + return + } + const ( minPercent float64 = 0.0 maxPercent float64 = 1.0 @@ -305,6 +546,32 @@ func (m pagerModel) statusBarView(b *strings.Builder) { // Logo logo := glowLogoView() + // Page indicator + var pageIndicator string + viewHeight := max(1, m.viewport.Height) + currentPage := m.viewport.YOffset/viewHeight + 1 + totalPages := (m.viewport.TotalLineCount() + viewHeight - 1) / viewHeight + currentPage = min(currentPage, totalPages) + if totalPages > 1 { + pageIndicator = fmt.Sprintf(" pg %d/%d ", currentPage, totalPages) + } + if showStatusMessage { + pageIndicator = statusBarMessageScrollPosStyle(pageIndicator) + } else { + pageIndicator = statusBarScrollPosStyle(pageIndicator) + } + + // Match counter (when search results are active) + var matchCounter string + if m.searchQuery != "" && len(m.searchMatches) > 0 { + matchCounter = fmt.Sprintf(" %d/%d ", m.searchIndex+1, len(m.searchMatches)) + } + if showStatusMessage { + matchCounter = statusBarMessageScrollPosStyle(matchCounter) + } else { + matchCounter = statusBarScrollPosStyle(matchCounter) + } + // Scroll percent percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent())) scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude) @@ -332,6 +599,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) { note = truncate.StringWithTail(" "+note+" ", uint(max(0, //nolint:gosec m.common.width- ansi.PrintableRuneWidth(logo)- + ansi.PrintableRuneWidth(matchCounter)- + ansi.PrintableRuneWidth(pageIndicator)- ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(helpNote), )), ellipsis) @@ -346,6 +615,8 @@ func (m pagerModel) statusBarView(b *strings.Builder) { m.common.width- ansi.PrintableRuneWidth(logo)- ansi.PrintableRuneWidth(note)- + ansi.PrintableRuneWidth(matchCounter)- + ansi.PrintableRuneWidth(pageIndicator)- ansi.PrintableRuneWidth(scrollPercent)- ansi.PrintableRuneWidth(helpNote), ) @@ -356,10 +627,12 @@ func (m pagerModel) statusBarView(b *strings.Builder) { emptySpace = statusBarNoteStyle(emptySpace) } - fmt.Fprintf(b, "%s%s%s%s%s", + fmt.Fprintf(b, "%s%s%s%s%s%s%s", logo, note, emptySpace, + matchCounter, + pageIndicator, scrollPercent, helpNote, ) @@ -369,6 +642,9 @@ func (m pagerModel) helpView() (s string) { col1 := []string{ "g/home go to top", "G/end go to bottom", + "/ search", + "n/N next/prev match", + ": jump to line/pct", "c copy contents", "e edit this document", "r reload this document", @@ -381,11 +657,14 @@ func (m pagerModel) helpView() (s string) { s += "j/↓ down " + col1[1] + "\n" s += "b/pgup page up " + col1[2] + "\n" s += "f/pgdn page down " + col1[3] + "\n" - s += "u ½ page up " + col1[4] + "\n" - s += "d ½ page down " + s += "←/→ page back/fwd " + col1[4] + "\n" + s += "u ½ page up " + col1[5] + "\n" + s += "d ½ page down " + col1[6] + "\n" + s += " " + col1[7] + "\n" + s += " " + col1[8] - if len(col1) > 5 { - s += col1[5] + if len(col1) > 9 { + s += "\n " + col1[9] } s = indent(s, 2) diff --git a/ui/pager_test.go b/ui/pager_test.go new file mode 100644 index 0000000..d6f8d30 --- /dev/null +++ b/ui/pager_test.go @@ -0,0 +1,453 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +func testPagerModel(width, height int, cfg Config) pagerModel { + config = cfg + common := &commonModel{cfg: cfg, width: width, height: height} + vp := viewport.New(width, height) + return pagerModel{ + common: common, + viewport: vp, + state: pagerStateBrowse, + } +} + +func TestGlamourRender(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + t.Run("GlamourEnabled false returns raw markdown", func(t *testing.T) { + cfg := Config{GlamourEnabled: false} + m := testPagerModel(80, 24, cfg) + + input := "# Hello\n\nWorld" + got, err := glamourRender(m, input) + if err != nil { + t.Fatalf("glamourRender() error: %v", err) + } + if got != input { + t.Errorf("glamourRender() = %q, want %q", got, input) + } + }) + + t.Run("markdown file renders non-empty", func(t *testing.T) { + cfg := Config{ + GlamourEnabled: true, + GlamourStyle: "dark", + GlamourMaxWidth: 80, + } + m := testPagerModel(80, 24, cfg) + m.currentDocument = markdown{Note: "test.md"} + + got, err := glamourRender(m, "# Hello\n\nWorld") + if err != nil { + t.Fatalf("glamourRender() error: %v", err) + } + if got == "" { + t.Error("glamourRender() returned empty string for markdown") + } + }) + + t.Run("code file wraps in code block", func(t *testing.T) { + cfg := Config{ + GlamourEnabled: true, + GlamourStyle: "dark", + GlamourMaxWidth: 80, + } + m := testPagerModel(80, 24, cfg) + m.currentDocument = markdown{Note: "main.go"} + + got, err := glamourRender(m, "package main\n") + if err != nil { + t.Fatalf("glamourRender() error: %v", err) + } + if got == "" { + t.Error("glamourRender() returned empty for code file") + } + }) + + t.Run("ShowLineNumbers adds prefixes", func(t *testing.T) { + cfg := Config{ + GlamourEnabled: true, + GlamourStyle: "dark", + GlamourMaxWidth: 80, + ShowLineNumbers: true, + } + m := testPagerModel(80, 24, cfg) + m.currentDocument = markdown{Note: "test.md"} + + got, err := glamourRender(m, "# Hello") + if err != nil { + t.Fatalf("glamourRender() error: %v", err) + } + // Line numbers should be present - look for the number 1 + if !strings.Contains(got, "1") { + t.Errorf("glamourRender() with ShowLineNumbers should contain line numbers, got: %q", got) + } + }) + + t.Run("code files always get line numbers", func(t *testing.T) { + cfg := Config{ + GlamourEnabled: true, + GlamourStyle: "dark", + GlamourMaxWidth: 80, + ShowLineNumbers: false, // explicitly false + } + m := testPagerModel(80, 24, cfg) + m.currentDocument = markdown{Note: "main.go"} + + got, err := glamourRender(m, "package main\n") + if err != nil { + t.Fatalf("glamourRender() error: %v", err) + } + // Code files always get line numbers regardless of ShowLineNumbers + if !strings.Contains(got, "1") { + t.Errorf("glamourRender() code file should have line numbers, got: %q", got) + } + }) +} + +func TestStatusBarView(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + t.Run("browse state shows Note", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(80, 24, cfg) + m.currentDocument = markdown{Note: "myfile.md"} + m.state = pagerStateBrowse + + var b strings.Builder + m.statusBarView(&b) + got := b.String() + if !strings.Contains(got, "myfile.md") { + t.Errorf("statusBarView() in browse should contain Note, got: %q", got) + } + }) + + t.Run("status message state shows message", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(80, 24, cfg) + m.state = pagerStateStatusMessage + m.statusMessage = "Copied contents" + + var b strings.Builder + m.statusBarView(&b) + got := b.String() + if !strings.Contains(got, "Copied contents") { + t.Errorf("statusBarView() should contain status message, got: %q", got) + } + }) + + t.Run("narrow width no panic", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(10, 5, cfg) + m.currentDocument = markdown{Note: "a-very-long-filename-that-exceeds-width.md"} + m.state = pagerStateBrowse + + var b strings.Builder + // Should not panic + m.statusBarView(&b) + }) + + t.Run("zero width no panic", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(0, 0, cfg) + m.state = pagerStateBrowse + + var b strings.Builder + m.statusBarView(&b) + }) +} + +func TestHelpView(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + cfg := Config{} + m := testPagerModel(80, 24, cfg) + + got := m.helpView() + + if got == "" { + t.Error("helpView() returned empty string") + } + + expectedBindings := []string{"g/home", "G/end", "esc"} + for _, binding := range expectedBindings { + if !strings.Contains(got, binding) { + t.Errorf("helpView() should contain %q", binding) + } + } +} + +func TestSetSize(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + t.Run("viewport dimensions correct", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(80, 24, cfg) + m.setSize(100, 30) + + if m.viewport.Width != 100 { + t.Errorf("viewport.Width = %d, want 100", m.viewport.Width) + } + wantHeight := 30 - statusBarHeight + if m.viewport.Height != wantHeight { + t.Errorf("viewport.Height = %d, want %d", m.viewport.Height, wantHeight) + } + }) + + t.Run("showHelp reduces height", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(80, 24, cfg) + + m.setSize(80, 40) + heightWithoutHelp := m.viewport.Height + + m.showHelp = true + pagerHelpHeight = 0 // reset so it recalculates + m.setSize(80, 40) + heightWithHelp := m.viewport.Height + + if heightWithHelp >= heightWithoutHelp { + t.Errorf("showHelp should reduce viewport height: withHelp=%d, withoutHelp=%d", + heightWithHelp, heightWithoutHelp) + } + }) +} + +func TestLocalDir(t *testing.T) { + m := pagerModel{ + currentDocument: markdown{localPath: "/home/user/docs/readme.md"}, + } + + got := m.localDir() + want := "/home/user/docs" + if got != want { + t.Errorf("localDir() = %q, want %q", got, want) + } +} + +func TestFindMatches(t *testing.T) { + content := "Hello World\nfoo bar\nHello again\nbaz\nhello lower" + + t.Run("basic match", func(t *testing.T) { + matches := findMatches(content, "Hello") + // Case-insensitive: should match lines 0, 2, 4 + if len(matches) != 3 { + t.Errorf("findMatches() returned %d matches, want 3", len(matches)) + } + if matches[0] != 0 || matches[1] != 2 || matches[2] != 4 { + t.Errorf("findMatches() = %v, want [0, 2, 4]", matches) + } + }) + + t.Run("case insensitive", func(t *testing.T) { + matches := findMatches(content, "hello") + if len(matches) != 3 { + t.Errorf("findMatches() returned %d matches, want 3", len(matches)) + } + }) + + t.Run("no matches", func(t *testing.T) { + matches := findMatches(content, "xyz") + if len(matches) != 0 { + t.Errorf("findMatches() returned %d matches, want 0", len(matches)) + } + }) + + t.Run("empty query matches all lines", func(t *testing.T) { + matches := findMatches(content, "") + if len(matches) != 5 { + t.Errorf("findMatches() returned %d matches, want 5", len(matches)) + } + }) + + t.Run("single line match", func(t *testing.T) { + matches := findMatches(content, "baz") + if len(matches) != 1 { + t.Errorf("findMatches() returned %d matches, want 1", len(matches)) + } + if matches[0] != 3 { + t.Errorf("findMatches()[0] = %d, want 3", matches[0]) + } + }) +} + +func TestInInputMode(t *testing.T) { + t.Run("browse state no query", func(t *testing.T) { + m := pagerModel{state: pagerStateBrowse} + if m.inInputMode() { + t.Error("inInputMode() should be false in browse with no query") + } + }) + + t.Run("search state", func(t *testing.T) { + m := pagerModel{state: pagerStateSearch} + if !m.inInputMode() { + t.Error("inInputMode() should be true in search state") + } + }) + + t.Run("jump state", func(t *testing.T) { + m := pagerModel{state: pagerStateJumpToLine} + if !m.inInputMode() { + t.Error("inInputMode() should be true in jump state") + } + }) + + t.Run("browse with active search query", func(t *testing.T) { + m := pagerModel{state: pagerStateBrowse, searchQuery: "foo"} + if !m.inInputMode() { + t.Error("inInputMode() should be true when searchQuery is set") + } + }) + + t.Run("status message no query", func(t *testing.T) { + m := pagerModel{state: pagerStateStatusMessage} + if m.inInputMode() { + t.Error("inInputMode() should be false in status message state with no query") + } + }) +} + +func TestPageIndicator(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + t.Run("multi-page document shows indicator", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(80, 10, cfg) + // Set content with many lines + lines := strings.Repeat("line\n", 50) + m.viewport.SetContent(lines) + + var b strings.Builder + m.statusBarView(&b) + got := b.String() + if !strings.Contains(got, "pg") { + t.Errorf("statusBarView() should contain page indicator for multi-page doc, got: %q", got) + } + }) + + t.Run("single-page document no indicator", func(t *testing.T) { + cfg := Config{} + m := testPagerModel(80, 50, cfg) + m.viewport.SetContent("short content") + + var b strings.Builder + m.statusBarView(&b) + got := b.String() + // "pg" should not appear for single-page content + if strings.Contains(got, " pg ") { + t.Errorf("statusBarView() should not contain page indicator for single-page doc") + } + }) +} + +func TestSearchMatchCounter(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + cfg := Config{} + m := testPagerModel(80, 24, cfg) + m.searchQuery = "test" + m.searchMatches = []int{0, 5, 10} + m.searchIndex = 1 + + var b strings.Builder + m.statusBarView(&b) + got := b.String() + if !strings.Contains(got, "2/3") { + t.Errorf("statusBarView() should contain match counter '2/3', got: %q", got) + } +} + +func TestClearSearch(t *testing.T) { + m := pagerModel{ + searchQuery: "test", + searchMatches: []int{1, 2, 3}, + searchIndex: 1, + } + + m.clearSearch() + + if m.searchQuery != "" { + t.Errorf("clearSearch() should clear searchQuery, got %q", m.searchQuery) + } + if m.searchMatches != nil { + t.Errorf("clearSearch() should clear searchMatches, got %v", m.searchMatches) + } + if m.searchIndex != -1 { + t.Errorf("clearSearch() should set searchIndex to -1, got %d", m.searchIndex) + } +} + +func TestHelpViewContainsSearchAndJump(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + cfg := Config{} + m := testPagerModel(80, 24, cfg) + + got := m.helpView() + + expectedBindings := []string{"/", "n/N", ":", "←/→"} + for _, binding := range expectedBindings { + if !strings.Contains(got, binding) { + t.Errorf("helpView() should contain %q", binding) + } + } +} + +func TestArrowKeyPaging(t *testing.T) { + savedConfig := config + t.Cleanup(func() { config = savedConfig }) + + cfg := Config{} + m := testPagerModel(80, 10, cfg) + // 50 lines of content, viewport height 10 → multiple pages + m.viewport.SetContent(strings.Repeat("line\n", 50)) + + t.Run("right arrow pages forward", func(t *testing.T) { + m.viewport.GotoTop() + before := m.viewport.YOffset + + m.handleBrowseKeys(tea.KeyMsg{Type: tea.KeyRight}) + + if m.viewport.YOffset <= before { + t.Errorf("right arrow should page forward: before=%d, after=%d", before, m.viewport.YOffset) + } + }) + + t.Run("left arrow pages backward", func(t *testing.T) { + // Start partway down + m.viewport.SetYOffset(20) + before := m.viewport.YOffset + + m.handleBrowseKeys(tea.KeyMsg{Type: tea.KeyLeft}) + + if m.viewport.YOffset >= before { + t.Errorf("left arrow should page backward: before=%d, after=%d", before, m.viewport.YOffset) + } + }) + + t.Run("left arrow at top stays at top", func(t *testing.T) { + m.viewport.GotoTop() + + m.handleBrowseKeys(tea.KeyMsg{Type: tea.KeyLeft}) + + if m.viewport.YOffset != 0 { + t.Errorf("left arrow at top should stay at 0, got %d", m.viewport.YOffset) + } + }) +} diff --git a/ui/sort_test.go b/ui/sort_test.go new file mode 100644 index 0000000..6caf784 --- /dev/null +++ b/ui/sort_test.go @@ -0,0 +1,61 @@ +package ui + +import ( + "testing" +) + +func TestSortMarkdowns(t *testing.T) { + tests := []struct { + name string + notes []string + wantNotes []string + }{ + { + name: "alphabetical sort", + notes: []string{"cherry", "apple", "banana"}, + wantNotes: []string{"apple", "banana", "cherry"}, + }, + { + name: "empty slice", + notes: []string{}, + wantNotes: []string{}, + }, + { + name: "single item", + notes: []string{"only"}, + wantNotes: []string{"only"}, + }, + { + name: "already sorted", + notes: []string{"a", "b", "c"}, + wantNotes: []string{"a", "b", "c"}, + }, + { + name: "reverse order", + notes: []string{"c", "b", "a"}, + wantNotes: []string{"a", "b", "c"}, + }, + { + name: "duplicate notes stable", + notes: []string{"b", "a", "b", "a"}, + wantNotes: []string{"a", "a", "b", "b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mds := make([]*markdown, len(tt.notes)) + for i, n := range tt.notes { + mds[i] = &markdown{Note: n} + } + + sortMarkdowns(mds) + + for i, md := range mds { + if md.Note != tt.wantNotes[i] { + t.Errorf("index %d: got Note=%q, want %q", i, md.Note, tt.wantNotes[i]) + } + } + }) + } +} diff --git a/ui/stash_test.go b/ui/stash_test.go new file mode 100644 index 0000000..78ba404 --- /dev/null +++ b/ui/stash_test.go @@ -0,0 +1,311 @@ +package ui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/textinput" +) + +func testStashModel(numMarkdowns, perPage int) stashModel { + initSections() + + common := &commonModel{ + cfg: Config{}, + width: 80, + height: 40, + } + + si := textinput.New() + si.Prompt = "Find:" + + s := []section{ + sections[documentsSection], + } + // Set PerPage so we control pagination + s[0].paginator.PerPage = perPage + + mds := make([]*markdown, numMarkdowns) + for i := range mds { + mds[i] = &markdown{ + Note: string(rune('a' + i%26)), + } + mds[i].buildFilterValue() + } + + m := stashModel{ + common: common, + filterInput: si, + sections: s, + markdowns: mds, + } + + // Set total pages based on markdowns + if numMarkdowns > 0 { + m.paginator().SetTotalPages(numMarkdowns) + } else { + m.paginator().SetTotalPages(1) + } + + return m +} + +func TestMoveCursorUp(t *testing.T) { + t.Run("at top of first page stays 0", func(t *testing.T) { + m := testStashModel(10, 5) + m.setCursor(0) + m.paginator().Page = 0 + + m.moveCursorUp() + + if m.cursor() != 0 { + t.Errorf("cursor = %d, want 0", m.cursor()) + } + }) + + t.Run("middle decrements", func(t *testing.T) { + m := testStashModel(10, 5) + m.setCursor(3) + + m.moveCursorUp() + + if m.cursor() != 2 { + t.Errorf("cursor = %d, want 2", m.cursor()) + } + }) + + t.Run("top of page 2 goes to prev page", func(t *testing.T) { + m := testStashModel(10, 5) + m.paginator().Page = 1 + m.setCursor(0) + + m.moveCursorUp() + + if m.paginator().Page != 0 { + t.Errorf("page = %d, want 0", m.paginator().Page) + } + // Cursor should be at last item of previous page + if m.cursor() < 0 { + t.Errorf("cursor = %d, should be >= 0", m.cursor()) + } + }) +} + +func TestMoveCursorDown(t *testing.T) { + t.Run("middle increments", func(t *testing.T) { + m := testStashModel(10, 5) + m.setCursor(2) + + m.moveCursorDown() + + if m.cursor() != 3 { + t.Errorf("cursor = %d, want 3", m.cursor()) + } + }) + + t.Run("bottom of non-last page goes to next page", func(t *testing.T) { + m := testStashModel(10, 5) + m.setCursor(4) // last item on page (0-indexed, perPage=5) + + m.moveCursorDown() + + if m.paginator().Page != 1 { + t.Errorf("page = %d, want 1", m.paginator().Page) + } + if m.cursor() != 0 { + t.Errorf("cursor = %d, want 0", m.cursor()) + } + }) + + t.Run("bottom of last page stays", func(t *testing.T) { + m := testStashModel(5, 5) + m.setCursor(4) // last item, only one page + + m.moveCursorDown() + + if m.cursor() != 4 { + t.Errorf("cursor = %d, want 4", m.cursor()) + } + }) +} + +func TestUpdatePagination(t *testing.T) { + t.Run("correct page count", func(t *testing.T) { + m := testStashModel(10, 5) + m.updatePagination() + + if m.paginator().TotalPages < 1 { + t.Errorf("TotalPages = %d, want >= 1", m.paginator().TotalPages) + } + }) + + t.Run("empty markdowns gives 1 page", func(t *testing.T) { + m := testStashModel(0, 5) + m.updatePagination() + + if m.paginator().TotalPages != 1 { + t.Errorf("TotalPages = %d, want 1", m.paginator().TotalPages) + } + }) +} + +func TestFilterMarkdowns(t *testing.T) { + t.Run("no filter returns all", func(t *testing.T) { + m := testStashModel(5, 5) + m.filterState = unfiltered + + cmd := filterMarkdowns(m) + msg := cmd() + + filtered, ok := msg.(filteredMarkdownMsg) + if !ok { + t.Fatalf("expected filteredMarkdownMsg, got %T", msg) + } + if len(filtered) != 5 { + t.Errorf("len(filtered) = %d, want 5", len(filtered)) + } + }) + + t.Run("fuzzy match", func(t *testing.T) { + initSections() + common := &commonModel{cfg: Config{}, width: 80, height: 40} + si := textinput.New() + si.Prompt = "Find:" + si.SetValue("a") + + mds := []*markdown{ + {Note: "apple", filterValue: "apple"}, + {Note: "banana", filterValue: "banana"}, + {Note: "avocado", filterValue: "avocado"}, + } + + m := stashModel{ + common: common, + filterInput: si, + filterState: filtering, + markdowns: mds, + sections: []section{sections[documentsSection]}, + } + + cmd := filterMarkdowns(m) + msg := cmd() + + filtered, ok := msg.(filteredMarkdownMsg) + if !ok { + t.Fatalf("expected filteredMarkdownMsg, got %T", msg) + } + if len(filtered) == 0 { + t.Error("expected some fuzzy matches, got 0") + } + }) + + t.Run("no matches returns empty", func(t *testing.T) { + initSections() + common := &commonModel{cfg: Config{}, width: 80, height: 40} + si := textinput.New() + si.Prompt = "Find:" + si.SetValue("zzzzzzz") + + mds := []*markdown{ + {Note: "apple", filterValue: "apple"}, + {Note: "banana", filterValue: "banana"}, + } + + m := stashModel{ + common: common, + filterInput: si, + filterState: filtering, + markdowns: mds, + sections: []section{sections[documentsSection]}, + } + + cmd := filterMarkdowns(m) + msg := cmd() + + filtered, ok := msg.(filteredMarkdownMsg) + if !ok { + t.Fatalf("expected filteredMarkdownMsg, got %T", msg) + } + if len(filtered) != 0 { + t.Errorf("len(filtered) = %d, want 0", len(filtered)) + } + }) +} + +func TestSelectedMarkdown(t *testing.T) { + t.Run("valid cursor returns correct markdown", func(t *testing.T) { + m := testStashModel(5, 5) + m.setCursor(2) + + md := m.selectedMarkdown() + if md == nil { + t.Fatal("selectedMarkdown() returned nil") + } + if md != m.markdowns[2] { + t.Error("selectedMarkdown() returned wrong markdown") + } + }) + + t.Run("empty list returns nil", func(t *testing.T) { + m := testStashModel(0, 5) + + md := m.selectedMarkdown() + if md != nil { + t.Errorf("selectedMarkdown() = %v, want nil", md) + } + }) +} + +func TestGetVisibleMarkdowns(t *testing.T) { + t.Run("not filtering returns markdowns", func(t *testing.T) { + m := testStashModel(5, 5) + m.filterState = unfiltered + + got := m.getVisibleMarkdowns() + if len(got) != 5 { + t.Errorf("len(getVisibleMarkdowns()) = %d, want 5", len(got)) + } + }) + + t.Run("filtering returns filteredMarkdowns", func(t *testing.T) { + m := testStashModel(5, 5) + m.filterState = filtering + m.filteredMarkdowns = []*markdown{ + {Note: "filtered1"}, + {Note: "filtered2"}, + } + + got := m.getVisibleMarkdowns() + if len(got) != 2 { + t.Errorf("len(getVisibleMarkdowns()) = %d, want 2", len(got)) + } + }) +} + +func TestMarkdownIndex(t *testing.T) { + tests := []struct { + name string + page int + cursor int + perPage int + want int + }{ + {"page 0 cursor 2 perPage 5", 0, 2, 5, 2}, + {"page 1 cursor 3 perPage 5", 1, 3, 5, 8}, + {"page 0 cursor 0 perPage 10", 0, 0, 10, 0}, + {"page 2 cursor 1 perPage 3", 2, 1, 3, 7}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := testStashModel(20, tt.perPage) + m.paginator().Page = tt.page + m.paginator().PerPage = tt.perPage + m.setCursor(tt.cursor) + + got := m.markdownIndex() + if got != tt.want { + t.Errorf("markdownIndex() = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/ui/ui.go b/ui/ui.go index 3537d3f..4e1da15 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -216,6 +216,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc": + if m.state == stateShowDocument && m.pager.inInputMode() { + break // let pager handle (cancel search/jump, or clear results) + } if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument { batch := m.unloadDocument() return m, tea.Batch(batch...) @@ -233,6 +236,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "q": + if m.state == stateShowDocument && m.pager.inInputMode() { + break // let pager handle (typing 'q' in search input, or clearing search) + } + var cmd tea.Cmd switch m.state { //nolint:exhaustive @@ -246,7 +253,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit - case "left", "h", "delete": + case "h", "delete": + if m.state == stateShowDocument && m.pager.inInputMode() { + break // let pager textinput handle cursor/delete + } if m.state == stateShowDocument { cmds = append(cmds, m.unloadDocument()...) return m, tea.Batch(cmds...) diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 0000000..49b2628 --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,199 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveFrontmatter(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "YAML frontmatter stripped", + input: "---\ntitle: hello\n---\n# Body", + want: "# Body", + }, + { + name: "no frontmatter unchanged", + input: "# Just a heading\nSome text", + want: "# Just a heading\nSome text", + }, + { + name: "empty input", + input: "", + want: "", + }, + { + name: "single delimiter not stripped", + input: "---\nno closing delimiter", + want: "---\nno closing delimiter", + }, + { + name: "frontmatter only at position 0", + input: "some text\n---\ntitle: hello\n---\nbody", + want: "some text\n---\ntitle: hello\n---\nbody", + }, + { + name: "frontmatter with blank line", + input: "---\n\ntitle: hello\n---\n# Body", + want: "# Body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := string(RemoveFrontmatter([]byte(tt.input))) + if got != tt.want { + t.Errorf("RemoveFrontmatter() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIsMarkdownFile(t *testing.T) { + tests := []struct { + name string + filename string + want bool + }{ + {"md extension", "README.md", true}, + {"mdown extension", "file.mdown", true}, + {"mkdn extension", "file.mkdn", true}, + {"mkd extension", "file.mkd", true}, + {"markdown extension", "file.markdown", true}, + {"go extension", "main.go", false}, + {"txt extension", "notes.txt", false}, + {"rs extension", "lib.rs", false}, + {"no extension", "Makefile", true}, + {"case insensitive MD", "README.MD", true}, + {"case insensitive Md", "file.Md", true}, + {"multi-dot md", "file.tar.md", true}, + {"multi-dot go", "file.test.go", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsMarkdownFile(tt.filename) + if got != tt.want { + t.Errorf("IsMarkdownFile(%q) = %v, want %v", tt.filename, got, tt.want) + } + }) + } +} + +func TestWrapCodeBlock(t *testing.T) { + tests := []struct { + name string + s string + language string + want string + }{ + { + name: "normal wrap", + s: "fmt.Println(\"hello\")\n", + language: "go", + want: "```go\nfmt.Println(\"hello\")\n```", + }, + { + name: "empty string", + s: "", + language: "go", + want: "```go\n```", + }, + { + name: "empty language", + s: "some code\n", + language: "", + want: "```\nsome code\n```", + }, + { + name: "multiline", + s: "line1\nline2\nline3\n", + language: "python", + want: "```python\nline1\nline2\nline3\n```", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := WrapCodeBlock(tt.s, tt.language) + if got != tt.want { + t.Errorf("WrapCodeBlock() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExpandPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + path string + want string + }{ + { + name: "tilde expansion", + path: "~/foo", + want: filepath.Join(home, "foo"), + }, + { + name: "absolute unchanged", + path: "/usr/local/bin", + want: "/usr/local/bin", + }, + { + name: "empty string", + path: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExpandPath(tt.path) + if got != tt.want { + t.Errorf("ExpandPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } + + t.Run("env var expansion", func(t *testing.T) { + t.Setenv("GLOW_TEST_DIR", "/tmp/glowtest") + got := ExpandPath("$GLOW_TEST_DIR/foo") + want := "/tmp/glowtest/foo" + if got != want { + t.Errorf("ExpandPath($GLOW_TEST_DIR/foo) = %q, want %q", got, want) + } + }) +} + +func TestGlamourStyle(t *testing.T) { + tests := []struct { + name string + style string + isCode bool + }{ + {"dark style", "dark", false}, + {"light style", "light", false}, + {"notty style", "notty", false}, + {"dark style isCode", "dark", true}, + {"light style isCode", "light", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opt := GlamourStyle(tt.style, tt.isCode) + if opt == nil { + t.Errorf("GlamourStyle(%q, %v) returned nil", tt.style, tt.isCode) + } + }) + } +}