anchor link support

This commit is contained in:
booleanmaybe 2026-01-30 13:06:12 -05:00
parent e89c8946ce
commit 0968a1acfa
6 changed files with 193 additions and 130 deletions

View file

@ -28,7 +28,7 @@ press `q` to quit
## Navigate links
with a Markdown file open press `Tab/Shift-Tab` to select next/previous link in the file
then press Enter to load the linked file
then press Enter to load the linked file or go to a linked section within the same file
to go back/forward in history use `Left/Right` or `Alt-Left/Alt-Right`
## Pager commands

2
go.mod
View file

@ -3,7 +3,7 @@ module github.com/boolean-maybe/tiki
go 1.24.2
require (
github.com/boolean-maybe/navidown v0.1.1
github.com/boolean-maybe/navidown v0.2.0
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0

4
go.sum
View file

@ -25,8 +25,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/boolean-maybe/navidown v0.1.1 h1:eNBaVSXoJ9ou2Irh4gwacSxpmorCqJRTzgjsQxLYjx4=
github.com/boolean-maybe/navidown v0.1.1/go.mod h1:WdI3A007LaGuaJTOwEVO25kMojD9u4SCEVqAT8H9rwQ=
github.com/boolean-maybe/navidown v0.2.0 h1:oXTlci7wOc0gQTsibGoFYkffALkqD4DL8KXzJ/TnBEI=
github.com/boolean-maybe/navidown v0.2.0/go.mod h1:WdI3A007LaGuaJTOwEVO25kMojD9u4SCEVqAT8H9rwQ=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=

View file

@ -11,9 +11,9 @@ import (
"github.com/boolean-maybe/navidown/loaders"
nav "github.com/boolean-maybe/navidown/navidown"
navtview "github.com/boolean-maybe/navidown/navidown/tview"
navutil "github.com/boolean-maybe/navidown/util"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/util"
"github.com/boolean-maybe/tiki/view"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
@ -27,55 +27,40 @@ func Run(input InputSpec) error {
}
app := tview.NewApplication()
viewer := navtview.NewTextView()
viewer.SetAnsiConverter(navutil.NewAnsiConverter(true))
viewer.SetRenderer(nav.NewANSIRendererWithStyle(config.GetEffectiveTheme()))
viewer.SetBackgroundColor(config.GetContentBackgroundColor())
provider := &loaders.FileHTTP{SearchRoots: input.SearchRoots}
content, sourcePath, err := loadInitialContent(input, provider)
if err != nil {
content = formatErrorContent(err)
}
if sourcePath != "" {
viewer.SetMarkdownWithSource(content, sourcePath, false)
} else {
viewer.SetMarkdown(content)
}
viewer.SetSelectHandler(func(v *navtview.TextViewViewer, elem nav.NavElement) {
if elem.Type != nav.NavElementURL {
return
}
content, err := provider.FetchContent(elem)
if err != nil {
v.SetMarkdown(formatErrorContent(err))
return
}
if content == "" {
return
}
v.SetMarkdownWithSource(content, resolveSourcePath(elem, input.SearchRoots), true)
})
// create status bar
statusBar := tview.NewTextView()
statusBar.SetDynamicColors(true)
statusBar.SetTextAlign(tview.AlignLeft)
viewer.SetStateChangedHandler(func(v *navtview.TextViewViewer) {
updateStatusBar(statusBar, v)
// Create NavigableMarkdown - OnStateChange is set after creation to avoid forward reference
md := view.NewNavigableMarkdown(view.NavigableMarkdownConfig{
Provider: provider,
SearchRoots: input.SearchRoots,
})
md.SetStateChangedHandler(func() {
updateStatusBar(statusBar, md.Viewer())
})
content, sourcePath, err := loadInitialContent(input, provider)
if err != nil {
content = view.FormatErrorContent(err)
}
if sourcePath != "" {
md.SetMarkdownWithSource(content, sourcePath, false)
} else {
md.SetMarkdown(content)
}
// initial status bar update
updateStatusBar(statusBar, viewer)
updateStatusBar(statusBar, md.Viewer())
// create flex layout with status bar
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(viewer, 0, 1, true).
AddItem(md.Viewer(), 0, 1, true).
AddItem(statusBar, 1, 0, false)
// key handlers
@ -85,7 +70,7 @@ func Run(input InputSpec) error {
app.Stop()
return nil
case 'e':
srcPath := viewer.Core().SourceFilePath()
srcPath := md.SourceFilePath()
if srcPath == "" || strings.HasPrefix(srcPath, "http://") || strings.HasPrefix(srcPath, "https://") {
return nil
}
@ -103,8 +88,8 @@ func Run(input InputSpec) error {
slog.Error("failed to reload file after edit", "file", srcPath, "error", err)
return nil
}
viewer.SetMarkdownWithSource(string(data), srcPath, false)
updateStatusBar(statusBar, viewer)
md.SetMarkdownWithSource(string(data), srcPath, false)
updateStatusBar(statusBar, md.Viewer())
return nil
}
return event
@ -164,21 +149,6 @@ func resolveInitialSource(candidate string, searchRoots []string) string {
return resolved
}
func resolveSourcePath(elem nav.NavElement, searchRoots []string) string {
if elem.SourceFilePath == "" {
return elem.URL
}
resolved, err := nav.ResolveMarkdownPath(elem.URL, elem.SourceFilePath, searchRoots)
if err != nil || resolved == "" {
return elem.URL
}
return resolved
}
func formatErrorContent(err error) string {
return "# Error\n\n```\n" + err.Error() + "\n```"
}
// updateStatusBar refreshes the status bar with current viewer state.
func updateStatusBar(statusBar *tview.TextView, v *navtview.TextViewViewer) {
core := v.Core()

View file

@ -13,8 +13,6 @@ import (
"github.com/boolean-maybe/navidown/loaders"
nav "github.com/boolean-maybe/navidown/navidown"
navtview "github.com/boolean-maybe/navidown/navidown/tview"
navutil "github.com/boolean-maybe/navidown/util"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
@ -30,12 +28,12 @@ var customMd string
// DokiView renders a documentation plugin (navigable markdown)
type DokiView struct {
root *tview.Flex
titleBar tview.Primitive
contentView *navtview.TextViewViewer
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
renderer renderer.MarkdownRenderer
root *tview.Flex
titleBar tview.Primitive
markdown *NavigableMarkdown
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
renderer renderer.MarkdownRenderer
}
// NewDokiView creates a doki view
@ -61,19 +59,9 @@ func (dv *DokiView) build() {
}
dv.titleBar = NewGradientCaptionRow([]string{dv.pluginDef.Name}, dv.pluginDef.Background, textColor)
// content view (Navigable Markdown)
dv.contentView = navtview.NewTextView()
dv.contentView.SetAnsiConverter(navutil.NewAnsiConverter(true))
dv.contentView.SetRenderer(nav.NewANSIRendererWithStyle(config.GetEffectiveTheme()))
dv.contentView.SetBackgroundColor(config.GetContentBackgroundColor())
// Set up state change handler to update navigation actions
dv.contentView.SetStateChangedHandler(func(_ *navtview.TextViewViewer) {
dv.UpdateNavigationActions()
})
// Fetch initial content using component fetchers
// Fetch initial content and create NavigableMarkdown with appropriate provider
var content string
var sourcePath string
var err error
switch dv.pluginDef.Fetcher {
@ -81,32 +69,18 @@ func (dv *DokiView) build() {
searchRoots := []string{config.GetDokiDir()}
provider := &loaders.FileHTTP{SearchRoots: searchRoots}
// Fetch initial content (no source context yet; rely on searchRoots)
content, err = provider.FetchContent(nav.NavElement{URL: dv.pluginDef.URL})
// Set up link navigation for file-based docs
dv.contentView.SetSelectHandler(func(v *navtview.TextViewViewer, elem nav.NavElement) {
if elem.Type != nav.NavElementURL {
return
}
content, err := provider.FetchContent(elem)
if err != nil {
errorContent := "# Error\n\nFailed to load `" + elem.URL + "`:\n\n```\n" + err.Error() + "\n```"
v.SetMarkdown(errorContent)
return
}
if content == "" {
return
}
// Resolve path for source context
newSourcePath := elem.URL
if elem.SourceFilePath != "" {
resolved, rerr := nav.ResolveMarkdownPath(elem.URL, elem.SourceFilePath, searchRoots)
if rerr == nil && resolved != "" {
newSourcePath = resolved
}
}
v.SetMarkdownWithSource(content, newSourcePath, true)
// Resolve initial source path for stable relative navigation
sourcePath, _ = nav.ResolveMarkdownPath(dv.pluginDef.URL, "", searchRoots)
if sourcePath == "" {
sourcePath = dv.pluginDef.URL
}
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
Provider: provider,
SearchRoots: searchRoots,
OnStateChange: dv.UpdateNavigationActions,
})
case "internal":
@ -118,26 +92,16 @@ func (dv *DokiView) build() {
provider := &internalDokiProvider{content: cnt}
content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})
// Set up link navigation (internal docs use text as source path for history)
dv.contentView.SetSelectHandler(func(v *navtview.TextViewViewer, elem nav.NavElement) {
if elem.Type != nav.NavElementURL {
return
}
content, err := provider.FetchContent(elem)
if err != nil {
errorContent := "# Error\n\nFailed to load content:\n\n```\n" + err.Error() + "\n```"
v.SetMarkdown(errorContent)
return
}
if content == "" {
return
}
// Use elem.Text as source path for history tracking
v.SetMarkdownWithSource(content, elem.Text, true)
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
Provider: provider,
OnStateChange: dv.UpdateNavigationActions,
})
default:
content = "Error: Unknown fetcher type"
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
OnStateChange: dv.UpdateNavigationActions,
})
}
if err != nil {
@ -145,16 +109,11 @@ func (dv *DokiView) build() {
content = fmt.Sprintf("Error loading content: %v", err)
}
// Display initial content with source context (don't push to history - this is the first page)
if dv.pluginDef.Fetcher == "file" {
// Try to resolve the initial URL so subsequent relative navigation has a stable source path.
sourcePath, rerr := nav.ResolveMarkdownPath(dv.pluginDef.URL, "", []string{config.GetDokiDir()})
if rerr != nil || sourcePath == "" {
sourcePath = dv.pluginDef.URL
}
dv.contentView.SetMarkdownWithSource(content, sourcePath, false)
// Display initial content (don't push to history - this is the first page)
if sourcePath != "" {
dv.markdown.SetMarkdownWithSource(content, sourcePath, false)
} else {
dv.contentView.SetMarkdown(content)
dv.markdown.SetMarkdown(content)
}
// root layout
@ -165,7 +124,7 @@ func (dv *DokiView) build() {
func (dv *DokiView) rebuildLayout() {
dv.root.Clear()
dv.root.AddItem(dv.titleBar, 1, 0, false)
dv.root.AddItem(dv.contentView, 0, 1, true)
dv.root.AddItem(dv.markdown.Viewer(), 0, 1, true)
}
func (dv *DokiView) GetPrimitive() tview.Primitive {
@ -210,7 +169,7 @@ func (dv *DokiView) UpdateNavigationActions() {
// Add back action if available
// Note: navidown supports both plain Left/Right and Alt+Left/Right for navigation
// We register plain arrows since they're simpler and work in all terminals
if dv.contentView.Core().CanGoBack() {
if dv.markdown.CanGoBack() {
dv.registry.Register(controller.Action{
ID: controller.ActionNavigateBack,
Key: tcell.KeyLeft,
@ -220,7 +179,7 @@ func (dv *DokiView) UpdateNavigationActions() {
}
// Add forward action if available
if dv.contentView.Core().CanGoForward() {
if dv.markdown.CanGoForward() {
dv.registry.Register(controller.Action{
ID: controller.ActionNavigateForward,
Key: tcell.KeyRight,

134
view/navigable_markdown.go Normal file
View file

@ -0,0 +1,134 @@
package view
import (
"strings"
"github.com/boolean-maybe/tiki/config"
nav "github.com/boolean-maybe/navidown/navidown"
navtview "github.com/boolean-maybe/navidown/navidown/tview"
navutil "github.com/boolean-maybe/navidown/util"
)
// NavigableMarkdown wraps navidown TextViewViewer with link/anchor handling.
type NavigableMarkdown struct {
viewer *navtview.TextViewViewer
provider nav.ContentProvider
searchRoots []string
onStateChange func()
}
// NavigableMarkdownConfig configures a NavigableMarkdown component.
type NavigableMarkdownConfig struct {
Provider nav.ContentProvider
SearchRoots []string
OnStateChange func() // called on navigation state changes
}
// NewNavigableMarkdown creates a new navigable markdown viewer.
func NewNavigableMarkdown(cfg NavigableMarkdownConfig) *NavigableMarkdown {
nm := &NavigableMarkdown{
viewer: navtview.NewTextView(),
provider: cfg.Provider,
searchRoots: cfg.SearchRoots,
onStateChange: cfg.OnStateChange,
}
nm.viewer.SetAnsiConverter(navutil.NewAnsiConverter(true))
nm.viewer.SetRenderer(nav.NewANSIRendererWithStyle(config.GetEffectiveTheme()))
nm.viewer.SetBackgroundColor(config.GetContentBackgroundColor())
nm.viewer.SetStateChangedHandler(func(_ *navtview.TextViewViewer) {
if nm.onStateChange != nil {
nm.onStateChange()
}
})
nm.setupSelectHandler()
return nm
}
func (nm *NavigableMarkdown) setupSelectHandler() {
nm.viewer.SetSelectHandler(func(v *navtview.TextViewViewer, elem nav.NavElement) {
if elem.Type != nav.NavElementURL {
return
}
// Internal anchor (same file)
if elem.IsInternalLink() {
v.ScrollToAnchor(elem.AnchorTarget(), true)
return
}
// Cross-file (possibly with anchor)
path, fragment := splitURLFragment(elem.URL)
content, err := nm.provider.FetchContent(nav.NavElement{
URL: path,
SourceFilePath: elem.SourceFilePath,
Type: elem.Type,
})
if err != nil {
v.SetMarkdown(FormatErrorContent(err))
return
}
if content == "" {
return
}
v.SetMarkdownWithSource(content, nm.resolveSourcePath(path, elem.SourceFilePath), true)
if fragment != "" {
v.ScrollToAnchor(fragment, false)
}
})
}
func (nm *NavigableMarkdown) resolveSourcePath(url, sourceFile string) string {
if sourceFile == "" {
return url
}
resolved, err := nav.ResolveMarkdownPath(url, sourceFile, nm.searchRoots)
if err != nil || resolved == "" {
return url
}
return resolved
}
// Viewer returns the underlying TextViewViewer for layout embedding.
func (nm *NavigableMarkdown) Viewer() *navtview.TextViewViewer {
return nm.viewer
}
// CanGoBack returns true if back navigation is available.
func (nm *NavigableMarkdown) CanGoBack() bool {
return nm.viewer.Core().CanGoBack()
}
// CanGoForward returns true if forward navigation is available.
func (nm *NavigableMarkdown) CanGoForward() bool {
return nm.viewer.Core().CanGoForward()
}
// SourceFilePath returns the current source file path.
func (nm *NavigableMarkdown) SourceFilePath() string {
return nm.viewer.Core().SourceFilePath()
}
// SetMarkdown sets markdown content without source context.
func (nm *NavigableMarkdown) SetMarkdown(content string) {
nm.viewer.SetMarkdown(content)
}
// SetMarkdownWithSource sets markdown content with source context.
func (nm *NavigableMarkdown) SetMarkdownWithSource(content, source string, pushHistory bool) {
nm.viewer.SetMarkdownWithSource(content, source, pushHistory)
}
// SetStateChangedHandler sets the callback for navigation state changes.
// This is useful when the handler needs to reference the NavigableMarkdown instance.
func (nm *NavigableMarkdown) SetStateChangedHandler(handler func()) {
nm.onStateChange = handler
}
func splitURLFragment(url string) (path, fragment string) {
path, fragment, _ = strings.Cut(url, "#")
return path, fragment
}
// FormatErrorContent formats an error as markdown content.
func FormatErrorContent(err error) string {
return "# Error\n\n```\n" + err.Error() + "\n```"
}