diff --git a/component/barchart/braille.go b/component/barchart/braille.go index 3b9de6d..b11fdfe 100644 --- a/component/barchart/braille.go +++ b/component/barchart/braille.go @@ -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 } diff --git a/config/art.go b/config/art.go index 29f21a0..6bf1557 100644 --- a/config/art.go +++ b/config/art.go @@ -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") } diff --git a/controller/actions_test.go b/controller/actions_test.go index 1fcf870..d5f0e2e 100644 --- a/controller/actions_test.go +++ b/controller/actions_test.go @@ -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) diff --git a/go.mod b/go.mod index 412ae14..c6d5837 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index da1137e..5634463 100644 --- a/go.sum +++ b/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.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= diff --git a/integration/task_edit_test.go b/integration/task_edit_test.go index 2a7ca64..4206c4b 100644 --- a/integration/task_edit_test.go +++ b/integration/task_edit_test.go @@ -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. diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 707d62b..437208b 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -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 diff --git a/internal/viewer/markdown_viewer.go b/internal/viewer/markdown_viewer.go index c932afe..2bdba58 100644 --- a/internal/viewer/markdown_viewer.go +++ b/internal/viewer/markdown_viewer.go @@ -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()) }) diff --git a/plugin/keyparser.go b/plugin/keyparser.go index 9073c2c..e9d1cf1 100644 --- a/plugin/keyparser.go +++ b/plugin/keyparser.go @@ -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) } diff --git a/store/internal/git/shell/timestamps.go b/store/internal/git/shell/timestamps.go index 1e54972..cafe0e9 100644 --- a/store/internal/git/shell/timestamps.go +++ b/store/internal/git/shell/timestamps.go @@ -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 { diff --git a/store/internal/git/shell/users.go b/store/internal/git/shell/users.go index 71b0fb6..d6d2d68 100644 --- a/store/internal/git/shell/users.go +++ b/store/internal/git/shell/users.go @@ -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 { diff --git a/view/doki_plugin_view.go b/view/doki_plugin_view.go index 613813e..a1d9c2a 100644 --- a/view/doki_plugin_view.go +++ b/view/doki_plugin_view.go @@ -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, }) } diff --git a/view/factory.go b/view/factory.go index 060c573..5e03b8c 100644 --- a/view/factory.go +++ b/view/factory.go @@ -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) } diff --git a/view/header/context_help.go b/view/header/context_help.go index ca19591..2209d8f 100644 --- a/view/header/context_help.go +++ b/view/header/context_help.go @@ -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 { diff --git a/view/navigable_markdown.go b/view/navigable_markdown.go index 6254a81..2f374c7 100644 --- a/view/navigable_markdown.go +++ b/view/navigable_markdown.go @@ -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 { diff --git a/view/taskdetail/base.go b/view/taskdetail/base.go index 3111e4f..93b836f 100644 --- a/view/taskdetail/base.go +++ b/view/taskdetail/base.go @@ -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 diff --git a/view/taskdetail/task_detail_test.go b/view/taskdetail/task_detail_test.go index 4bdd641..151a31e 100644 --- a/view/taskdetail/task_detail_test.go +++ b/view/taskdetail/task_detail_test.go @@ -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() diff --git a/view/taskdetail/task_detail_view.go b/view/taskdetail/task_detail_view.go index 70f20bf..1dc4a03 100644 --- a/view/taskdetail/task_detail_view.go +++ b/view/taskdetail/task_detail_view.go @@ -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)