SVG and mermaid support

This commit is contained in:
booleanmaybe 2026-03-16 11:38:32 -04:00
parent 98a878b6da
commit 31e32ea791
18 changed files with 61 additions and 36 deletions

View file

@ -151,5 +151,5 @@ func brailleColumnMask(level int, rightColumn bool) uint8 {
func brailleRuneForCounts(leftCount, rightCount int) rune {
mask := brailleColumnMask(leftCount, false) | brailleColumnMask(rightCount, true)
return rune(0x2800 + int(mask))
return rune(0x2800 + int(mask)) //nolint:gosec // G115: mask is uint8 (0-255), result is 0x2800..0x28FF, valid braille rune range
}

View file

@ -48,7 +48,7 @@ func GetArtTView() string {
colorIdx = len(currentGradient) - 1
}
color := currentGradient[colorIdx]
result.WriteString(fmt.Sprintf("[%s]%s[white]\n", color, line))
fmt.Fprintf(&result, "[%s]%s[white]\n", color, line)
}
return result.String()
}
@ -73,7 +73,7 @@ func getDotsArtTView() string {
result.WriteRune(char)
continue
}
result.WriteString(fmt.Sprintf("[%s]%c", color, char))
fmt.Fprintf(&result, "[%s]%c", color, char)
}
result.WriteString("[white]\n")
}

View file

@ -315,8 +315,8 @@ func TestActionRegistry_GetHeaderActions(t *testing.T) {
expectedIDs := []ActionID{ActionQuit}
for i, action := range headerActions {
if action.ID != expectedIDs[i] {
t.Errorf("header action %d: expected %v, got %v", i, expectedIDs[i], action.ID)
if action.ID != expectedIDs[i] { //nolint:gosec // G602: len verified equal on line 312
t.Errorf("header action %d: expected %v, got %v", i, expectedIDs[i], action.ID) //nolint:gosec // G602: len verified equal on line 312
}
if !action.ShowInHeader {
t.Errorf("header action %d: ShowInHeader should be true", i)

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.3.1
github.com/boolean-maybe/navidown v0.4.0
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.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.3.1 h1:Q5uUIxF28wCWSSK7JRUb79dbkZkwYbRA0FDf7IAspts=
github.com/boolean-maybe/navidown v0.3.1/go.mod h1:WdI3A007LaGuaJTOwEVO25kMojD9u4SCEVqAT8H9rwQ=
github.com/boolean-maybe/navidown v0.4.0 h1:HnO7Ck58O4DDGWQ21V8b00y3hRSQ0iS6itVCFyWDVgI=
github.com/boolean-maybe/navidown v0.4.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

@ -163,7 +163,7 @@ func TestEditSource_DuplicateCaseIDs_Repro(t *testing.T) {
return err
}
content = append(content, '\n')
return os.WriteFile(path, content, 0644)
return os.WriteFile(path, content, 0644) //nolint:gosec // G703: path is a controlled temp file provided by the app
})
// Open task detail view directly.

View file

@ -137,7 +137,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
wireOnViewActivated(rootLayout, application)
// Phase 11: Background tasks
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel stored in Result.CancelFunc, called by app shutdown
background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application)
// Phase 12: Navigation and input wiring

View file

@ -42,10 +42,12 @@ func Run(input InputSpec) error {
// Create NavigableMarkdown - OnStateChange is set after creation to avoid forward reference
md := view.NewNavigableMarkdown(view.NavigableMarkdownConfig{
Provider: provider,
SearchRoots: input.SearchRoots,
ImageManager: imgMgr,
Provider: provider,
SearchRoots: input.SearchRoots,
ImageManager: imgMgr,
MermaidOptions: &nav.MermaidOptions{},
})
defer md.Close()
md.SetStateChangedHandler(func() {
updateStatusBar(statusBar, md.Viewer())
})

View file

@ -40,7 +40,7 @@ func parseKey(s string) (tcell.Key, rune, tcell.ModMask, error) {
char := []rune(rest)
if len(char) == 1 && char[0] >= 'A' && char[0] <= 'Z' {
// tcell.KeyCtrlA is 65 ('A'), KeyCtrlZ is 90 ('Z')
return tcell.Key(char[0]), 0, tcell.ModCtrl, nil
return tcell.Key(char[0]), 0, tcell.ModCtrl, nil //nolint:gosec // G115: char[0] bounded 'A'-'Z' (65-90), safe to convert to int16
}
return 0, 0, 0, fmt.Errorf("invalid ctrl key: %q (expected Ctrl-A..Ctrl-Z or Ctrl-F1..Ctrl-F12)", s)
}

View file

@ -35,7 +35,7 @@ func (u *Util) LastCommitTime(filePath string) (time.Time, error) {
// Returns a map of file paths to their last commit time.
func (u *Util) AllLastCommitTimes(dirPattern string) (map[string]time.Time, error) {
// Get all commits (most recent first due to default reverse chronological order)
cmd := exec.Command("git", "log", "--all", "--format=%aI", "--name-only", "--", dirPattern)
cmd := exec.Command("git", "log", "--all", "--format=%aI", "--name-only", "--", dirPattern) //nolint:gosec // G204: git command with controlled directory pattern
cmd.Dir = u.repoPath
output, err := cmd.Output()
if err != nil {

View file

@ -142,7 +142,7 @@ func (u *Util) Author(filePath string) (*AuthorInfo, error) {
// Returns a map of file paths to their author info.
func (u *Util) AllAuthors(dirPattern string) (map[string]*AuthorInfo, error) {
// Use git log with --diff-filter=A (added files), --name-only, and --reverse to get creation info
cmd := exec.Command("git", "log", "--all", "--diff-filter=A", "--format=%H|%an|%ae|%ai|%s", "--name-only", "--reverse", "--", dirPattern)
cmd := exec.Command("git", "log", "--all", "--diff-filter=A", "--format=%H|%an|%ae|%ai|%s", "--name-only", "--reverse", "--", dirPattern) //nolint:gosec // G204: git command with controlled directory pattern
cmd.Dir = u.repoPath
output, err := cmd.Output()
if err != nil {

View file

@ -34,17 +34,20 @@ type DokiView struct {
pluginDef *plugin.DokiPlugin
registry *controller.ActionRegistry
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
}
// NewDokiView creates a doki view
func NewDokiView(
pluginDef *plugin.DokiPlugin,
imageManager *navtview.ImageManager,
mermaidOpts *nav.MermaidOptions,
) *DokiView {
dv := &DokiView{
pluginDef: pluginDef,
registry: controller.NewActionRegistry(),
imageManager: imageManager,
mermaidOpts: mermaidOpts,
}
dv.build()
@ -78,10 +81,11 @@ func (dv *DokiView) build() {
}
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
Provider: provider,
SearchRoots: searchRoots,
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
Provider: provider,
SearchRoots: searchRoots,
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
MermaidOptions: dv.mermaidOpts,
})
case "internal":
@ -94,16 +98,18 @@ func (dv *DokiView) build() {
content, err = provider.FetchContent(nav.NavElement{Text: dv.pluginDef.Text})
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
Provider: provider,
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
Provider: provider,
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
MermaidOptions: dv.mermaidOpts,
})
default:
content = "Error: Unknown fetcher type"
dv.markdown = NewNavigableMarkdown(NavigableMarkdownConfig{
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
OnStateChange: dv.UpdateNavigationActions,
ImageManager: dv.imageManager,
MermaidOptions: dv.mermaidOpts,
})
}

View file

@ -21,6 +21,7 @@ import (
type ViewFactory struct {
taskStore store.Store
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
// Plugin support
pluginConfigs map[string]*model.PluginConfig
pluginDefs map[string]plugin.Plugin
@ -39,6 +40,7 @@ func NewViewFactory(taskStore store.Store) *ViewFactory {
return &ViewFactory{
taskStore: taskStore,
imageManager: imgMgr,
mermaidOpts: &nav.MermaidOptions{},
}
}
@ -60,7 +62,7 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
switch viewID {
case model.TaskDetailViewID:
taskID := model.DecodeTaskDetailParams(params).TaskID
v = taskdetail.NewTaskDetailView(f.taskStore, taskID, f.imageManager)
v = taskdetail.NewTaskDetailView(f.taskStore, taskID, f.imageManager, f.mermaidOpts)
case model.TaskEditViewID:
taskID := model.DecodeTaskEditParams(params).TaskID
@ -96,7 +98,7 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
slog.Error("plugin controller type mismatch", "plugin", pluginName)
}
} else if dokiPlugin, ok := pluginDef.(*plugin.DokiPlugin); ok {
v = NewDokiView(dokiPlugin, f.imageManager)
v = NewDokiView(dokiPlugin, f.imageManager, f.mermaidOpts)
} else {
slog.Error("unknown plugin type or missing config", "plugin", pluginName)
}

View file

@ -237,7 +237,7 @@ func buildGridRow(rowData []cellData, maxKeyLenPerCol, maxLabelLenPerCol []int,
// Render cell with colors
scheme := getColorScheme(cell.colorType)
line.WriteString(fmt.Sprintf("[%s]<%s>[%s]", scheme.KeyColor, cell.key, scheme.LabelColor))
fmt.Fprintf(&line, "[%s]<%s>[%s]", scheme.KeyColor, cell.key, scheme.LabelColor)
// Add key padding
if keyPadding := maxKeyLenPerCol[col] - cell.keyLen; keyPadding > 0 {

View file

@ -20,10 +20,11 @@ type NavigableMarkdown struct {
// NavigableMarkdownConfig configures a NavigableMarkdown component.
type NavigableMarkdownConfig struct {
Provider nav.ContentProvider
SearchRoots []string
OnStateChange func() // called on navigation state changes
ImageManager *navtview.ImageManager
Provider nav.ContentProvider
SearchRoots []string
OnStateChange func() // called on navigation state changes
ImageManager *navtview.ImageManager
MermaidOptions *nav.MermaidOptions // nil = disabled
}
// NewNavigableMarkdown creates a new navigable markdown viewer.
@ -45,10 +46,18 @@ func NewNavigableMarkdown(cfg NavigableMarkdownConfig) *NavigableMarkdown {
nm.onStateChange()
}
})
if cfg.MermaidOptions != nil {
nm.viewer.Core().SetMermaidOptions(cfg.MermaidOptions)
}
nm.setupSelectHandler()
return nm
}
// Close releases resources held by the navigable markdown (e.g., mermaid temp files).
func (nm *NavigableMarkdown) Close() {
nm.viewer.Core().Close()
}
func (nm *NavigableMarkdown) setupSelectHandler() {
nm.viewer.SetSelectHandler(func(v *navtview.TextViewViewer, elem nav.NavElement) {
if elem.Type != nav.NavElementURL {

View file

@ -5,6 +5,7 @@ import (
"github.com/boolean-maybe/tiki/store"
taskpkg "github.com/boolean-maybe/tiki/task"
nav "github.com/boolean-maybe/navidown/navidown"
navtview "github.com/boolean-maybe/navidown/navidown/tview"
"github.com/rivo/tview"
)
@ -20,6 +21,7 @@ type Base struct {
taskStore store.Store
taskID string
imageManager *navtview.ImageManager
mermaidOpts *nav.MermaidOptions
descView tview.Primitive
// Task data

View file

@ -28,7 +28,7 @@ func TestBuildMetadataColumns_Structure(t *testing.T) {
UpdatedAt: time.Date(2024, 1, 2, 14, 30, 0, 0, time.UTC),
}
view := NewTaskDetailView(s, task.ID, nil)
view := NewTaskDetailView(s, task.ID, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()
@ -62,7 +62,7 @@ func TestBuildMetadataColumns_Column1Fields(t *testing.T) {
Priority: 3,
}
view := NewTaskDetailView(s, task.ID, nil)
view := NewTaskDetailView(s, task.ID, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()
@ -88,7 +88,7 @@ func TestBuildMetadataColumns_Column2Fields(t *testing.T) {
Points: 5,
}
view := NewTaskDetailView(s, task.ID, nil)
view := NewTaskDetailView(s, task.ID, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()

View file

@ -30,12 +30,13 @@ type TaskDetailView struct {
}
// NewTaskDetailView creates a task detail view in read-only mode
func NewTaskDetailView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager) *TaskDetailView {
func NewTaskDetailView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager, mermaidOpts *nav.MermaidOptions) *TaskDetailView {
tv := &TaskDetailView{
Base: Base{
taskStore: taskStore,
taskID: taskID,
imageManager: imageManager,
mermaidOpts: mermaidOpts,
},
registry: controller.TaskDetailViewActions(),
viewID: model.TaskDetailViewID,
@ -178,6 +179,9 @@ func (tv *TaskDetailView) buildDescription(task *taskpkg.Task) tview.Primitive {
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)