This commit is contained in:
opsec-ai 2026-08-04 18:41:34 +04:00 committed by GitHub
commit 128adb385e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 838 additions and 85 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,22 +107,32 @@ type pagerModel struct {
// it here so we can re-render it on resize.
currentDocument markdown
watcher *fsnotify.Watcher
rendered string
links []followableLink
focusedLink int
history []navEntry
pendingRestoreYOffset *int
watcher *fsnotify.Watcher
watchedDir string
watchCancel chan struct{}
}
func newPagerModel(common *commonModel) pagerModel {
// Init viewport
func newPagerModel(common *commonModel) *pagerModel {
vp := viewport.New(0, 0)
vp.YPosition = 0
vp.HighPerformanceRendering = config.HighPerformancePager
vp.HighPerformanceRendering = common.cfg.HighPerformancePager
m := pagerModel{
common: common,
state: pagerStateBrowse,
viewport: vp,
common: common,
state: pagerStateBrowse,
viewport: vp,
focusedLink: -1,
}
m.initWatcher()
return m
return &m
}
func (m *pagerModel) setSize(w, h int) {
@ -136,6 +151,14 @@ func (m *pagerModel) setContent(s string) {
m.viewport.SetContent(s)
}
func (m *pagerModel) applyRenderedContent() {
content := m.rendered
if m.focusedLink >= 0 {
content = highlightFocusedLink(content, m.links, m.focusedLink)
}
m.setContent(content)
}
func (m *pagerModel) toggleHelp() {
m.showHelp = !m.showHelp
m.setSize(m.common.width, m.common.height)
@ -175,10 +198,15 @@ func (m *pagerModel) unload() {
m.state = pagerStateBrowse
m.viewport.SetContent("")
m.viewport.YOffset = 0
m.unwatchFile()
m.rendered = ""
m.links = nil
m.focusedLink = -1
m.history = nil
m.pendingRestoreYOffset = nil
m.stopWatching()
}
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
func (m *pagerModel) update(msg tea.Msg) (*pagerModel, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
@ -192,26 +220,85 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
m.state = pagerStateBrowse
return m, nil
}
case keyTab, "down":
if len(m.links) == 0 {
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No local links", false}))
break
}
if m.focusedLink < 0 {
m.focusedLink = 0
} else {
m.focusedLink = (m.focusedLink + 1) % len(m.links)
}
m.applyRenderedContent()
if m.common != nil && m.common.cfg.HighPerformancePager {
cmds = append(cmds, viewport.Sync(m.viewport))
}
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false}))
case keyShiftTab, "backtab", "up":
if len(m.links) == 0 {
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"No local links", false}))
break
}
if m.focusedLink < 0 {
m.focusedLink = len(m.links) - 1
} else {
m.focusedLink--
if m.focusedLink < 0 {
m.focusedLink = len(m.links) - 1
}
}
m.applyRenderedContent()
if m.common != nil && m.common.cfg.HighPerformancePager {
cmds = append(cmds, viewport.Sync(m.viewport))
}
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Open: " + m.links[m.focusedLink].ResolvedNote, false}))
case keyEnter, "right":
if m.focusedLink >= 0 && m.focusedLink < len(m.links) {
cmd := m.followFocusedLink()
return m, cmd
}
if len(m.links) > 0 {
// No link selected. Just visit the nearest visible link.
body := strings.TrimSpace(m.currentDocument.Body)
for i, l := range m.links {
if strings.Contains(body, l.Label) {
m.focusedLink = i
cmd := m.followFocusedLink()
return m, cmd
}
}
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Tab to select a link", false}))
}
case keyBackspace, "left", "h", "delete":
if len(m.history) > 0 {
last := m.history[len(m.history)-1]
m.history = m.history[:len(m.history)-1]
m.focusedLink = -1
y := last.YOffset
m.pendingRestoreYOffset = &y
m.viewport.GotoTop()
md := &markdown{
localPath: last.Path,
Note: stripAbsolutePath(last.Path, m.common.cwd),
}
return m, loadLocalMarkdown(md)
}
m.focusedLink = -1
return m, func() tea.Msg { return goBackToStashMsg{} }
case "home", "g":
m.viewport.GotoTop()
if m.viewport.HighPerformanceRendering {
if m.common != nil && m.common.cfg.HighPerformancePager {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "end", "G":
m.viewport.GotoBottom()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "d":
m.viewport.HalfViewDown()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "u":
m.viewport.HalfViewUp()
if m.viewport.HighPerformanceRendering {
if m.common != nil && m.common.cfg.HighPerformancePager {
cmds = append(cmds, viewport.Sync(m.viewport))
}
@ -239,20 +326,32 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
case "?":
m.toggleHelp()
if m.viewport.HighPerformanceRendering {
if m.common != nil && m.common.cfg.HighPerformancePager {
cmds = append(cmds, viewport.Sync(m.viewport))
}
}
case errMsg:
m.pendingRestoreYOffset = nil
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{msg.Error(), true}))
// Glow has rendered the content
case contentRenderedMsg:
log.Info("content rendered", "state", m.state)
m.setContent(string(msg))
if m.viewport.HighPerformanceRendering {
m.rendered = string(msg)
m.applyRenderedContent()
if m.pendingRestoreYOffset != nil {
m.viewport.YOffset = *m.pendingRestoreYOffset
if m.viewport.PastBottom() {
m.viewport.GotoBottom()
}
m.pendingRestoreYOffset = nil
}
if m.common != nil && m.common.cfg.HighPerformancePager {
cmds = append(cmds, viewport.Sync(m.viewport))
}
cmds = append(cmds, m.watchFile)
cmds = append(cmds, m.startWatching())
// The file was changed on disk and we're reloading it
case reloadMsg:
@ -279,7 +378,7 @@ func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m pagerModel) View() string {
func (m *pagerModel) View() string {
var b strings.Builder
fmt.Fprint(&b, m.viewport.View()+"\n")
@ -366,26 +465,30 @@ func (m pagerModel) statusBarView(b *strings.Builder) {
}
func (m pagerModel) helpView() (s string) {
col1 := []string{
"g/home go to top",
"G/end go to bottom",
"c copy contents",
"e edit this document",
"r reload this document",
"esc back to files",
"q quit",
rows := [][2]string{
{"k/↑ up", "g/home go to top"},
{"j/↓ down", "G/end go to bottom"},
{"b/pgup page up", "tab next link"},
{"f/pgdn page down", "⇧tab prev link"},
{"u ½ page up", "enter follow link"},
{"d ½ page down", "⌫ go back"},
{"", "c copy contents"},
{"", "e edit this document"},
{"", "r reload this document"},
{"", "esc back to files"},
{"", "q quit"},
}
s += "\n"
s += "k/↑ up " + col1[0] + "\n"
s += "j/↓ down " + col1[1] + "\n"
s += "b/pgup page up " + col1[2] + "\n"
s += "f/pgdn page down " + col1[3] + "\n"
s += "u ½ page up " + col1[4] + "\n"
s += "d ½ page down "
if len(col1) > 5 {
s += col1[5]
for _, row := range rows {
left := row[0]
right := row[1]
if left != "" {
left = fmt.Sprintf("%-24s", left)
} else {
left = strings.Repeat(" ", 24)
}
s += left + right + "\n"
}
s = indent(s, 2)
@ -407,7 +510,7 @@ func (m pagerModel) helpView() (s string) {
// COMMANDS
func renderWithGlamour(m pagerModel, md string) tea.Cmd {
func renderWithGlamour(m *pagerModel, md string) tea.Cmd {
return func() tea.Msg {
s, err := glamourRender(m, md)
if err != nil {
@ -419,7 +522,7 @@ func renderWithGlamour(m pagerModel, md string) tea.Cmd {
}
// This is where the magic happens.
func glamourRender(m pagerModel, markdown string) (string, error) {
func glamourRender(m *pagerModel, markdown string) (string, error) {
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
if !config.GlamourEnabled {
@ -487,20 +590,37 @@ func (m *pagerModel) initWatcher() {
}
}
func (m *pagerModel) watchFile() tea.Msg {
dir := m.localDir()
func (m *pagerModel) startWatching() tea.Cmd {
if m.watcher == nil || m.currentDocument.localPath == "" {
return nil
}
m.stopWatching()
dir := m.localDir()
if err := m.watcher.Add(dir); err != nil {
log.Error("error adding dir to fsnotify watcher", "error", err)
return nil
}
m.watchedDir = dir
m.watchCancel = make(chan struct{})
log.Info("fsnotify watching dir", "dir", dir)
cancel := m.watchCancel
return func() tea.Msg { return m.watchFile(cancel) }
}
func (m *pagerModel) watchFile(cancel <-chan struct{}) tea.Msg {
log.Info("fsnotify watching dir", "dir", m.watchedDir)
for {
select {
case <-cancel:
return nil
case event, ok := <-m.watcher.Events:
if !ok || event.Name != m.currentDocument.localPath {
if !ok {
return nil
}
if event.Name != m.currentDocument.localPath {
continue
}
@ -512,24 +632,53 @@ func (m *pagerModel) watchFile() tea.Msg {
return reloadMsg{}
case err, ok := <-m.watcher.Errors:
if !ok {
continue
return nil
}
log.Debug("fsnotify error", "dir", dir, "error", err)
log.Debug("fsnotify error", "dir", m.watchedDir, "error", err)
}
}
}
func (m *pagerModel) unwatchFile() {
dir := m.localDir()
err := m.watcher.Remove(dir)
if err == nil {
log.Debug("fsnotify dir unwatched", "dir", dir)
} else {
log.Error("fsnotify fail to unwatch dir", "dir", dir, "error", err)
func (m *pagerModel) stopWatching() {
if m.watchCancel != nil {
close(m.watchCancel)
m.watchCancel = nil
}
if m.watcher == nil || m.watchedDir == "" {
return
}
err := m.watcher.Remove(m.watchedDir)
if err == nil {
log.Debug("fsnotify dir unwatched", "dir", m.watchedDir)
} else {
log.Error("fsnotify fail to unwatch dir", "dir", m.watchedDir, "error", err)
}
m.watchedDir = ""
}
func (m *pagerModel) localDir() string {
return filepath.Dir(m.currentDocument.localPath)
}
func (m *pagerModel) followFocusedLink() tea.Cmd {
l := m.links[m.focusedLink]
if l.ResolvedPath == "" {
return nil
}
if m.currentDocument.localPath != "" {
m.history = append(m.history, navEntry{Path: m.currentDocument.localPath, YOffset: m.viewport.YOffset})
}
m.focusedLink = -1
m.viewport.GotoTop()
m.pendingRestoreYOffset = nil
md := &markdown{
localPath: l.ResolvedPath,
Note: l.ResolvedNote,
}
return loadLocalMarkdown(md)
}

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

254
ui/pager_links_test.go Normal file
View file

@ -0,0 +1,254 @@
package ui
import (
"os"
"path/filepath"
"testing"
)
func TestFollowableLinksForDocument_CommonFormatsAndSafety(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "root")
outside := filepath.Join(base, "outside")
mustMkdirAll(t, filepath.Join(root, "docs"))
mustMkdirAll(t, outside)
currentFilePath := filepath.Join(root, "current.md")
mustWriteFile(t, currentFilePath, "# Current\n")
targetMD := filepath.Join(root, "docs", "target.md")
targetMarkdown := filepath.Join(root, "docs", "target.markdown")
spaceNameMD := filepath.Join(root, "docs", "SPACE NAME.md")
mustWriteFile(t, targetMD, "# Target\n")
mustWriteFile(t, targetMarkdown, "# Target Markdown\n")
mustWriteFile(t, spaceNameMD, "# Space Name\n")
outsideMD := filepath.Join(outside, "outside.md")
mustWriteFile(t, outsideMD, "# Outside\n")
// A directory with a markdown-looking name should not be followable.
dirLooksLikeMD := filepath.Join(root, "docs", "dir.md")
mustMkdirAll(t, dirLooksLikeMD)
rootAbs := absEvalSymlinks(t, root)
type wantLink struct {
Label string
ResolvedPath string
ResolvedNote string
Fragment string
}
targetAbs := absEvalSymlinks(t, targetMD)
targetMarkdownAbs := absEvalSymlinks(t, targetMarkdown)
spaceNameAbs := absEvalSymlinks(t, spaceNameMD)
cases := []struct {
name string
md string
want []wantLink
setup func(t *testing.T)
}{
{
name: "inline_relative_md",
md: "See [Target](docs/target.md).\n",
want: []wantLink{{
Label: "Target",
ResolvedPath: targetAbs,
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
}},
},
{
name: "reference_relative_md",
md: "See [Target][id].\n\n[id]: docs/target.md\n",
want: []wantLink{{
Label: "Target",
ResolvedPath: targetAbs,
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
}},
},
{
name: "collapsed_reference_relative_md",
md: "[Target][]\n\n[Target]: docs/target.md\n",
want: []wantLink{{
Label: "Target",
ResolvedPath: targetAbs,
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
}},
},
{
name: "relative_md_with_fragment",
md: "See [Target](docs/target.md#section).\n",
want: []wantLink{{
Label: "Target",
ResolvedPath: targetAbs,
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
Fragment: "section",
}},
},
{
name: "destination_in_angle_brackets",
md: "See [Target](<docs/target.md>).\n",
want: []wantLink{{
Label: "Target",
ResolvedPath: targetAbs,
ResolvedNote: stripAbsolutePath(targetAbs, rootAbs),
}},
},
{
name: "url_escaped_path_is_unescaped",
md: "See [Space](docs/SPACE%20NAME.md).\n",
want: []wantLink{{
Label: "Space",
ResolvedPath: spaceNameAbs,
ResolvedNote: stripAbsolutePath(spaceNameAbs, rootAbs),
}},
},
{
name: "relative_markdown_extension",
md: "See [Target](docs/target.markdown).\n",
want: []wantLink{{
Label: "Target",
ResolvedPath: targetMarkdownAbs,
ResolvedNote: stripAbsolutePath(targetMarkdownAbs, rootAbs),
}},
},
{
name: "empty_label_is_ignored",
md: "See [](docs/target.md).\n",
want: nil,
},
{
name: "image_is_ignored",
md: "![Alt](docs/target.md)\n",
want: nil,
},
{
name: "autolink_relative_is_ignored",
md: "See <docs/target.md>.\n",
want: nil,
},
{
name: "bare_path_is_ignored",
md: "docs/target.md\n",
want: nil,
},
{
name: "external_http_is_ignored",
md: "See [Ext](https://example.com/docs/target.md).\n",
want: nil,
},
{
name: "mailto_is_ignored",
md: "See [Mail](mailto:test@example.com).\n",
want: nil,
},
{
name: "non_markdown_extension_is_ignored",
md: "See [Txt](docs/target.txt).\n",
want: nil,
},
{
name: "missing_file_is_ignored",
md: "See [Missing](docs/missing.md).\n",
want: nil,
},
{
name: "directory_is_ignored_even_if_md_suffix",
md: "See [Dir](docs/dir.md).\n",
want: nil,
},
{
name: "absolute_unix_path_is_ignored",
md: "See [Abs](/etc/passwd).\n",
want: nil,
},
{
name: "windows_drive_absolute_is_ignored",
md: `See [WinAbs](C:\\Windows\\system32\\a.md).`,
want: nil,
},
{
name: "windows_unc_absolute_is_ignored",
md: `See [UNC](\\\\server\\share\\a.md).`,
want: nil,
},
{
name: "root_escape_via_dotdot_is_ignored",
md: "See [Escape](../outside/outside.md).\n",
want: nil,
},
{
name: "root_escape_via_symlink_is_ignored",
md: "See [Escape](escape/outside.md).\n",
want: nil,
setup: func(t *testing.T) {
t.Helper()
escape := filepath.Join(root, "escape")
if err := os.Symlink(outside, escape); err != nil {
t.Skipf("symlink not supported: %v", err)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.setup != nil {
tc.setup(t)
}
got, err := followableLinksForDocument(root, currentFilePath, tc.md)
if err != nil {
t.Fatalf("followableLinksForDocument returned error: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("expected %d links, got %d: %+v", len(tc.want), len(got), got)
}
for i := range tc.want {
if got[i].Label != tc.want[i].Label {
t.Fatalf("link[%d] label: expected %q, got %q", i, tc.want[i].Label, got[i].Label)
}
if got[i].ResolvedPath != tc.want[i].ResolvedPath {
t.Fatalf("link[%d] resolved path: expected %q, got %q", i, tc.want[i].ResolvedPath, got[i].ResolvedPath)
}
if got[i].ResolvedNote != tc.want[i].ResolvedNote {
t.Fatalf("link[%d] resolved note: expected %q, got %q", i, tc.want[i].ResolvedNote, got[i].ResolvedNote)
}
if got[i].Fragment != tc.want[i].Fragment {
t.Fatalf("link[%d] fragment: expected %q, got %q", i, tc.want[i].Fragment, got[i].Fragment)
}
}
})
}
}
func absEvalSymlinks(t *testing.T, path string) string {
t.Helper()
abs, err := filepath.Abs(path)
if err != nil {
t.Fatalf("abs %q: %v", path, err)
}
if eval, err := filepath.EvalSymlinks(abs); err == nil {
return eval
}
return abs
}
func mustMkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdirall %q: %v", path, err)
}
}
func mustWriteFile(t *testing.T, path, contents string) {
t.Helper()
mustMkdirAll(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("writefile %q: %v", path, err)
}
}

View file

@ -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 {

View file

@ -63,6 +63,7 @@ type (
foundLocalFileMsg gitcha.SearchResult
localFileSearchFinished struct{}
statusMessageTimeoutMsg applicationContext
goBackToStashMsg struct{}
)
// applicationContext indicates the area of the application something applies
@ -104,7 +105,7 @@ type model struct {
// Sub-models
stash stashModel
pager pagerModel
pager *pagerModel
// Channel that receives paths to local markdown files
// (via the github.com/muesli/gitcha package)
@ -116,6 +117,7 @@ type model struct {
func (m *model) unloadDocument() []tea.Cmd {
m.state = stateShowStash
m.stash.viewState = stashStateReady
m.stash.markdowns = nil
m.pager.unload()
m.pager.showHelp = false
@ -127,10 +129,16 @@ func (m *model) unloadDocument() []tea.Cmd {
if !m.stash.shouldSpin() {
batch = append(batch, m.stash.spinner.Tick)
}
// If we are transitioning from a document to the stash, we need to populate the stash.
if m.common.cwd != "" {
batch = append(batch, findLocalFiles(*m.common))
}
return batch
}
func newModel(cfg Config, content string) tea.Model {
func newModel(cfg Config, content string) *model {
initSections()
if cfg.GlamourStyle == styles.AutoStyle {
@ -156,7 +164,7 @@ func newModel(cfg Config, content string) tea.Model {
if path == "" && content != "" {
m.state = stateShowDocument
m.pager.currentDocument = markdown{Body: content}
return m
return &m
}
if path == "" {
@ -166,43 +174,60 @@ func newModel(cfg Config, content string) tea.Model {
if err != nil {
log.Error("unable to stat file", "file", path, "error", err)
m.fatalErr = err
return m
return &m
}
if info.IsDir() {
m.state = stateShowStash
} else {
cwd, _ := os.Getwd()
m.common.cwd = cwd
if rel, err := filepath.Rel(m.common.cwd, path); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
m.common.cwd = filepath.Dir(path)
}
m.state = stateShowDocument
m.pager.currentDocument = markdown{
localPath: path,
Note: stripAbsolutePath(path, cwd),
Note: stripAbsolutePath(path, m.common.cwd),
Modtime: info.ModTime(),
}
}
return m
return &m
}
func (m model) Init() tea.Cmd {
func (m *model) Init() tea.Cmd {
cmds := []tea.Cmd{m.stash.spinner.Tick}
switch m.state {
case stateShowStash:
cmds = append(cmds, findLocalFiles(*m.common))
case stateShowDocument:
content, err := os.ReadFile(m.common.cfg.Path)
if err != nil {
log.Error("unable to read file", "file", m.common.cfg.Path, "error", err)
return func() tea.Msg { return errMsg{err} }
var body string
if m.pager.currentDocument.localPath != "" {
content, err := os.ReadFile(m.pager.currentDocument.localPath)
if err != nil {
log.Error("unable to read file", "file", m.pager.currentDocument.localPath, "error", err)
return func() tea.Msg { return errMsg{err} }
}
body = string(utils.RemoveFrontmatter(content))
} else {
body = string(utils.RemoveFrontmatter([]byte(m.pager.currentDocument.Body)))
}
m.pager.currentDocument.Body = body
if m.pager.currentDocument.localPath != "" && m.common.cwd != "" {
links, err := followableLinksForDocument(m.common.cwd, m.pager.currentDocument.localPath, body)
if err != nil {
log.Debug("error extracting followable links", "error", err)
}
m.pager.links = links
m.pager.focusedLink = -1
}
body := string(utils.RemoveFrontmatter(content))
cmds = append(cmds, renderWithGlamour(m.pager, body))
}
return tea.Batch(cmds...)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// If there's been an error, any key exits
if m.fatalErr != nil {
if _, ok := msg.(tea.KeyMsg); ok {
@ -246,12 +271,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "left", "h", "delete":
if m.state == stateShowDocument {
cmds = append(cmds, m.unloadDocument()...)
return m, tea.Batch(cmds...)
}
case "ctrl+z":
return m, tea.Suspend
@ -276,11 +295,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// We've loaded a markdown file's contents for rendering
m.pager.currentDocument = *msg
body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
m.pager.currentDocument.Body = body
if m.pager.currentDocument.localPath != "" && m.common.cwd != "" {
links, err := followableLinksForDocument(m.common.cwd, m.pager.currentDocument.localPath, body)
if err != nil {
log.Debug("error extracting followable links", "error", err)
}
m.pager.links = links
m.pager.focusedLink = -1
} else {
m.pager.links = nil
m.pager.focusedLink = -1
}
cmds = append(cmds, renderWithGlamour(m.pager, body))
case contentRenderedMsg:
m.state = stateShowDocument
case goBackToStashMsg:
batch := m.unloadDocument()
return m, tea.Batch(batch...)
case localFileSearchFinished:
// Always pass these messages to the stash so we can keep it updated
// about network activity, even if the user isn't currently viewing
@ -324,7 +359,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m model) View() string {
func (m *model) View() string {
if m.fatalErr != nil {
return errorView(m.fatalErr, true)
}
@ -369,6 +404,9 @@ func findLocalFiles(m commonModel) tea.Cmd {
info, err = os.Stat(cwd)
if err == nil && info.IsDir() {
cwd, err = filepath.Abs(cwd)
} else if err == nil {
cwd = filepath.Dir(cwd)
cwd, err = filepath.Abs(cwd)
}
}
@ -397,7 +435,7 @@ func findLocalFiles(m commonModel) tea.Cmd {
}
}
func findNextLocalFile(m model) tea.Cmd {
func findNextLocalFile(m *model) tea.Cmd {
return func() tea.Msg {
res, ok := <-m.localFileFinder
@ -432,9 +470,12 @@ func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
}
func stripAbsolutePath(fullPath, cwd string) string {
fp, _ := filepath.EvalSymlinks(fullPath)
cp, _ := filepath.EvalSymlinks(cwd)
return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
rel, err := filepath.Rel(cp, fullPath)
if err != nil {
return fullPath
}
return rel
}
// Lightweight version of reflow's indent function.