From 883cfa7ce2a7c3b83ff54b774a690d3882932578 Mon Sep 17 00:00:00 2001 From: Alex Harris Date: Sat, 14 Feb 2026 19:56:19 +0000 Subject: [PATCH 1/9] 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: From 8d0224ee5ef93445713d36e42e6485876822a734 Mon Sep 17 00:00:00 2001 From: Alex Harris Date: Sun, 15 Feb 2026 22:32:17 +0000 Subject: [PATCH 2/9] test: link extraction formats and root-escape protection --- ui/pager_links_test.go | 254 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 ui/pager_links_test.go diff --git a/ui/pager_links_test.go b/ui/pager_links_test.go new file mode 100644 index 0000000..4407ee3 --- /dev/null +++ b/ui/pager_links_test.go @@ -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]().\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: "![Alt](docs/target.md)\n", + want: nil, + }, + { + name: "autolink_relative_is_ignored", + md: "See .\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) + } +} From cf1a50c7a608e1bcf7dc3b67ce32827a5fdd1a00 Mon Sep 17 00:00:00 2001 From: themanyone Date: Wed, 27 May 2026 04:11:56 -0800 Subject: [PATCH 3/9] fix: newModel to return pointer & update receiver types Init(): read body from disk when localPath is set, else from memory This fixes bugs with the past two commits where links were being discarded by newModel create when loading markdown file directly, instead of via the built-in markdown find feature. --- ui/ui.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/ui/ui.go b/ui/ui.go index 6da0196..e48671b 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -130,7 +130,7 @@ func (m *model) unloadDocument() []tea.Cmd { 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 +156,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,7 +166,7 @@ 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 @@ -184,22 +184,27 @@ func newModel(cfg Config, content string) tea.Model { } } - 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))) } - 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) @@ -209,13 +214,12 @@ func (m model) Init() tea.Cmd { m.pager.links = links m.pager.focusedLink = -1 } - 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 { @@ -349,7 +353,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) } @@ -422,7 +426,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 From fe7986f6c83ef838fc4c1973c6660f834ee5f085 Mon Sep 17 00:00:00 2001 From: themanyone Date: Wed, 27 May 2026 20:59:19 -0800 Subject: [PATCH 4/9] enh: visit nearest available link by default --- ui/pager.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui/pager.go b/ui/pager.go index 3cb9380..aa124da 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -254,6 +254,16 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { 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})) } From eab7252dd3026984fe3e83fbf01d863f9245988f Mon Sep 17 00:00:00 2001 From: themanyone Date: Wed, 27 May 2026 21:42:52 -0800 Subject: [PATCH 5/9] fix: sync viewport to show highlighted links --- ui/pager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/pager.go b/ui/pager.go index aa124da..acf5f36 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -231,6 +231,9 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { 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": if len(m.links) == 0 { @@ -246,6 +249,9 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { } } 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: From 28258fec6b9e1eb213852e42b2b53d4f9da5fbc8 Mon Sep 17 00:00:00 2001 From: themanyone Date: Thu, 28 May 2026 02:27:39 -0800 Subject: [PATCH 6/9] fix: make followable links navigation history work --- ui/pager.go | 56 ++++++++++++++++++++++++----------------------------- ui/stash.go | 2 +- ui/ui.go | 13 ++++++------- 3 files changed, 32 insertions(+), 39 deletions(-) diff --git a/ui/pager.go b/ui/pager.go index acf5f36..95f20eb 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -120,7 +120,7 @@ type pagerModel struct { watchCancel chan struct{} } -func newPagerModel(common *commonModel) pagerModel { +func newPagerModel(common *commonModel) *pagerModel { vp := viewport.New(0, 0) vp.YPosition = 0 vp.HighPerformanceRendering = common.cfg.HighPerformancePager @@ -132,7 +132,7 @@ func newPagerModel(common *commonModel) pagerModel { focusedLink: -1, } m.initWatcher() - return m + return &m } func (m *pagerModel) setSize(w, h int) { @@ -206,7 +206,7 @@ func (m *pagerModel) unload() { 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 @@ -220,7 +220,7 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { m.state = pagerStateBrowse return m, nil } - case keyTab: + case keyTab, "down": if len(m.links) == 0 { cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable links", false})) break @@ -235,7 +235,7 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { cmds = append(cmds, viewport.Sync(m.viewport)) } cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false})) - case keyShiftTab, "backtab": + case keyShiftTab, "backtab", "up": if len(m.links) == 0 { cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable links", false})) break @@ -254,7 +254,7 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { } cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false})) - case keyEnter: + case keyEnter, "right": if m.focusedLink >= 0 && m.focusedLink < len(m.links) { cmd := m.followFocusedLink() return m, cmd @@ -273,11 +273,24 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) { cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Tab to select a link", false})) } - case keyBackspace: + case keyBackspace, "left", "h", "delete": if len(m.history) > 0 { - cmd := m.goBack() - return m, cmd + 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.common != nil && m.common.cfg.HighPerformancePager { @@ -365,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") @@ -497,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 { @@ -509,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 { @@ -669,22 +682,3 @@ func (m *pagerModel) followFocusedLink() tea.Cmd { 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/stash.go b/ui/stash.go index c3739b2..2255b74 100644 --- a/ui/stash.go +++ b/ui/stash.go @@ -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 { diff --git a/ui/ui.go b/ui/ui.go index e48671b..b2deeec 100644 --- a/ui/ui.go +++ b/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) @@ -263,12 +264,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 @@ -310,6 +305,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 From 5fc505d42e06c0bafa3dd287977ea7e517eac5f0 Mon Sep 17 00:00:00 2001 From: themanyone Date: Thu, 28 May 2026 02:51:39 -0800 Subject: [PATCH 7/9] enh: change status message from followable to local --- ui/pager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/pager.go b/ui/pager.go index 95f20eb..b146efa 100644 --- a/ui/pager.go +++ b/ui/pager.go @@ -222,7 +222,7 @@ func (m *pagerModel) update(msg tea.Msg) (*pagerModel, tea.Cmd) { } case keyTab, "down": if len(m.links) == 0 { - cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No followable links", false})) + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No local links", false})) break } if m.focusedLink < 0 { @@ -237,7 +237,7 @@ func (m *pagerModel) update(msg tea.Msg) (*pagerModel, tea.Cmd) { 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 followable links", false})) + cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No local links", false})) break } if m.focusedLink < 0 { From 4abc462f8ee660989cd67564831edc4eed7c7fee Mon Sep 17 00:00:00 2001 From: themanyone Date: Thu, 28 May 2026 05:22:29 -0800 Subject: [PATCH 8/9] fix: also populate stash when starting with a document --- ui/ui.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui/ui.go b/ui/ui.go index b2deeec..9a295c0 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -117,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 @@ -128,6 +129,12 @@ 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 } @@ -397,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) } } From 1a4c861c884a5c0a709c1f8a3bb20de668b70d24 Mon Sep 17 00:00:00 2001 From: themanyone Date: Mon, 1 Jun 2026 18:37:15 -0800 Subject: [PATCH 9/9] fix: symlinks appearing as duplicates in pager stash #965 Date: Mon Jun 1 18:37:15 2026 -0800 modified: ui/ui.go details: stripAbsolutePath now uses filepath.Rel instead of EvalSymlinks. That way symlinks keep their own name in the stash. Tests pass. --- ui/ui.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/ui.go b/ui/ui.go index 9a295c0..4779811 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -470,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.