mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
allow navigation in task overview
This commit is contained in:
parent
3d3e6c3c6c
commit
6132bea014
6 changed files with 262 additions and 42 deletions
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package view
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
70
view/taskdetail/content_provider.go
Normal file
70
view/taskdetail/content_provider.go
Normal 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()
|
||||
}
|
||||
154
view/taskdetail/content_provider_test.go
Normal file
154
view/taskdetail/content_provider_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue