mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-23 16:44:17 +02:00
Merge 98fdd5fdd1 into e5cb757278
This commit is contained in:
commit
d59fb2a9e6
9 changed files with 1615 additions and 67 deletions
98
tests/e2e_test.go
Normal file
98
tests/e2e_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
23
tests/testdata/test.md
vendored
Normal file
23
tests/testdata/test.md
vendored
Normal file
|
|
@ -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.
|
||||
114
ui/markdown_test.go
Normal file
114
ui/markdown_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
403
ui/pager.go
403
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)
|
||||
|
|
|
|||
453
ui/pager_test.go
Normal file
453
ui/pager_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
61
ui/sort_test.go
Normal file
61
ui/sort_test.go
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
311
ui/stash_test.go
Normal file
311
ui/stash_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
12
ui/ui.go
12
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...)
|
||||
|
|
|
|||
199
utils/utils_test.go
Normal file
199
utils/utils_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue