mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
anchor link support
This commit is contained in:
parent
e89c8946ce
commit
0968a1acfa
6 changed files with 193 additions and 130 deletions
|
|
@ -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
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
134
view/navigable_markdown.go
Normal 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```"
|
||||
}
|
||||
Loading…
Reference in a new issue