allow navigation in task overview

This commit is contained in:
booleanmaybe 2026-03-25 23:22:13 -04:00
parent 3d3e6c3c6c
commit 6132bea014
6 changed files with 262 additions and 42 deletions

View file

@ -13,7 +13,7 @@ import (
navtview "github.com/boolean-maybe/navidown/navidown/tview"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/util"
"github.com/boolean-maybe/tiki/view"
"github.com/boolean-maybe/tiki/view/markdown"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
@ -41,7 +41,7 @@ func Run(input InputSpec) error {
imgMgr.SetSupported(util.SupportsKittyGraphics())
// Create NavigableMarkdown - OnStateChange is set after creation to avoid forward reference
md := view.NewNavigableMarkdown(view.NavigableMarkdownConfig{
md := markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
Provider: provider,
SearchRoots: input.SearchRoots,
ImageManager: imgMgr,
@ -54,7 +54,7 @@ func Run(input InputSpec) error {
content, sourcePath, err := loadInitialContent(input, provider)
if err != nil {
content = view.FormatErrorContent(err)
content = markdown.FormatErrorContent(err)
}
if sourcePath != "" {

View file

@ -9,6 +9,7 @@ import (
"github.com/boolean-maybe/tiki/controller"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/view/markdown"
"github.com/boolean-maybe/navidown/loaders"
nav "github.com/boolean-maybe/navidown/navidown"
@ -30,7 +31,7 @@ var customMd string
type DokiView struct {
root *tview.Flex
titleBar tview.Primitive
markdown *NavigableMarkdown
md *markdown.NavigableMarkdown
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
imageManager *navtview.ImageManager
@ -80,7 +81,7 @@ func (dv *DokiView) build() {
sourcePath = dv.pluginDef.URL
}
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
dv.md = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
Provider: provider,
SearchRoots: searchRoots,
OnStateChange: dv.UpdateNavigationActions,
@ -97,7 +98,7 @@ func (dv *DokiView) build() {
provider := &internalDokiProvider{content: cnt}
content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
dv.md = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
Provider: provider,
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
@ -106,7 +107,7 @@ func (dv *DokiView) build() {
default:
content = "Error: Unknown fetcher type"
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
dv.md = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
MermaidOptions: dv.mermaidOpts,
@ -120,9 +121,9 @@ func (dv *DokiView) build() {
// Display initial content (don't push to history - this is the first page)
if sourcePath != "" {
dv.markdown.SetMarkdownWithSource(content, sourcePath, false)
dv.md.SetMarkdownWithSource(content, sourcePath, false)
} else {
dv.markdown.SetMarkdown(content)
dv.md.SetMarkdown(content)
}
// root layout
@ -133,7 +134,7 @@ func (dv *DokiView) build() {
func (dv *DokiView) rebuildLayout() {
dv.root.Clear()
dv.root.AddItem(dv.titleBar, 1, 0, false)
dv.root.AddItem(dv.markdown.Viewer(), 0, 1, true)
dv.root.AddItem(dv.md.Viewer(), 0, 1, true)
}
// ShowNavigation returns true — doki views always show plugin navigation keys.
@ -162,7 +163,9 @@ func (dv *DokiView) OnFocus() {
}
func (dv *DokiView) OnBlur() {
// No cleanup needed yet
if dv.md != nil {
dv.md.Close()
}
}
// UpdateNavigationActions updates the registry to reflect current navigation state
@ -187,7 +190,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.markdown.CanGoBack() {
if dv.md.CanGoBack() {
dv.registry.Register(controller.Action{
ID: controller.ActionNavigateBack,
Key: tcell.KeyLeft,
@ -197,7 +200,7 @@ func (dv *DokiView) UpdateNavigationActions() {
}
// Add forward action if available
if dv.markdown.CanGoForward() {
if dv.md.CanGoForward() {
dv.registry.Register(controller.Action{
ID: controller.ActionNavigateForward,
Key: tcell.KeyRight,

View file

@ -1,4 +1,4 @@
package view
package markdown
import (
"strings"

View file

@ -0,0 +1,70 @@
package taskdetail
import (
"fmt"
"strings"
"github.com/boolean-maybe/navidown/loaders"
nav "github.com/boolean-maybe/navidown/navidown"
"github.com/boolean-maybe/tiki/store"
taskpkg "github.com/boolean-maybe/tiki/task"
)
// taskDescriptionProvider is a ContentProvider for task detail descriptions.
// It resolves tiki IDs (e.g., "TIKI-ABC123") from the store and delegates
// file-based links to FileHTTP.
type taskDescriptionProvider struct {
store store.Store
fileHTTP *loaders.FileHTTP
}
func newTaskDescriptionProvider(taskStore store.Store, searchRoots []string) *taskDescriptionProvider {
return &taskDescriptionProvider{
store: taskStore,
fileHTTP: &loaders.FileHTTP{SearchRoots: searchRoots},
}
}
func (p *taskDescriptionProvider) FetchContent(elem nav.NavElement) (string, error) {
if looksLikeTikiID(elem.URL) {
task := p.store.GetTask(elem.URL)
if task == nil {
return "", fmt.Errorf("task %s not found", strings.ToUpper(elem.URL))
}
return formatTaskAsMarkdown(task), nil
}
return p.fileHTTP.FetchContent(elem)
}
// looksLikeTikiID checks if a URL looks like a tiki ID (TIKI-XXXXXX, case-insensitive).
func looksLikeTikiID(url string) bool {
if len(url) != 11 {
return false
}
upper := strings.ToUpper(url)
if upper[:5] != "TIKI-" {
return false
}
for _, c := range upper[5:] {
if (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
return false
}
}
return true
}
// formatTaskAsMarkdown renders a task as a readable markdown document.
func formatTaskAsMarkdown(task *taskpkg.Task) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", task.Title)
fmt.Fprintf(&b, "**%s** · %s · %s", task.ID, task.Status, task.Type)
if task.Priority > 0 {
fmt.Fprintf(&b, " · P%d", task.Priority)
}
b.WriteString("\n\n")
if task.Description != "" {
b.WriteString(task.Description)
b.WriteString("\n")
}
return b.String()
}

View file

@ -0,0 +1,154 @@
package taskdetail
import (
"strings"
"testing"
nav "github.com/boolean-maybe/navidown/navidown"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
func TestLooksLikeTikiID(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"TIKI-ABC123", true},
{"tiki-abc123", true},
{"Tiki-AbC123", true},
{"TIKI-ZZZZZZ", true},
{"TIKI-000000", true},
{"TIKI-ABC12", false}, // too short
{"TIKI-ABC1234", false}, // too long
{"JIRA-ABC123", false},
{"tiki-abc12!", false},
{"", false},
{"not-a-tiki", false},
{"other.md", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := looksLikeTikiID(tt.input); got != tt.want {
t.Errorf("looksLikeTikiID(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestTaskDescriptionProvider_FetchContent_TikiID(t *testing.T) {
s := store.NewInMemoryStore()
_ = s.CreateTask(&task.Task{
ID: "TIKI-ABC123",
Title: "Test Task",
Description: "some description",
Status: task.StatusReady,
Type: task.TypeStory,
Priority: 2,
})
provider := newTaskDescriptionProvider(s, nil)
t.Run("uppercase tiki ID", func(t *testing.T) {
content, err := provider.FetchContent(nav.NavElement{URL: "TIKI-ABC123"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(content, "Test Task") {
t.Errorf("expected title in content, got: %s", content)
}
if !strings.Contains(content, "some description") {
t.Errorf("expected description in content, got: %s", content)
}
if !strings.Contains(content, "P2") {
t.Errorf("expected priority in content, got: %s", content)
}
})
t.Run("lowercase tiki ID", func(t *testing.T) {
content, err := provider.FetchContent(nav.NavElement{URL: "tiki-abc123"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(content, "Test Task") {
t.Errorf("expected title in content, got: %s", content)
}
})
t.Run("not found tiki ID", func(t *testing.T) {
_, err := provider.FetchContent(nav.NavElement{URL: "TIKI-ZZZZZZ"})
if err == nil {
t.Fatal("expected error for missing task")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' in error, got: %v", err)
}
})
t.Run("non-tiki URL falls through", func(t *testing.T) {
// FileHTTP with nil search roots will fail on a nonexistent file,
// but the point is it doesn't try the store path
_, err := provider.FetchContent(nav.NavElement{URL: "other.md"})
if err == nil {
t.Fatal("expected error for nonexistent file")
}
// error should be from FileHTTP, not "not found" task error
if strings.Contains(err.Error(), "not found") && strings.Contains(err.Error(), "TIKI") {
t.Errorf("should not have attempted task lookup for non-tiki URL")
}
})
}
func TestFormatTaskAsMarkdown(t *testing.T) {
t.Run("with all fields", func(t *testing.T) {
tk := &task.Task{
ID: "TIKI-ABC123",
Title: "My Task",
Description: "detailed desc",
Status: task.StatusInProgress,
Type: task.TypeBug,
Priority: 1,
}
md := formatTaskAsMarkdown(tk)
if !strings.HasPrefix(md, "# My Task\n") {
t.Errorf("expected title as h1, got: %s", md)
}
if !strings.Contains(md, "TIKI-ABC123") {
t.Error("expected task ID in output")
}
if !strings.Contains(md, "P1") {
t.Error("expected priority in output")
}
if !strings.Contains(md, "detailed desc") {
t.Error("expected description in output")
}
})
t.Run("no priority", func(t *testing.T) {
tk := &task.Task{
ID: "TIKI-ABC123",
Title: "No Prio",
Status: task.StatusReady,
Type: task.TypeStory,
}
md := formatTaskAsMarkdown(tk)
if strings.Contains(md, "P0") {
t.Error("should not show P0 for zero priority")
}
})
t.Run("empty description", func(t *testing.T) {
tk := &task.Task{
ID: "TIKI-ABC123",
Title: "No Desc",
Status: task.StatusReady,
Type: task.TypeStory,
}
md := formatTaskAsMarkdown(tk)
// should end after the metadata line
lines := strings.Split(strings.TrimSpace(md), "\n")
if len(lines) != 3 { // title, blank, metadata
t.Errorf("expected 3 lines for no-description task, got %d: %q", len(lines), md)
}
})
}

View file

@ -10,10 +10,10 @@ import (
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/store"
taskpkg "github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/view/markdown"
nav "github.com/boolean-maybe/navidown/navidown"
navtview "github.com/boolean-maybe/navidown/navidown/tview"
navutil "github.com/boolean-maybe/navidown/util"
"github.com/rivo/tview"
)
@ -26,6 +26,7 @@ type TaskDetailView struct {
// View-mode specific
storeListenerID int
navMarkdown *markdown.NavigableMarkdown
}
// NewTaskDetailView creates a task detail view.
@ -83,12 +84,20 @@ func (tv *TaskDetailView) OnBlur() {
tv.taskStore.RemoveListener(tv.storeListenerID)
tv.storeListenerID = 0
}
if tv.navMarkdown != nil {
tv.navMarkdown.Close()
tv.navMarkdown = nil
}
}
// refresh re-renders the view
func (tv *TaskDetailView) refresh() {
tv.content.Clear()
tv.descView = nil
if tv.navMarkdown != nil {
tv.navMarkdown.Close()
tv.navMarkdown = nil
}
task := tv.GetTask()
if task == nil {
@ -151,35 +160,19 @@ func (tv *TaskDetailView) buildMetadataColumns(task *taskpkg.Task, ctx FieldRend
func (tv *TaskDetailView) buildDescription(task *taskpkg.Task) tview.Primitive {
desc := defaultString(task.Description, "(No description)")
// Get the source file path for the task to enable relative image resolution
taskSourcePath := getTaskFilePath(task.ID)
searchRoots := []string{config.GetTaskDir()}
viewer := navtview.NewTextView()
viewer.SetAnsiConverter(navutil.NewAnsiConverter(true))
renderer := nav.NewANSIRendererWithStyle(config.GetEffectiveTheme())
if t := config.GetCodeBlockTheme(); t != "" {
renderer = renderer.WithCodeTheme(t)
}
if bg := config.GetCodeBlockBackground(); bg != "" {
renderer = renderer.WithCodeBackground(bg)
}
if b := config.GetCodeBlockBorder(); b != "" {
renderer = renderer.WithCodeBorder(b)
}
viewer.SetRenderer(renderer)
viewer.SetBackgroundColor(config.GetContentBackgroundColor())
if tv.imageManager != nil && tv.imageManager.Supported() {
viewer.SetImageManager(tv.imageManager)
}
if tv.mermaidOpts != nil {
viewer.Core().SetMermaidOptions(tv.mermaidOpts)
}
// Use SetMarkdownWithSource to provide the source file path for relative image resolution
viewer.SetMarkdownWithSource(desc, taskSourcePath, false)
viewer.SetBorderPadding(1, 1, 2, 2)
tv.descView = viewer
return viewer
tv.navMarkdown = markdown.NewNavigableMarkdown(markdown.NavigableMarkdownConfig{
Provider: newTaskDescriptionProvider(tv.taskStore, searchRoots),
SearchRoots: searchRoots,
ImageManager: tv.imageManager,
MermaidOptions: tv.mermaidOpts,
})
tv.navMarkdown.SetMarkdownWithSource(desc, taskSourcePath, false)
tv.navMarkdown.Viewer().SetBorderPadding(1, 1, 2, 2)
tv.descView = tv.navMarkdown.Viewer()
return tv.navMarkdown.Viewer()
}
// getTaskFilePath constructs the file path for a task based on its ID