mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-20 07:04:17 +02:00
Merge 1a4c861c88 into e5cb757278
This commit is contained in:
commit
128adb385e
7 changed files with 838 additions and 85 deletions
|
|
@ -1,6 +1,9 @@
|
|||
package ui
|
||||
|
||||
const (
|
||||
keyEnter = "enter"
|
||||
keyEsc = "esc"
|
||||
keyTab = "tab"
|
||||
keyShiftTab = "shift+tab"
|
||||
keyEnter = "enter"
|
||||
keyBackspace = "backspace"
|
||||
keyEsc = "esc"
|
||||
)
|
||||
|
|
|
|||
273
ui/pager.go
273
ui/pager.go
|
|
@ -89,6 +89,11 @@ const (
|
|||
pagerStateStatusMessage
|
||||
)
|
||||
|
||||
type navEntry struct {
|
||||
Path string
|
||||
YOffset int
|
||||
}
|
||||
|
||||
type pagerModel struct {
|
||||
common *commonModel
|
||||
viewport viewport.Model
|
||||
|
|
@ -102,22 +107,32 @@ type pagerModel struct {
|
|||
// it here so we can re-render it on resize.
|
||||
currentDocument markdown
|
||||
|
||||
watcher *fsnotify.Watcher
|
||||
rendered string
|
||||
|
||||
links []followableLink
|
||||
focusedLink int
|
||||
history []navEntry
|
||||
|
||||
pendingRestoreYOffset *int
|
||||
|
||||
watcher *fsnotify.Watcher
|
||||
watchedDir string
|
||||
watchCancel chan struct{}
|
||||
}
|
||||
|
||||
func newPagerModel(common *commonModel) pagerModel {
|
||||
// Init viewport
|
||||
func newPagerModel(common *commonModel) *pagerModel {
|
||||
vp := viewport.New(0, 0)
|
||||
vp.YPosition = 0
|
||||
vp.HighPerformanceRendering = config.HighPerformancePager
|
||||
vp.HighPerformanceRendering = common.cfg.HighPerformancePager
|
||||
|
||||
m := pagerModel{
|
||||
common: common,
|
||||
state: pagerStateBrowse,
|
||||
viewport: vp,
|
||||
common: common,
|
||||
state: pagerStateBrowse,
|
||||
viewport: vp,
|
||||
focusedLink: -1,
|
||||
}
|
||||
m.initWatcher()
|
||||
return m
|
||||
return &m
|
||||
}
|
||||
|
||||
func (m *pagerModel) setSize(w, h int) {
|
||||
|
|
@ -136,6 +151,14 @@ func (m *pagerModel) setContent(s string) {
|
|||
m.viewport.SetContent(s)
|
||||
}
|
||||
|
||||
func (m *pagerModel) applyRenderedContent() {
|
||||
content := m.rendered
|
||||
if m.focusedLink >= 0 {
|
||||
content = highlightFocusedLink(content, m.links, m.focusedLink)
|
||||
}
|
||||
m.setContent(content)
|
||||
}
|
||||
|
||||
func (m *pagerModel) toggleHelp() {
|
||||
m.showHelp = !m.showHelp
|
||||
m.setSize(m.common.width, m.common.height)
|
||||
|
|
@ -175,10 +198,15 @@ func (m *pagerModel) unload() {
|
|||
m.state = pagerStateBrowse
|
||||
m.viewport.SetContent("")
|
||||
m.viewport.YOffset = 0
|
||||
m.unwatchFile()
|
||||
m.rendered = ""
|
||||
m.links = nil
|
||||
m.focusedLink = -1
|
||||
m.history = nil
|
||||
m.pendingRestoreYOffset = nil
|
||||
m.stopWatching()
|
||||
}
|
||||
|
||||
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
||||
func (m *pagerModel) update(msg tea.Msg) (*pagerModel, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
|
|
@ -192,26 +220,85 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
|||
m.state = pagerStateBrowse
|
||||
return m, nil
|
||||
}
|
||||
case keyTab, "down":
|
||||
if len(m.links) == 0 {
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No local links", false}))
|
||||
break
|
||||
}
|
||||
if m.focusedLink < 0 {
|
||||
m.focusedLink = 0
|
||||
} else {
|
||||
m.focusedLink = (m.focusedLink + 1) % len(m.links)
|
||||
}
|
||||
m.applyRenderedContent()
|
||||
if m.common != nil && m.common.cfg.HighPerformancePager {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false}))
|
||||
case keyShiftTab, "backtab", "up":
|
||||
if len(m.links) == 0 {
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No local links", false}))
|
||||
break
|
||||
}
|
||||
if m.focusedLink < 0 {
|
||||
m.focusedLink = len(m.links) - 1
|
||||
} else {
|
||||
m.focusedLink--
|
||||
if m.focusedLink < 0 {
|
||||
m.focusedLink = len(m.links) - 1
|
||||
}
|
||||
}
|
||||
m.applyRenderedContent()
|
||||
if m.common != nil && m.common.cfg.HighPerformancePager {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false}))
|
||||
|
||||
case keyEnter, "right":
|
||||
if m.focusedLink >= 0 && m.focusedLink < len(m.links) {
|
||||
cmd := m.followFocusedLink()
|
||||
return m, cmd
|
||||
}
|
||||
if len(m.links) > 0 {
|
||||
// No link selected. Just visit the nearest visible link.
|
||||
body := strings.TrimSpace(m.currentDocument.Body)
|
||||
for i, l := range m.links {
|
||||
if strings.Contains(body, l.Label) {
|
||||
m.focusedLink = i
|
||||
cmd := m.followFocusedLink()
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Tab to select a link", false}))
|
||||
}
|
||||
|
||||
case keyBackspace, "left", "h", "delete":
|
||||
if len(m.history) > 0 {
|
||||
last := m.history[len(m.history)-1]
|
||||
m.history = m.history[:len(m.history)-1]
|
||||
|
||||
m.focusedLink = -1
|
||||
y := last.YOffset
|
||||
m.pendingRestoreYOffset = &y
|
||||
m.viewport.GotoTop()
|
||||
|
||||
md := &markdown{
|
||||
localPath: last.Path,
|
||||
Note: stripAbsolutePath(last.Path, m.common.cwd),
|
||||
}
|
||||
return m, loadLocalMarkdown(md)
|
||||
}
|
||||
m.focusedLink = -1
|
||||
return m, func() tea.Msg { return goBackToStashMsg{} }
|
||||
case "home", "g":
|
||||
m.viewport.GotoTop()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
if m.common != nil && m.common.cfg.HighPerformancePager {
|
||||
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 {
|
||||
if m.common != nil && m.common.cfg.HighPerformancePager {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
|
||||
|
|
@ -239,20 +326,32 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
|||
|
||||
case "?":
|
||||
m.toggleHelp()
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
if m.common != nil && m.common.cfg.HighPerformancePager {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
}
|
||||
|
||||
case errMsg:
|
||||
m.pendingRestoreYOffset = nil
|
||||
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{msg.Error(), true}))
|
||||
|
||||
// Glow has rendered the content
|
||||
case contentRenderedMsg:
|
||||
log.Info("content rendered", "state", m.state)
|
||||
|
||||
m.setContent(string(msg))
|
||||
if m.viewport.HighPerformanceRendering {
|
||||
m.rendered = string(msg)
|
||||
m.applyRenderedContent()
|
||||
if m.pendingRestoreYOffset != nil {
|
||||
m.viewport.YOffset = *m.pendingRestoreYOffset
|
||||
if m.viewport.PastBottom() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
m.pendingRestoreYOffset = nil
|
||||
}
|
||||
if m.common != nil && m.common.cfg.HighPerformancePager {
|
||||
cmds = append(cmds, viewport.Sync(m.viewport))
|
||||
}
|
||||
cmds = append(cmds, m.watchFile)
|
||||
cmds = append(cmds, m.startWatching())
|
||||
|
||||
// The file was changed on disk and we're reloading it
|
||||
case reloadMsg:
|
||||
|
|
@ -279,7 +378,7 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
|
|||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m pagerModel) View() string {
|
||||
func (m *pagerModel) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprint(&b, m.viewport.View()+"\n")
|
||||
|
||||
|
|
@ -366,26 +465,30 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
|
|||
}
|
||||
|
||||
func (m pagerModel) helpView() (s string) {
|
||||
col1 := []string{
|
||||
"g/home go to top",
|
||||
"G/end go to bottom",
|
||||
"c copy contents",
|
||||
"e edit this document",
|
||||
"r reload this document",
|
||||
"esc back to files",
|
||||
"q quit",
|
||||
rows := [][2]string{
|
||||
{"k/↑ up", "g/home go to top"},
|
||||
{"j/↓ down", "G/end go to bottom"},
|
||||
{"b/pgup page up", "tab next link"},
|
||||
{"f/pgdn page down", "⇧tab prev link"},
|
||||
{"u ½ page up", "enter follow link"},
|
||||
{"d ½ page down", "⌫ go back"},
|
||||
{"", "c copy contents"},
|
||||
{"", "e edit this document"},
|
||||
{"", "r reload this document"},
|
||||
{"", "esc back to files"},
|
||||
{"", "q quit"},
|
||||
}
|
||||
|
||||
s += "\n"
|
||||
s += "k/↑ up " + col1[0] + "\n"
|
||||
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 "
|
||||
|
||||
if len(col1) > 5 {
|
||||
s += col1[5]
|
||||
for _, row := range rows {
|
||||
left := row[0]
|
||||
right := row[1]
|
||||
if left != "" {
|
||||
left = fmt.Sprintf("%-24s", left)
|
||||
} else {
|
||||
left = strings.Repeat(" ", 24)
|
||||
}
|
||||
s += left + right + "\n"
|
||||
}
|
||||
|
||||
s = indent(s, 2)
|
||||
|
|
@ -407,7 +510,7 @@ func (m pagerModel) helpView() (s string) {
|
|||
|
||||
// COMMANDS
|
||||
|
||||
func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
||||
func renderWithGlamour(m *pagerModel, md string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
s, err := glamourRender(m, md)
|
||||
if err != nil {
|
||||
|
|
@ -419,7 +522,7 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
|
|||
}
|
||||
|
||||
// This is where the magic happens.
|
||||
func glamourRender(m pagerModel, markdown string) (string, error) {
|
||||
func glamourRender(m *pagerModel, markdown string) (string, error) {
|
||||
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
|
||||
|
||||
if !config.GlamourEnabled {
|
||||
|
|
@ -487,20 +590,37 @@ func (m *pagerModel) initWatcher() {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) watchFile() tea.Msg {
|
||||
dir := m.localDir()
|
||||
func (m *pagerModel) startWatching() tea.Cmd {
|
||||
if m.watcher == nil || m.currentDocument.localPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.stopWatching()
|
||||
|
||||
dir := m.localDir()
|
||||
if err := m.watcher.Add(dir); err != nil {
|
||||
log.Error("error adding dir to fsnotify watcher", "error", err)
|
||||
return nil
|
||||
}
|
||||
m.watchedDir = dir
|
||||
m.watchCancel = make(chan struct{})
|
||||
|
||||
log.Info("fsnotify watching dir", "dir", dir)
|
||||
cancel := m.watchCancel
|
||||
return func() tea.Msg { return m.watchFile(cancel) }
|
||||
}
|
||||
|
||||
func (m *pagerModel) watchFile(cancel <-chan struct{}) tea.Msg {
|
||||
log.Info("fsnotify watching dir", "dir", m.watchedDir)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cancel:
|
||||
return nil
|
||||
case event, ok := <-m.watcher.Events:
|
||||
if !ok || event.Name != m.currentDocument.localPath {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if event.Name != m.currentDocument.localPath {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -512,24 +632,53 @@ func (m *pagerModel) watchFile() tea.Msg {
|
|||
return reloadMsg{}
|
||||
case err, ok := <-m.watcher.Errors:
|
||||
if !ok {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
log.Debug("fsnotify error", "dir", dir, "error", err)
|
||||
log.Debug("fsnotify error", "dir", m.watchedDir, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *pagerModel) unwatchFile() {
|
||||
dir := m.localDir()
|
||||
|
||||
err := m.watcher.Remove(dir)
|
||||
if err == nil {
|
||||
log.Debug("fsnotify dir unwatched", "dir", dir)
|
||||
} else {
|
||||
log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err)
|
||||
func (m *pagerModel) stopWatching() {
|
||||
if m.watchCancel != nil {
|
||||
close(m.watchCancel)
|
||||
m.watchCancel = nil
|
||||
}
|
||||
|
||||
if m.watcher == nil || m.watchedDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := m.watcher.Remove(m.watchedDir)
|
||||
if err == nil {
|
||||
log.Debug("fsnotify dir unwatched", "dir", m.watchedDir)
|
||||
} else {
|
||||
log.Error("fsnotify fail to unwatch dir", "dir", m.watchedDir, "error", err)
|
||||
}
|
||||
m.watchedDir = ""
|
||||
}
|
||||
|
||||
func (m *pagerModel) localDir() string {
|
||||
return filepath.Dir(m.currentDocument.localPath)
|
||||
}
|
||||
|
||||
func (m *pagerModel) followFocusedLink() tea.Cmd {
|
||||
l := m.links[m.focusedLink]
|
||||
if l.ResolvedPath == "" {
|
||||
return nil
|
||||
}
|
||||
if m.currentDocument.localPath != "" {
|
||||
m.history = append(m.history, navEntry{Path: m.currentDocument.localPath, YOffset: m.viewport.YOffset})
|
||||
}
|
||||
|
||||
m.focusedLink = -1
|
||||
m.viewport.GotoTop()
|
||||
m.pendingRestoreYOffset = nil
|
||||
|
||||
md := &markdown{
|
||||
localPath: l.ResolvedPath,
|
||||
Note: l.ResolvedNote,
|
||||
}
|
||||
return loadLocalMarkdown(md)
|
||||
}
|
||||
|
||||
|
|
|
|||
108
ui/pager_highlight.go
Normal file
108
ui/pager_highlight.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func highlightFocusedLink(rendered string, links []followableLink, focused int) string {
|
||||
if focused < 0 || focused >= len(links) {
|
||||
return rendered
|
||||
}
|
||||
|
||||
printable, offsets := printableRunesAndOffsets(rendered)
|
||||
if len(printable) == 0 {
|
||||
return rendered
|
||||
}
|
||||
printableStr := string(printable)
|
||||
|
||||
type span struct {
|
||||
start int
|
||||
end int
|
||||
ok bool
|
||||
}
|
||||
|
||||
spans := make([]span, len(links))
|
||||
searchFrom := 0
|
||||
for i, l := range links {
|
||||
label := strings.TrimSpace(l.Label)
|
||||
if label == "" || searchFrom >= len(printableStr) {
|
||||
continue
|
||||
}
|
||||
|
||||
relIdx := strings.Index(printableStr[searchFrom:], label)
|
||||
if relIdx < 0 {
|
||||
continue
|
||||
}
|
||||
byteIdx := searchFrom + relIdx
|
||||
searchFrom = byteIdx + len(label)
|
||||
|
||||
startRune := utf8.RuneCountInString(printableStr[:byteIdx])
|
||||
endRune := startRune + utf8.RuneCountInString(label)
|
||||
if startRune < 0 || endRune > len(offsets)-1 {
|
||||
continue
|
||||
}
|
||||
|
||||
startByte := offsets[startRune]
|
||||
endByte := offsets[endRune]
|
||||
if startByte < 0 || endByte < startByte || endByte > len(rendered) {
|
||||
continue
|
||||
}
|
||||
|
||||
spans[i] = span{start: startByte, end: endByte, ok: true}
|
||||
}
|
||||
|
||||
s := spans[focused]
|
||||
if !s.ok {
|
||||
return rendered
|
||||
}
|
||||
|
||||
const (
|
||||
reverseOn = "\x1b[7m"
|
||||
reverseOff = "\x1b[27m"
|
||||
)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(rendered) + len(reverseOn) + len(reverseOff))
|
||||
b.WriteString(rendered[:s.start])
|
||||
b.WriteString(reverseOn)
|
||||
b.WriteString(rendered[s.start:s.end])
|
||||
b.WriteString(reverseOff)
|
||||
b.WriteString(rendered[s.end:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func printableRunesAndOffsets(s string) ([]rune, []int) {
|
||||
var (
|
||||
runes []rune
|
||||
offsets []int
|
||||
)
|
||||
|
||||
for i := 0; i < len(s); {
|
||||
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
||||
i += 2
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
i++
|
||||
if c >= 0x40 && c <= 0x7E {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
r = rune(s[i])
|
||||
size = 1
|
||||
}
|
||||
|
||||
runes = append(runes, r)
|
||||
offsets = append(offsets, i)
|
||||
i += size
|
||||
}
|
||||
|
||||
offsets = append(offsets, len(s))
|
||||
|
||||
return runes, offsets
|
||||
}
|
||||
198
ui/pager_links.go
Normal file
198
ui/pager_links.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
type followableLink struct {
|
||||
Href string
|
||||
Path string
|
||||
Fragment string
|
||||
Label string
|
||||
|
||||
ResolvedPath string
|
||||
ResolvedNote string
|
||||
}
|
||||
|
||||
type rawLink struct {
|
||||
href string
|
||||
label string
|
||||
}
|
||||
|
||||
func followableLinksForDocument(rootDir, currentFilePath, markdown string) ([]followableLink, error) {
|
||||
raw := extractRawLinks(markdown)
|
||||
|
||||
out := make([]followableLink, 0, len(raw))
|
||||
for _, l := range raw {
|
||||
link, ok, err := resolveFollowableLink(rootDir, currentFilePath, l.href)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(l.label) == "" {
|
||||
continue
|
||||
}
|
||||
link.Label = l.label
|
||||
out = append(out, link)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitFragment(href string) (path, frag string) {
|
||||
path, frag, ok := strings.Cut(href, "#")
|
||||
if ok {
|
||||
return path, frag
|
||||
}
|
||||
return href, ""
|
||||
}
|
||||
|
||||
func isAbsoluteOrUNCPath(path string) bool {
|
||||
if strings.HasPrefix(path, "/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, `\\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 2 {
|
||||
c0 := path[0]
|
||||
if ((c0 >= 'a' && c0 <= 'z') || (c0 >= 'A' && c0 <= 'Z')) && path[1] == ':' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return filepath.IsAbs(path)
|
||||
}
|
||||
|
||||
func isFollowableHref(href string) bool {
|
||||
href = strings.TrimSpace(href)
|
||||
href = strings.Trim(href, "<>")
|
||||
hrefLower := strings.ToLower(href)
|
||||
|
||||
if strings.Contains(href, "://") || strings.HasPrefix(hrefLower, "mailto:") {
|
||||
return false
|
||||
}
|
||||
|
||||
path, _ := splitFragment(href)
|
||||
if isAbsoluteOrUNCPath(path) {
|
||||
return false
|
||||
}
|
||||
pathLower := strings.ToLower(path)
|
||||
|
||||
return strings.HasSuffix(pathLower, ".md") || strings.HasSuffix(pathLower, ".markdown")
|
||||
}
|
||||
|
||||
func extractRawLinks(markdown string) []rawLink {
|
||||
source := []byte(markdown)
|
||||
parser := goldmark.New().Parser()
|
||||
doc := parser.Parse(text.NewReader(source))
|
||||
|
||||
var out []rawLink
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
link, ok := n.(*ast.Link)
|
||||
if !ok {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
href := strings.TrimSpace(string(link.Destination))
|
||||
if href == "" {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
_ = ast.Walk(link, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if t, ok := child.(*ast.Text); ok {
|
||||
b.Write(t.Segment.Value(source))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
|
||||
out = append(out, rawLink{
|
||||
href: href,
|
||||
label: strings.TrimSpace(b.String()),
|
||||
})
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveFollowableLink(rootDir, currentFilePath, href string) (followableLink, bool, error) {
|
||||
href = strings.TrimSpace(href)
|
||||
href = strings.Trim(href, "<>")
|
||||
|
||||
if !isFollowableHref(href) {
|
||||
return followableLink{}, false, nil
|
||||
}
|
||||
|
||||
path, frag := splitFragment(href)
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return followableLink{}, false, nil
|
||||
}
|
||||
|
||||
if strings.Contains(path, "%") {
|
||||
if decoded, err := url.PathUnescape(path); err == nil {
|
||||
path = decoded
|
||||
}
|
||||
}
|
||||
|
||||
base := filepath.Dir(currentFilePath)
|
||||
resolved := filepath.Clean(filepath.Join(base, path))
|
||||
|
||||
rootAbs, err := filepath.Abs(rootDir)
|
||||
if err != nil {
|
||||
return followableLink{}, false, fmt.Errorf("abs root dir: %w", err)
|
||||
}
|
||||
resAbs, err := filepath.Abs(resolved)
|
||||
if err != nil {
|
||||
return followableLink{}, false, fmt.Errorf("abs resolved path: %w", err)
|
||||
}
|
||||
|
||||
if rootEval, err := filepath.EvalSymlinks(rootAbs); err == nil {
|
||||
rootAbs = rootEval
|
||||
}
|
||||
if resEval, err := filepath.EvalSymlinks(resAbs); err == nil {
|
||||
resAbs = resEval
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(rootAbs, resAbs)
|
||||
if err != nil {
|
||||
return followableLink{}, false, nil
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return followableLink{}, false, nil
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(resAbs)
|
||||
if statErr != nil {
|
||||
return followableLink{}, false, nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return followableLink{}, false, nil
|
||||
}
|
||||
|
||||
return followableLink{
|
||||
Href: href,
|
||||
Path: path,
|
||||
Fragment: frag,
|
||||
ResolvedPath: resAbs,
|
||||
ResolvedNote: stripAbsolutePath(resAbs, rootAbs),
|
||||
}, true, nil
|
||||
}
|
||||
254
ui/pager_links_test.go
Normal file
254
ui/pager_links_test.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFollowableLinksForDocument_CommonFormatsAndSafety(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "root")
|
||||
outside := filepath.Join(base, "outside")
|
||||
|
||||
mustMkdirAll(t, filepath.Join(root, "docs"))
|
||||
mustMkdirAll(t, outside)
|
||||
|
||||
currentFilePath := filepath.Join(root, "current.md")
|
||||
mustWriteFile(t, currentFilePath, "# Current\n")
|
||||
|
||||
targetMD := filepath.Join(root, "docs", "target.md")
|
||||
targetMarkdown := filepath.Join(root, "docs", "target.markdown")
|
||||
spaceNameMD := filepath.Join(root, "docs", "SPACE NAME.md")
|
||||
mustWriteFile(t, targetMD, "# Target\n")
|
||||
mustWriteFile(t, targetMarkdown, "# Target Markdown\n")
|
||||
mustWriteFile(t, spaceNameMD, "# Space Name\n")
|
||||
|
||||
outsideMD := filepath.Join(outside, "outside.md")
|
||||
mustWriteFile(t, outsideMD, "# Outside\n")
|
||||
|
||||
// A directory with a markdown-looking name should not be followable.
|
||||
dirLooksLikeMD := filepath.Join(root, "docs", "dir.md")
|
||||
mustMkdirAll(t, dirLooksLikeMD)
|
||||
|
||||
rootAbs := absEvalSymlinks(t, root)
|
||||
|
||||
type wantLink struct {
|
||||
Label string
|
||||
ResolvedPath string
|
||||
ResolvedNote string
|
||||
Fragment string
|
||||
}
|
||||
|
||||
targetAbs := absEvalSymlinks(t, targetMD)
|
||||
targetMarkdownAbs := absEvalSymlinks(t, targetMarkdown)
|
||||
spaceNameAbs := absEvalSymlinks(t, spaceNameMD)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
md string
|
||||
want []wantLink
|
||||
setup func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "inline_relative_md",
|
||||
md: "See [Target](docs/target.md).\n",
|
||||
want: []wantLink{{
|
||||
Label: "Target",
|
||||
ResolvedPath: targetAbs,
|
||||
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "reference_relative_md",
|
||||
md: "See [Target][id].\n\n[id]: docs/target.md\n",
|
||||
want: []wantLink{{
|
||||
Label: "Target",
|
||||
ResolvedPath: targetAbs,
|
||||
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "collapsed_reference_relative_md",
|
||||
md: "[Target][]\n\n[Target]: docs/target.md\n",
|
||||
want: []wantLink{{
|
||||
Label: "Target",
|
||||
ResolvedPath: targetAbs,
|
||||
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "relative_md_with_fragment",
|
||||
md: "See [Target](docs/target.md#section).\n",
|
||||
want: []wantLink{{
|
||||
Label: "Target",
|
||||
ResolvedPath: targetAbs,
|
||||
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
|
||||
Fragment: "section",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "destination_in_angle_brackets",
|
||||
md: "See [Target](<docs/target.md>).\n",
|
||||
want: []wantLink{{
|
||||
Label: "Target",
|
||||
ResolvedPath: targetAbs,
|
||||
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "url_escaped_path_is_unescaped",
|
||||
md: "See [Space](docs/SPACE%20NAME.md).\n",
|
||||
want: []wantLink{{
|
||||
Label: "Space",
|
||||
ResolvedPath: spaceNameAbs,
|
||||
ResolvedNote: stripAbsolutePath(spaceNameAbs, rootAbs),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "relative_markdown_extension",
|
||||
md: "See [Target](docs/target.markdown).\n",
|
||||
want: []wantLink{{
|
||||
Label: "Target",
|
||||
ResolvedPath: targetMarkdownAbs,
|
||||
ResolvedNote: stripAbsolutePath(targetMarkdownAbs, rootAbs),
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "empty_label_is_ignored",
|
||||
md: "See [](docs/target.md).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "image_is_ignored",
|
||||
md: "\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "autolink_relative_is_ignored",
|
||||
md: "See <docs/target.md>.\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "bare_path_is_ignored",
|
||||
md: "docs/target.md\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "external_http_is_ignored",
|
||||
md: "See [Ext](https://example.com/docs/target.md).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "mailto_is_ignored",
|
||||
md: "See [Mail](mailto:test@example.com).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "non_markdown_extension_is_ignored",
|
||||
md: "See [Txt](docs/target.txt).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "missing_file_is_ignored",
|
||||
md: "See [Missing](docs/missing.md).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "directory_is_ignored_even_if_md_suffix",
|
||||
md: "See [Dir](docs/dir.md).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "absolute_unix_path_is_ignored",
|
||||
md: "See [Abs](/etc/passwd).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "windows_drive_absolute_is_ignored",
|
||||
md: `See [WinAbs](C:\\Windows\\system32\\a.md).`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "windows_unc_absolute_is_ignored",
|
||||
md: `See [UNC](\\\\server\\share\\a.md).`,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "root_escape_via_dotdot_is_ignored",
|
||||
md: "See [Escape](../outside/outside.md).\n",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "root_escape_via_symlink_is_ignored",
|
||||
md: "See [Escape](escape/outside.md).\n",
|
||||
want: nil,
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
escape := filepath.Join(root, "escape")
|
||||
if err := os.Symlink(outside, escape); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.setup != nil {
|
||||
tc.setup(t)
|
||||
}
|
||||
|
||||
got, err := followableLinksForDocument(root, currentFilePath, tc.md)
|
||||
if err != nil {
|
||||
t.Fatalf("followableLinksForDocument returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("expected %d links, got %d: %+v", len(tc.want), len(got), got)
|
||||
}
|
||||
|
||||
for i := range tc.want {
|
||||
if got[i].Label != tc.want[i].Label {
|
||||
t.Fatalf("link[%d] label: expected %q, got %q", i, tc.want[i].Label, got[i].Label)
|
||||
}
|
||||
if got[i].ResolvedPath != tc.want[i].ResolvedPath {
|
||||
t.Fatalf("link[%d] resolved path: expected %q, got %q", i, tc.want[i].ResolvedPath, got[i].ResolvedPath)
|
||||
}
|
||||
if got[i].ResolvedNote != tc.want[i].ResolvedNote {
|
||||
t.Fatalf("link[%d] resolved note: expected %q, got %q", i, tc.want[i].ResolvedNote, got[i].ResolvedNote)
|
||||
}
|
||||
if got[i].Fragment != tc.want[i].Fragment {
|
||||
t.Fatalf("link[%d] fragment: expected %q, got %q", i, tc.want[i].Fragment, got[i].Fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func absEvalSymlinks(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatalf("abs %q: %v", path, err)
|
||||
}
|
||||
if eval, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return eval
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdirall %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
mustMkdirAll(t, filepath.Dir(path))
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("writefile %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -527,7 +527,7 @@ func (m *stashModel) handleDocumentBrowsing(msg tea.Msg) tea.Cmd {
|
|||
return openEditor(md.localPath, 0)
|
||||
|
||||
// Open document
|
||||
case keyEnter:
|
||||
case keyEnter, "right":
|
||||
m.hideStatusMessage()
|
||||
|
||||
if numDocs == 0 {
|
||||
|
|
|
|||
89
ui/ui.go
89
ui/ui.go
|
|
@ -63,6 +63,7 @@ type (
|
|||
foundLocalFileMsg gitcha.SearchResult
|
||||
localFileSearchFinished struct{}
|
||||
statusMessageTimeoutMsg applicationContext
|
||||
goBackToStashMsg struct{}
|
||||
)
|
||||
|
||||
// applicationContext indicates the area of the application something applies
|
||||
|
|
@ -104,7 +105,7 @@ type model struct {
|
|||
|
||||
// Sub-models
|
||||
stash stashModel
|
||||
pager pagerModel
|
||||
pager *pagerModel
|
||||
|
||||
// Channel that receives paths to local markdown files
|
||||
// (via the github.com/muesli/gitcha package)
|
||||
|
|
@ -116,6 +117,7 @@ type model struct {
|
|||
func (m *model) unloadDocument() []tea.Cmd {
|
||||
m.state = stateShowStash
|
||||
m.stash.viewState = stashStateReady
|
||||
m.stash.markdowns = nil
|
||||
m.pager.unload()
|
||||
m.pager.showHelp = false
|
||||
|
||||
|
|
@ -127,10 +129,16 @@ func (m *model) unloadDocument() []tea.Cmd {
|
|||
if !m.stash.shouldSpin() {
|
||||
batch = append(batch, m.stash.spinner.Tick)
|
||||
}
|
||||
|
||||
// If we are transitioning from a document to the stash, we need to populate the stash.
|
||||
if m.common.cwd != "" {
|
||||
batch = append(batch, findLocalFiles(*m.common))
|
||||
}
|
||||
|
||||
return batch
|
||||
}
|
||||
|
||||
func newModel(cfg Config, content string) tea.Model {
|
||||
func newModel(cfg Config, content string) *model {
|
||||
initSections()
|
||||
|
||||
if cfg.GlamourStyle == styles.AutoStyle {
|
||||
|
|
@ -156,7 +164,7 @@ func newModel(cfg Config, content string) tea.Model {
|
|||
if path == "" && content != "" {
|
||||
m.state = stateShowDocument
|
||||
m.pager.currentDocument = markdown{Body: content}
|
||||
return m
|
||||
return &m
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
|
|
@ -166,43 +174,60 @@ func newModel(cfg Config, content string) tea.Model {
|
|||
if err != nil {
|
||||
log.Error("unable to stat file", "file", path, "error", err)
|
||||
m.fatalErr = err
|
||||
return m
|
||||
return &m
|
||||
}
|
||||
if info.IsDir() {
|
||||
m.state = stateShowStash
|
||||
} else {
|
||||
cwd, _ := os.Getwd()
|
||||
m.common.cwd = cwd
|
||||
if rel, err := filepath.Rel(m.common.cwd, path); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
m.common.cwd = filepath.Dir(path)
|
||||
}
|
||||
m.state = stateShowDocument
|
||||
m.pager.currentDocument = markdown{
|
||||
localPath: path,
|
||||
Note: stripAbsolutePath(path, cwd),
|
||||
Note: stripAbsolutePath(path, m.common.cwd),
|
||||
Modtime: info.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
return &m
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
func (m *model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{m.stash.spinner.Tick}
|
||||
|
||||
switch m.state {
|
||||
case stateShowStash:
|
||||
cmds = append(cmds, findLocalFiles(*m.common))
|
||||
case stateShowDocument:
|
||||
content, err := os.ReadFile(m.common.cfg.Path)
|
||||
if err != nil {
|
||||
log.Error("unable to read file", "file", m.common.cfg.Path, "error", err)
|
||||
return func() tea.Msg { return errMsg{err} }
|
||||
var body string
|
||||
if m.pager.currentDocument.localPath != "" {
|
||||
content, err := os.ReadFile(m.pager.currentDocument.localPath)
|
||||
if err != nil {
|
||||
log.Error("unable to read file", "file", m.pager.currentDocument.localPath, "error", err)
|
||||
return func() tea.Msg { return errMsg{err} }
|
||||
}
|
||||
body = string(utils.RemoveFrontmatter(content))
|
||||
} else {
|
||||
body = string(utils.RemoveFrontmatter([]byte(m.pager.currentDocument.Body)))
|
||||
}
|
||||
m.pager.currentDocument.Body = body
|
||||
if m.pager.currentDocument.localPath != "" && m.common.cwd != "" {
|
||||
links, err := followableLinksForDocument(m.common.cwd, m.pager.currentDocument.localPath, body)
|
||||
if err != nil {
|
||||
log.Debug("error extracting followable links", "error", err)
|
||||
}
|
||||
m.pager.links = links
|
||||
m.pager.focusedLink = -1
|
||||
}
|
||||
body := string(utils.RemoveFrontmatter(content))
|
||||
cmds = append(cmds, renderWithGlamour(m.pager, body))
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// If there's been an error, any key exits
|
||||
if m.fatalErr != nil {
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
|
|
@ -246,12 +271,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
|
||||
return m, tea.Quit
|
||||
|
||||
case "left", "h", "delete":
|
||||
if m.state == stateShowDocument {
|
||||
cmds = append(cmds, m.unloadDocument()...)
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
case "ctrl+z":
|
||||
return m, tea.Suspend
|
||||
|
||||
|
|
@ -276,11 +295,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
// We've loaded a markdown file's contents for rendering
|
||||
m.pager.currentDocument = *msg
|
||||
body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
|
||||
m.pager.currentDocument.Body = body
|
||||
if m.pager.currentDocument.localPath != "" && m.common.cwd != "" {
|
||||
links, err := followableLinksForDocument(m.common.cwd, m.pager.currentDocument.localPath, body)
|
||||
if err != nil {
|
||||
log.Debug("error extracting followable links", "error", err)
|
||||
}
|
||||
m.pager.links = links
|
||||
m.pager.focusedLink = -1
|
||||
} else {
|
||||
m.pager.links = nil
|
||||
m.pager.focusedLink = -1
|
||||
}
|
||||
cmds = append(cmds, renderWithGlamour(m.pager, body))
|
||||
|
||||
case contentRenderedMsg:
|
||||
m.state = stateShowDocument
|
||||
|
||||
case goBackToStashMsg:
|
||||
batch := m.unloadDocument()
|
||||
return m, tea.Batch(batch...)
|
||||
|
||||
case localFileSearchFinished:
|
||||
// Always pass these messages to the stash so we can keep it updated
|
||||
// about network activity, even if the user isn't currently viewing
|
||||
|
|
@ -324,7 +359,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
func (m *model) View() string {
|
||||
if m.fatalErr != nil {
|
||||
return errorView(m.fatalErr, true)
|
||||
}
|
||||
|
|
@ -369,6 +404,9 @@ func findLocalFiles(m commonModel) tea.Cmd {
|
|||
info, err = os.Stat(cwd)
|
||||
if err == nil && info.IsDir() {
|
||||
cwd, err = filepath.Abs(cwd)
|
||||
} else if err == nil {
|
||||
cwd = filepath.Dir(cwd)
|
||||
cwd, err = filepath.Abs(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +435,7 @@ func findLocalFiles(m commonModel) tea.Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
func findNextLocalFile(m model) tea.Cmd {
|
||||
func findNextLocalFile(m *model) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
res, ok := <-m.localFileFinder
|
||||
|
||||
|
|
@ -432,9 +470,12 @@ func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
|
|||
}
|
||||
|
||||
func stripAbsolutePath(fullPath, cwd string) string {
|
||||
fp, _ := filepath.EvalSymlinks(fullPath)
|
||||
cp, _ := filepath.EvalSymlinks(cwd)
|
||||
return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
|
||||
rel, err := filepath.Rel(cp, fullPath)
|
||||
if err != nil {
|
||||
return fullPath
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// Lightweight version of reflow's indent function.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue