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..b146efa 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,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) +} + 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/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) + } +} 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 3537d3f..4779811 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) @@ -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.