mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-23 00:24:17 +02:00
feat: follow relative markdown links in TUI
This commit is contained in:
parent
53788271b3
commit
883cfa7ce2
5 changed files with 529 additions and 56 deletions
|
|
@ -1,6 +1,9 @@
|
|||
package ui
|
||||
|
||||
const (
|
||||
keyEnter = "enter"
|
||||
keyEsc = "esc"
|
||||
keyTab = "tab"
|
||||
keyShiftTab = "shift+tab"
|
||||
keyEnter = "enter"
|
||||
keyBackspace = "backspace"
|
||||
keyEsc = "esc"
|
||||
)
|
||||
|
|
|
|||
251
ui/pager.go
251
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)
|
||||
}
|
||||
|
|
|
|||
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
|
||||
}
|
||||
27
ui/ui.go
27
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue