From 883cfa7ce2a7c3b83ff54b774a690d3882932578 Mon Sep 17 00:00:00 2001 From: Alex Harris Date: Sat, 14 Feb 2026 19:56:19 +0000 Subject: [PATCH] feat: follow relative markdown links in TUI --- ui/keys.go | 7 +- ui/pager.go | 251 ++++++++++++++++++++++++++++++++---------- ui/pager_highlight.go | 108 ++++++++++++++++++ ui/pager_links.go | 198 +++++++++++++++++++++++++++++++++ ui/ui.go | 27 ++++- 5 files changed, 532 insertions(+), 59 deletions(-) create mode 100644 ui/pager_highlight.go create mode 100644 ui/pager_links.go diff --git a/ui/keys.go b/ui/keys.go index 8e13f95..e874465 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -1,6 +1,9 @@ package ui const ( - keyEnter = "enter" - keyEsc = "esc" + keyTab = "tab" + keyShiftTab = "shift+tab" + keyEnter = "enter" + keyBackspace = "backspace" + keyEsc = "esc" ) diff --git a/ui/pager.go b/ui/pager.go index 5c522e7..3cb9380 100644 --- a/ui/pager.go +++ b/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,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) +} diff --git a/ui/pager_highlight.go b/ui/pager_highlight.go new file mode 100644 index 0000000..578755c --- /dev/null +++ b/ui/pager_highlight.go @@ -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 +} diff --git a/ui/pager_links.go b/ui/pager_links.go new file mode 100644 index 0000000..adcbb2e --- /dev/null +++ b/ui/pager_links.go @@ -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 +} diff --git a/ui/ui.go b/ui/ui.go index 3537d3f..6da0196 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -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: