feat: follow relative markdown links in TUI

This commit is contained in:
Alex Harris 2026-02-14 19:56:19 +00:00 committed by themanyone
commit 883cfa7ce2
5 changed files with 529 additions and 56 deletions

View file

@ -1,6 +1,9 @@
package ui
const (
keyEnter = "enter"
keyEsc = "esc"
keyTab = "tab"
keyShiftTab = "shift+tab"
keyEnter = "enter"
keyBackspace = "backspace"
keyEsc = "esc"
)

View file

@ -89,6 +89,11 @@ const (
pagerStateStatusMessage
)
type navEntry struct {
Path string
YOffset int
}
type pagerModel struct {
common *commonModel
viewport viewport.Model
@ -102,19 +107,29 @@ 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
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
@ -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,7 +198,12 @@ 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) {
@ -192,26 +220,56 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
m.state = pagerStateBrowse
return m, nil
}
case keyTab:
if len(m.links) == 0 {
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable links", false}))
break
}
if m.focusedLink < 0 {
m.focusedLink = 0
} else {
m.focusedLink = (m.focusedLink + 1) % len(m.links)
}
m.applyRenderedContent()
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false}))
case keyShiftTab, "backtab":
if len(m.links) == 0 {
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable 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()
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false}))
case keyEnter:
if m.focusedLink >= 0 && m.focusedLink < len(m.links) {
cmd := m.followFocusedLink()
return m, cmd
}
if len(m.links) > 0 {
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Tab to select a link", false}))
}
case keyBackspace:
if len(m.history) > 0 {
cmd := m.goBack()
return m, cmd
}
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 +297,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:
@ -366,26 +436,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)
@ -487,20 +561,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 +603,72 @@ 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)
}
func (m *pagerModel) goBack() tea.Cmd {
if len(m.history) == 0 {
return nil
}
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 loadLocalMarkdown(md)
}

108
ui/pager_highlight.go Normal file
View 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
View 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
}

View file

@ -172,10 +172,14 @@ func newModel(cfg Config, content string) tea.Model {
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(),
}
}
@ -196,6 +200,15 @@ func (m model) Init() tea.Cmd {
return func() tea.Msg { return errMsg{err} }
}
body := string(utils.RemoveFrontmatter(content))
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
}
cmds = append(cmds, renderWithGlamour(m.pager, body))
}
@ -276,6 +289,18 @@ 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: