dependency editor

This commit is contained in:
booleanmaybe 2026-03-18 14:53:15 -04:00
parent f0765feee3
commit 839e6c45be
29 changed files with 3078 additions and 28 deletions

View file

@ -21,15 +21,15 @@ help:
build:
@echo "Building tiki $(VERSION)..."
go build $(LDFLAGS) -o tiki .
ifeq ($(shell uname),Darwin)
codesign -s - tiki
endif
# Build, sign, and install to ~/.local/bin
install: build
@echo "Installing tiki to ~/.local/bin..."
@mkdir -p ~/.local/bin
cp tiki ~/.local/bin/tiki
ifeq ($(shell uname),Darwin)
codesign -s - ~/.local/bin/tiki
endif
# Clean build artifacts
clean:

237
component/task_list.go Normal file
View file

@ -0,0 +1,237 @@
package component
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/util"
"github.com/boolean-maybe/tiki/util/gradient"
)
// TaskList displays tasks in a compact tabular format with three columns:
// status indicator, tiki ID (gradient-rendered), and title.
// It supports configurable visible row count, scrolling, and row selection.
type TaskList struct {
*tview.Box
tasks []*task.Task
maxVisibleRows int
scrollOffset int
selectionIndex int
idColumnWidth int // computed from widest ID
idGradient config.Gradient // gradient for ID text
idFallback tcell.Color // fallback solid color for ID
titleColor string // tview color tag for title, e.g. "[#b8b8b8]"
selectionColor string // tview color tag for selected row highlight
}
// NewTaskList creates a new TaskList with the given maximum visible row count.
func NewTaskList(maxVisibleRows int) *TaskList {
colors := config.GetColors()
return &TaskList{
Box: tview.NewBox(),
maxVisibleRows: maxVisibleRows,
idGradient: colors.TaskBoxIDColor,
idFallback: config.FallbackTaskIDColor,
titleColor: colors.TaskBoxTitleColor,
selectionColor: colors.TaskListSelectionColor,
}
}
// SetTasks replaces the task data, recomputes the ID column width, and clamps scroll/selection.
func (tl *TaskList) SetTasks(tasks []*task.Task) *TaskList {
tl.tasks = tasks
tl.recomputeIDColumnWidth()
tl.clampSelection()
tl.clampScroll()
return tl
}
// SetSelection sets the selected row index, clamped to valid bounds.
func (tl *TaskList) SetSelection(index int) *TaskList {
tl.selectionIndex = index
tl.clampSelection()
tl.ensureSelectionVisible()
return tl
}
// GetSelectedIndex returns the current selection index.
func (tl *TaskList) GetSelectedIndex() int {
return tl.selectionIndex
}
// GetSelectedTask returns the currently selected task, or nil if none.
func (tl *TaskList) GetSelectedTask() *task.Task {
if tl.selectionIndex < 0 || tl.selectionIndex >= len(tl.tasks) {
return nil
}
return tl.tasks[tl.selectionIndex]
}
// ScrollUp moves the selection up by one row.
func (tl *TaskList) ScrollUp() {
if tl.selectionIndex > 0 {
tl.selectionIndex--
tl.ensureSelectionVisible()
}
}
// ScrollDown moves the selection down by one row.
func (tl *TaskList) ScrollDown() {
if tl.selectionIndex < len(tl.tasks)-1 {
tl.selectionIndex++
tl.ensureSelectionVisible()
}
}
// SetIDColors overrides the gradient and fallback color for the ID column.
func (tl *TaskList) SetIDColors(g config.Gradient, fallback tcell.Color) *TaskList {
tl.idGradient = g
tl.idFallback = fallback
return tl
}
// SetTitleColor overrides the tview color tag for the title column.
func (tl *TaskList) SetTitleColor(color string) *TaskList {
tl.titleColor = color
return tl
}
// Draw renders the TaskList onto the screen.
func (tl *TaskList) Draw(screen tcell.Screen) {
tl.DrawForSubclass(screen, tl)
x, y, width, height := tl.GetInnerRect()
if width <= 0 || height <= 0 || len(tl.tasks) == 0 {
return
}
tl.ensureSelectionVisible()
visibleRows := tl.visibleRowCount(height)
for i := range visibleRows {
itemIndex := tl.scrollOffset + i
if itemIndex >= len(tl.tasks) {
break
}
t := tl.tasks[itemIndex]
row := tl.buildRow(t, itemIndex == tl.selectionIndex, width)
tview.Print(screen, row, x, y+i, width, tview.AlignLeft, tcell.ColorDefault)
}
}
// buildRow constructs the tview-tagged string for a single row.
func (tl *TaskList) buildRow(t *task.Task, selected bool, width int) string {
// Status indicator: done = green checkmark, else gray circle
var statusIndicator string
if config.GetStatusRegistry().IsDone(string(t.Status)) {
statusIndicator = "[green]\u2713[-]"
} else {
statusIndicator = "[gray]\u25CB[-]"
}
// Gradient-rendered ID, padded to idColumnWidth
idText := gradient.RenderAdaptiveGradientText(t.ID, tl.idGradient, tl.idFallback)
// Pad with spaces if ID is shorter than column width
if padding := tl.idColumnWidth - len(t.ID); padding > 0 {
idText += fmt.Sprintf("%*s", padding, "")
}
// Title: fill remaining width, truncated
// Layout: "X IDID Title" => status(1) + space(1) + id(idColumnWidth) + space(1) + title
titleAvailable := max(width-1-1-tl.idColumnWidth-1, 0)
truncatedTitle := tview.Escape(util.TruncateText(t.Title, titleAvailable))
row := fmt.Sprintf("%s %s %s%s[-]", statusIndicator, idText, tl.titleColor, truncatedTitle)
if selected {
row = tl.selectionColor + row
}
return row
}
// ensureSelectionVisible adjusts scrollOffset so the selected row is within the viewport.
func (tl *TaskList) ensureSelectionVisible() {
if len(tl.tasks) == 0 {
return
}
_, _, _, height := tl.GetInnerRect()
maxVisible := tl.visibleRowCount(height)
if maxVisible <= 0 {
return
}
// Selection above viewport
if tl.selectionIndex < tl.scrollOffset {
tl.scrollOffset = tl.selectionIndex
}
// Selection below viewport
lastVisible := tl.scrollOffset + maxVisible - 1
if tl.selectionIndex > lastVisible {
tl.scrollOffset = tl.selectionIndex - maxVisible + 1
}
tl.clampScroll()
}
// visibleRowCount returns the number of rows that can be displayed.
func (tl *TaskList) visibleRowCount(height int) int {
maxVisible := height
if tl.maxVisibleRows > 0 && maxVisible > tl.maxVisibleRows {
maxVisible = tl.maxVisibleRows
}
if maxVisible > len(tl.tasks) {
maxVisible = len(tl.tasks)
}
return maxVisible
}
// recomputeIDColumnWidth calculates the width needed for the widest task ID.
func (tl *TaskList) recomputeIDColumnWidth() {
tl.idColumnWidth = 0
for _, t := range tl.tasks {
if len(t.ID) > tl.idColumnWidth {
tl.idColumnWidth = len(t.ID)
}
}
}
// clampSelection ensures selectionIndex is within [0, len(tasks)-1].
func (tl *TaskList) clampSelection() {
if len(tl.tasks) == 0 {
tl.selectionIndex = 0
return
}
if tl.selectionIndex < 0 {
tl.selectionIndex = 0
}
if tl.selectionIndex >= len(tl.tasks) {
tl.selectionIndex = len(tl.tasks) - 1
}
}
// clampScroll ensures scrollOffset stays within valid bounds.
func (tl *TaskList) clampScroll() {
if tl.scrollOffset < 0 {
tl.scrollOffset = 0
}
_, _, _, height := tl.GetInnerRect()
maxVisible := tl.visibleRowCount(height)
if maxVisible <= 0 {
return
}
maxOffset := max(len(tl.tasks)-maxVisible, 0)
if tl.scrollOffset > maxOffset {
tl.scrollOffset = maxOffset
}
}

283
component/task_list_test.go Normal file
View file

@ -0,0 +1,283 @@
package component
import (
"strings"
"testing"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/task"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func makeTasks(ids ...string) []*task.Task {
tasks := make([]*task.Task, len(ids))
for i, id := range ids {
tasks[i] = &task.Task{
ID: id,
Title: "Task " + id,
Status: task.StatusBacklog,
}
}
return tasks
}
func TestNewTaskList(t *testing.T) {
tl := NewTaskList(5)
if tl == nil {
t.Fatal("NewTaskList returned nil")
}
if tl.maxVisibleRows != 5 {
t.Errorf("Expected maxVisibleRows=5, got %d", tl.maxVisibleRows)
}
if tl.selectionIndex != 0 {
t.Errorf("Expected initial selectionIndex=0, got %d", tl.selectionIndex)
}
colors := config.GetColors()
if tl.idGradient != colors.TaskBoxIDColor {
t.Error("Expected ID gradient from config")
}
if tl.idFallback != config.FallbackTaskIDColor {
t.Error("Expected ID fallback from config")
}
if tl.titleColor != colors.TaskBoxTitleColor {
t.Error("Expected title color from config")
}
}
func TestSetTasks_RecomputesIDColumnWidth(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("AB", "ABCDE", "XY"))
if tl.idColumnWidth != 5 {
t.Errorf("Expected idColumnWidth=5, got %d", tl.idColumnWidth)
}
tl.SetTasks(makeTasks("A"))
if tl.idColumnWidth != 1 {
t.Errorf("Expected idColumnWidth=1, got %d", tl.idColumnWidth)
}
}
func TestSetTasks_EmptyList(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(nil)
if tl.idColumnWidth != 0 {
t.Errorf("Expected idColumnWidth=0, got %d", tl.idColumnWidth)
}
if tl.selectionIndex != 0 {
t.Errorf("Expected selectionIndex=0, got %d", tl.selectionIndex)
}
}
func TestSelection_ClampsBounds(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.SetSelection(-5)
if tl.selectionIndex != 0 {
t.Errorf("Expected clamped to 0, got %d", tl.selectionIndex)
}
tl.SetSelection(100)
if tl.selectionIndex != 2 {
t.Errorf("Expected clamped to 2, got %d", tl.selectionIndex)
}
tl.SetSelection(1)
if tl.selectionIndex != 1 {
t.Errorf("Expected 1, got %d", tl.selectionIndex)
}
}
func TestGetSelectedTask(t *testing.T) {
tl := NewTaskList(10)
tasks := makeTasks("A", "B", "C")
tl.SetTasks(tasks)
tl.SetSelection(1)
selected := tl.GetSelectedTask()
if selected == nil || selected.ID != "B" {
t.Errorf("Expected task B, got %v", selected)
}
}
func TestGetSelectedTask_EmptyList(t *testing.T) {
tl := NewTaskList(10)
if tl.GetSelectedTask() != nil {
t.Error("Expected nil for empty task list")
}
}
func TestScrollDown(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.ScrollDown()
if tl.selectionIndex != 1 {
t.Errorf("Expected 1 after ScrollDown, got %d", tl.selectionIndex)
}
tl.ScrollDown()
if tl.selectionIndex != 2 {
t.Errorf("Expected 2 after second ScrollDown, got %d", tl.selectionIndex)
}
// Should not go past last item
tl.ScrollDown()
if tl.selectionIndex != 2 {
t.Errorf("Expected 2 (clamped), got %d", tl.selectionIndex)
}
}
func TestScrollUp(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.SetSelection(2)
tl.ScrollUp()
if tl.selectionIndex != 1 {
t.Errorf("Expected 1 after ScrollUp, got %d", tl.selectionIndex)
}
tl.ScrollUp()
if tl.selectionIndex != 0 {
t.Errorf("Expected 0 after second ScrollUp, got %d", tl.selectionIndex)
}
// Should not go below 0
tl.ScrollUp()
if tl.selectionIndex != 0 {
t.Errorf("Expected 0 (clamped), got %d", tl.selectionIndex)
}
}
func TestScrollDown_EmptyList(t *testing.T) {
tl := NewTaskList(10)
// Should not panic
tl.ScrollDown()
tl.ScrollUp()
}
func TestFewerItemsThanViewport(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B"))
// scrollOffset should stay at 0 since all items fit
if tl.scrollOffset != 0 {
t.Errorf("Expected scrollOffset=0, got %d", tl.scrollOffset)
}
tl.ScrollDown()
if tl.scrollOffset != 0 {
t.Errorf("Expected scrollOffset=0 with fewer items, got %d", tl.scrollOffset)
}
}
func TestSetIDColors(t *testing.T) {
tl := NewTaskList(10)
g := config.Gradient{Start: [3]int{255, 0, 0}, End: [3]int{0, 255, 0}}
fb := tcell.ColorRed
result := tl.SetIDColors(g, fb)
if result != tl {
t.Error("SetIDColors should return self for chaining")
}
if tl.idGradient != g {
t.Error("Expected custom gradient")
}
if tl.idFallback != fb {
t.Error("Expected custom fallback")
}
}
func TestSetTitleColor(t *testing.T) {
tl := NewTaskList(10)
result := tl.SetTitleColor("[#ff0000]")
if result != tl {
t.Error("SetTitleColor should return self for chaining")
}
if tl.titleColor != "[#ff0000]" {
t.Errorf("Expected [#ff0000], got %s", tl.titleColor)
}
}
func TestSetTasks_ClampsSelectionOnShrink(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C", "D", "E"))
tl.SetSelection(4)
// Shrink list — selection should clamp
tl.SetTasks(makeTasks("A", "B"))
if tl.selectionIndex != 1 {
t.Errorf("Expected selectionIndex clamped to 1, got %d", tl.selectionIndex)
}
}
func TestGetSelectedIndex(t *testing.T) {
tl := NewTaskList(10)
tl.SetTasks(makeTasks("A", "B", "C"))
tl.SetSelection(2)
if tl.GetSelectedIndex() != 2 {
t.Errorf("Expected 2, got %d", tl.GetSelectedIndex())
}
}
func TestBuildRow(t *testing.T) {
tl := NewTaskList(10)
pendingTask := &task.Task{ID: "TIKI-ABC001", Title: "My pending task", Status: task.StatusBacklog}
doneTask := &task.Task{ID: "TIKI-ABC002", Title: "My done task", Status: task.StatusDone}
// set tasks so idColumnWidth is computed
tl.SetTasks([]*task.Task{pendingTask, doneTask})
width := 80
t.Run("pending task shows circle", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
if !strings.Contains(row, "\u25CB") {
t.Error("pending task row should contain circle (○)")
}
})
t.Run("done task shows checkmark", func(t *testing.T) {
row := tl.buildRow(doneTask, false, width)
if !strings.Contains(row, "\u2713") {
t.Error("done task row should contain checkmark (✓)")
}
})
t.Run("contains task ID", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
if !strings.Contains(row, pendingTask.ID) {
t.Errorf("row should contain task ID %q", pendingTask.ID)
}
})
t.Run("contains title", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
escaped := tview.Escape(pendingTask.Title)
if !strings.Contains(row, escaped) {
t.Errorf("row should contain escaped title %q", escaped)
}
})
t.Run("selected row has selection color prefix", func(t *testing.T) {
row := tl.buildRow(pendingTask, true, width)
if !strings.HasPrefix(row, tl.selectionColor) {
t.Errorf("selected row should start with selection color %q", tl.selectionColor)
}
})
t.Run("unselected row has no selection prefix", func(t *testing.T) {
row := tl.buildRow(pendingTask, false, width)
if strings.HasPrefix(row, tl.selectionColor) {
t.Error("unselected row should not start with selection color")
}
})
}

View file

@ -0,0 +1,7 @@
package component
import "github.com/boolean-maybe/tiki/internal/teststatuses"
func init() {
teststatuses.Init()
}

View file

@ -28,6 +28,7 @@ type ColorConfig struct {
TaskBoxLabelColor string // tview color string like "[#767676]"
TaskBoxDescriptionColor string // tview color string like "[#767676]"
TaskBoxTagValueColor string // tview color string like "[#5a6f8f]"
TaskListSelectionColor string // tview color string for selected row highlight, e.g. "[white:#3a5f8a]"
// Task detail view colors
TaskDetailIDColor Gradient
@ -103,10 +104,11 @@ func DefaultColors() *ColorConfig {
Start: [3]int{30, 144, 255}, // Dodger Blue
End: [3]int{0, 191, 255}, // Deep Sky Blue
},
TaskBoxTitleColor: "[#b8b8b8]", // Light gray
TaskBoxLabelColor: "[#767676]", // Darker gray for labels
TaskBoxDescriptionColor: "[#767676]", // Darker gray for description
TaskBoxTagValueColor: "[#5a6f8f]", // Blueish gray for tag values
TaskBoxTitleColor: "[#b8b8b8]", // Light gray
TaskBoxLabelColor: "[#767676]", // Darker gray for labels
TaskBoxDescriptionColor: "[#767676]", // Darker gray for description
TaskBoxTagValueColor: "[#5a6f8f]", // Blueish gray for tag values
TaskListSelectionColor: "[white:#3a5f8a]", // White text on steel blue background
// Task detail
TaskDetailIDColor: Gradient{
@ -191,6 +193,12 @@ var UseGradients bool
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
var UseWideGradients bool
// Plugin-specific background colors for code-only plugins
var (
// DepsEditorBackground: muted slate for the dependency editor caption
DepsEditorBackground = tcell.NewHexColor(0x4e5768)
)
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
var (
// Caption title fallback: Royal Blue (end of gradient)

View file

@ -38,6 +38,7 @@ const (
ActionEditSource ActionID = "edit_source"
ActionFullscreen ActionID = "fullscreen"
ActionCloneTask ActionID = "clone_task"
ActionEditDeps ActionID = "edit_deps"
)
// ActionID values for task edit view actions.
@ -252,7 +253,7 @@ func TaskDetailViewActions() *ActionRegistry {
r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true})
r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true})
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
// Clone action removed - not yet implemented
r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Deps", ShowInHeader: true})
return r
}
@ -385,6 +386,32 @@ func PluginViewActions() *ActionRegistry {
return r
}
// DepsViewActions returns the action registry for the dependency editor view.
// Restricted to navigation, move task, view mode toggle, and search — no open/new/delete/plugin keys.
func DepsViewActions() *ActionRegistry {
r := NewActionRegistry()
// navigation (not shown in header)
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"})
r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"})
r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"})
r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"})
r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"})
// move task between lanes (shown in header)
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
// view mode and search
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
r.Register(Action{ID: ActionToggleViewMode, Key: tcell.KeyRune, Rune: 'v', Label: "View mode", ShowInHeader: true})
return r
}
// DokiViewActions returns the action registry for doki (documentation) plugin views.
// Doki views primarily handle navigation through the NavigableMarkdown component.
func DokiViewActions() *ActionRegistry {

View file

@ -426,11 +426,11 @@ func TestTaskDetailViewActions(t *testing.T) {
registry := TaskDetailViewActions()
actions := registry.GetActions()
if len(actions) != 3 {
t.Errorf("expected 3 task detail actions, got %d", len(actions))
if len(actions) != 4 {
t.Errorf("expected 4 task detail actions, got %d", len(actions))
}
expectedActions := []ActionID{ActionEditTitle, ActionEditSource, ActionFullscreen}
expectedActions := []ActionID{ActionEditTitle, ActionEditSource, ActionFullscreen, ActionEditDeps}
for i, expected := range expectedActions {
if i >= len(actions) {
t.Errorf("missing action at index %d: want %v", i, expected)

334
controller/deps.go Normal file
View file

@ -0,0 +1,334 @@
package controller
import (
"log/slog"
"strings"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
// lane indices for the deps editor
const (
depsLaneBlocks = 0
depsLaneAll = 1
depsLaneDepends = 2
)
// DepsController handles the dependency editor plugin view.
// Unlike PluginController, move logic here updates different tasks depending on
// the source/target lane pair — sometimes the moved task, sometimes the context task.
type DepsController struct {
taskStore store.Store
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
navController *NavigationController
registry *ActionRegistry
}
// NewDepsController creates a dependency editor controller.
func NewDepsController(
taskStore store.Store,
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
) *DepsController {
return &DepsController{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
registry: DepsViewActions(),
}
}
func (dc *DepsController) GetActionRegistry() *ActionRegistry { return dc.registry }
func (dc *DepsController) GetPluginName() string { return dc.pluginDef.Name }
func (dc *DepsController) ShowNavigation() bool { return false }
// HandleAction routes actions to the appropriate handler.
func (dc *DepsController) HandleAction(actionID ActionID) bool {
switch actionID {
case ActionNavUp:
return dc.handleNav("up")
case ActionNavDown:
return dc.handleNav("down")
case ActionNavLeft:
return dc.handleNav("left")
case ActionNavRight:
return dc.handleNav("right")
case ActionMoveTaskLeft:
return dc.handleMoveTask(-1)
case ActionMoveTaskRight:
return dc.handleMoveTask(1)
case ActionToggleViewMode:
dc.pluginConfig.ToggleViewMode()
return true
default:
return false
}
}
// HandleSearch processes a search query, narrowing visible tasks within each lane.
func (dc *DepsController) HandleSearch(query string) {
query = strings.TrimSpace(query)
if query == "" {
return
}
dc.pluginConfig.SavePreSearchState()
results := dc.taskStore.Search(query, nil)
if len(results) == 0 {
dc.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
return
}
dc.pluginConfig.SetSearchResults(results, query)
dc.selectFirstNonEmptyLane()
}
// GetFilteredTasksForLane returns tasks for a given lane of the deps editor.
// Lane 0 (Blocks): tasks whose dependsOn contains the context task.
// Lane 1 (All): all tasks minus context task, blocks set, and depends set.
// Lane 2 (Depends): tasks listed in the context task's dependsOn.
func (dc *DepsController) GetFilteredTasksForLane(lane int) []*task.Task {
if lane < 0 || lane >= len(dc.pluginDef.Lanes) {
return nil
}
contextTask := dc.taskStore.GetTask(dc.pluginDef.TaskID)
if contextTask == nil {
return nil
}
allTasks := dc.taskStore.GetAllTasks()
blocksSet := task.FindBlockedTasks(allTasks, contextTask.ID)
dependsSet := dc.resolveDependsTasks(contextTask, allTasks)
var result []*task.Task
switch lane {
case depsLaneAll:
result = dc.computeAllLane(allTasks, contextTask.ID, blocksSet, dependsSet)
case depsLaneBlocks:
result = blocksSet
case depsLaneDepends:
result = dependsSet
}
// narrow by search results if active
if searchResults := dc.pluginConfig.GetSearchResults(); searchResults != nil {
searchMap := make(map[string]bool, len(searchResults))
for _, sr := range searchResults {
searchMap[sr.Task.ID] = true
}
result = filterTasksBySearch(result, searchMap)
}
return result
}
// EnsureFirstNonEmptyLaneSelection selects the first non-empty lane if the current lane is empty.
func (dc *DepsController) EnsureFirstNonEmptyLaneSelection() bool {
currentLane := dc.pluginConfig.GetSelectedLane()
if currentLane >= 0 && currentLane < len(dc.pluginDef.Lanes) {
if len(dc.GetFilteredTasksForLane(currentLane)) > 0 {
return false
}
}
return dc.selectFirstNonEmptyLane()
}
// handleMoveTask applies dependency changes based on the source→target lane transition.
//
// From → To | What changes
// All → Blocks | moved task: dependsOn += [contextTaskID]
// All → Depends | context task: dependsOn += [movedTaskID]
// Blocks → All | moved task: dependsOn -= [contextTaskID]
// Depends → All | context task: dependsOn -= [movedTaskID]
func (dc *DepsController) handleMoveTask(offset int) bool {
if offset != -1 && offset != 1 {
return false
}
movedTaskID := dc.getSelectedTaskID()
if movedTaskID == "" {
return false
}
sourceLane := dc.pluginConfig.GetSelectedLane()
targetLane := sourceLane + offset
if targetLane < 0 || targetLane >= len(dc.pluginDef.Lanes) {
return false
}
contextTaskID := dc.pluginDef.TaskID
// determine which tasks to update and how
type update struct {
taskID string
action plugin.LaneAction
}
var updates []update
switch {
case sourceLane == depsLaneAll && targetLane == depsLaneBlocks:
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorAdd, contextTaskID)})
case sourceLane == depsLaneAll && targetLane == depsLaneDepends:
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorAdd, movedTaskID)})
case sourceLane == depsLaneBlocks && targetLane == depsLaneAll:
updates = append(updates, update{movedTaskID, depsAction(plugin.ActionOperatorRemove, contextTaskID)})
case sourceLane == depsLaneDepends && targetLane == depsLaneAll:
updates = append(updates, update{contextTaskID, depsAction(plugin.ActionOperatorRemove, movedTaskID)})
default:
return false
}
for _, u := range updates {
taskItem := dc.taskStore.GetTask(u.taskID)
if taskItem == nil {
slog.Error("deps move: task not found", "task_id", u.taskID)
return false
}
updated, err := plugin.ApplyLaneAction(taskItem, u.action, "")
if err != nil {
slog.Error("deps move: failed to apply action", "task_id", u.taskID, "error", err)
return false
}
if err := dc.taskStore.UpdateTask(updated); err != nil {
slog.Error("deps move: failed to update task", "task_id", u.taskID, "error", err)
return false
}
}
dc.selectTaskInLane(targetLane, movedTaskID)
return true
}
// depsAction builds a LaneAction that adds or removes a single task ID from dependsOn.
func depsAction(op plugin.ActionOperator, taskID string) plugin.LaneAction {
return plugin.LaneAction{
Ops: []plugin.LaneActionOp{{
Field: plugin.ActionFieldDependsOn,
Operator: op,
DependsOn: []string{taskID},
}},
}
}
// resolveDependsTasks looks up full task objects for the context task's DependsOn IDs.
func (dc *DepsController) resolveDependsTasks(contextTask *task.Task, allTasks []*task.Task) []*task.Task {
if len(contextTask.DependsOn) == 0 {
return nil
}
idMap := make(map[string]bool, len(contextTask.DependsOn))
for _, id := range contextTask.DependsOn {
idMap[strings.ToUpper(id)] = true
}
var result []*task.Task
for _, t := range allTasks {
if idMap[t.ID] {
result = append(result, t)
}
}
return result
}
// computeAllLane returns all tasks minus the context task, blocks set, and depends set.
func (dc *DepsController) computeAllLane(allTasks []*task.Task, contextID string, blocks, depends []*task.Task) []*task.Task {
exclude := make(map[string]bool, len(blocks)+len(depends)+1)
exclude[contextID] = true
for _, t := range blocks {
exclude[t.ID] = true
}
for _, t := range depends {
exclude[t.ID] = true
}
var result []*task.Task
for _, t := range allTasks {
if !exclude[t.ID] {
result = append(result, t)
}
}
return result
}
func (dc *DepsController) handleNav(direction string) bool {
lane := dc.pluginConfig.GetSelectedLane()
tasks := dc.GetFilteredTasksForLane(lane)
if direction == "left" || direction == "right" {
if dc.pluginConfig.MoveSelection(direction, len(tasks)) {
return true
}
return dc.handleLaneSwitch(direction)
}
return dc.pluginConfig.MoveSelection(direction, len(tasks))
}
func (dc *DepsController) handleLaneSwitch(direction string) bool {
currentLane := dc.pluginConfig.GetSelectedLane()
nextLane := currentLane
switch direction {
case "left":
nextLane--
case "right":
nextLane++
default:
return false
}
for nextLane >= 0 && nextLane < len(dc.pluginDef.Lanes) {
tasks := dc.GetFilteredTasksForLane(nextLane)
if len(tasks) > 0 {
dc.pluginConfig.SetSelectedLane(nextLane)
scrollOffset := dc.pluginConfig.GetScrollOffsetForLane(nextLane)
if scrollOffset >= len(tasks) {
scrollOffset = len(tasks) - 1
}
if scrollOffset < 0 {
scrollOffset = 0
}
dc.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
return true
}
switch direction {
case "left":
nextLane--
case "right":
nextLane++
}
}
return false
}
func (dc *DepsController) getSelectedTaskID() string {
lane := dc.pluginConfig.GetSelectedLane()
tasks := dc.GetFilteredTasksForLane(lane)
idx := dc.pluginConfig.GetSelectedIndexForLane(lane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
func (dc *DepsController) selectTaskInLane(lane int, taskID string) {
tasks := dc.GetFilteredTasksForLane(lane)
targetIndex := 0
for i, t := range tasks {
if t.ID == taskID {
targetIndex = i
break
}
}
dc.pluginConfig.SetSelectedLane(lane)
dc.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
}
func (dc *DepsController) selectFirstNonEmptyLane() bool {
for lane := range dc.pluginDef.Lanes {
if len(dc.GetFilteredTasksForLane(lane)) > 0 {
dc.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
return true
}
}
return false
}

418
controller/deps_test.go Normal file
View file

@ -0,0 +1,418 @@
package controller
import (
"slices"
"testing"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
const (
testCtxID = "TIKI-AACTX0"
testBlkID = "TIKI-AABLK0"
testDepID = "TIKI-AADEP0"
testFreeID = "TIKI-AAFRE0"
)
// newDepsTestEnv sets up a deps editor test environment with:
// - contextTask whose dependsOn contains testDepID
// - blockerTask whose dependsOn contains testCtxID
// - dependsTask with no deps
// - freeTask with no dependency relationship
func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
t.Helper()
taskStore := store.NewInMemoryStore()
tasks := []*task.Task{
{ID: testCtxID, Title: "Context", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testDepID}},
{ID: testBlkID, Title: "Blocker", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testCtxID}},
{ID: testDepID, Title: "Depends", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
{ID: testFreeID, Title: "Free", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
}
for _, tt := range tasks {
if err := taskStore.CreateTask(tt); err != nil {
t.Fatalf("create task %s: %v", tt.ID, err)
}
}
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{Name: "deps:" + testCtxID, ConfigIndex: -1, Type: "tiki"},
TaskID: testCtxID,
Lanes: []plugin.TikiLane{{Name: "Blocks"}, {Name: "All"}, {Name: "Depends"}},
}
pluginConfig := model.NewPluginConfig("deps:" + testCtxID)
pluginConfig.SetLaneLayout([]int{1, 2, 1})
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nil)
return dc, taskStore
}
func taskIDs(tasks []*task.Task) []string {
ids := make([]string, len(tasks))
for i, t := range tasks {
ids[i] = t.ID
}
return ids
}
func TestDepsController_GetFilteredTasksForLane(t *testing.T) {
dc, _ := newDepsTestEnv(t)
t.Run("all lane excludes context, blocks, and depends", func(t *testing.T) {
all := dc.GetFilteredTasksForLane(depsLaneAll)
ids := taskIDs(all)
if slices.Contains(ids, testCtxID) {
t.Error("all lane should not contain context task")
}
if slices.Contains(ids, testBlkID) {
t.Error("all lane should not contain blocker task")
}
if slices.Contains(ids, testDepID) {
t.Error("all lane should not contain depends task")
}
if !slices.Contains(ids, testFreeID) {
t.Error("all lane should contain free task")
}
})
t.Run("blocks lane contains tasks that depend on context", func(t *testing.T) {
blocks := dc.GetFilteredTasksForLane(depsLaneBlocks)
ids := taskIDs(blocks)
if !slices.Contains(ids, testBlkID) {
t.Error("blocks lane should contain blocker task")
}
if len(ids) != 1 {
t.Errorf("blocks lane should have exactly 1 task, got %d: %v", len(ids), ids)
}
})
t.Run("depends lane contains context task dependencies", func(t *testing.T) {
depends := dc.GetFilteredTasksForLane(depsLaneDepends)
ids := taskIDs(depends)
if !slices.Contains(ids, testDepID) {
t.Error("depends lane should contain depends task")
}
if len(ids) != 1 {
t.Errorf("depends lane should have exactly 1 task, got %d: %v", len(ids), ids)
}
})
t.Run("invalid lane returns nil", func(t *testing.T) {
if dc.GetFilteredTasksForLane(-1) != nil {
t.Error("invalid lane should return nil")
}
if dc.GetFilteredTasksForLane(3) != nil {
t.Error("out of range lane should return nil")
}
})
}
func TestDepsController_MoveTask_AllToBlocks(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
if !dc.handleMoveTask(-1) {
t.Fatal("move should succeed")
}
// free task should now have context task in its dependsOn
free := taskStore.GetTask(testFreeID)
if !slices.Contains(free.DependsOn, testCtxID) {
t.Errorf("free.DependsOn should contain %s, got %v", testCtxID, free.DependsOn)
}
}
func TestDepsController_MoveTask_AllToDepends(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
if !dc.handleMoveTask(1) {
t.Fatal("move should succeed")
}
// context task should now have free task in its dependsOn
ctx := taskStore.GetTask(testCtxID)
if !slices.Contains(ctx.DependsOn, testFreeID) {
t.Errorf("ctx.DependsOn should contain %s, got %v", testFreeID, ctx.DependsOn)
}
}
func TestDepsController_MoveTask_BlocksToAll(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
if !dc.handleMoveTask(1) {
t.Fatal("move should succeed")
}
blk := taskStore.GetTask(testBlkID)
if slices.Contains(blk.DependsOn, testCtxID) {
t.Errorf("blk.DependsOn should not contain %s after move, got %v", testCtxID, blk.DependsOn)
}
}
func TestDepsController_MoveTask_DependsToAll(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneDepends, 0)
if !dc.handleMoveTask(-1) {
t.Fatal("move should succeed")
}
ctx := taskStore.GetTask(testCtxID)
if slices.Contains(ctx.DependsOn, testDepID) {
t.Errorf("ctx.DependsOn should not contain %s after move, got %v", testDepID, ctx.DependsOn)
}
}
func TestDepsController_MoveTask_OutOfBounds(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
if dc.handleMoveTask(-1) {
t.Error("move left from lane 0 should fail")
}
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneDepends, 0)
if dc.handleMoveTask(1) {
t.Error("move right from lane 2 should fail")
}
}
func TestDepsController_MoveTask_RejectsMultiLaneJump(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneBlocks, 0)
if dc.handleMoveTask(2) {
t.Error("offset=2 should be rejected")
}
if dc.handleMoveTask(-2) {
t.Error("offset=-2 should be rejected")
}
}
func TestDepsController_HandleSearch(t *testing.T) {
t.Run("empty query is no-op", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.HandleSearch("")
if dc.pluginConfig.GetSearchResults() != nil {
t.Error("empty query should not set search results")
}
})
t.Run("matching query", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.HandleSearch("Free")
results := dc.pluginConfig.GetSearchResults()
if results == nil {
t.Fatal("expected search results, got nil")
}
found := false
for _, sr := range results {
if sr.Task.ID == testFreeID {
found = true
}
}
if !found {
t.Errorf("expected search results to contain %s", testFreeID)
}
})
t.Run("non-matching query", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.HandleSearch("zzzzz")
results := dc.pluginConfig.GetSearchResults()
if results == nil {
t.Fatal("expected empty search results slice, got nil")
}
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
})
}
func TestDepsController_HandleAction(t *testing.T) {
t.Run("nav down changes index", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
// All lane has only free task, so nav down should return false (can't go past end)
dc.HandleAction(ActionNavDown)
// just verify it doesn't panic and returns a bool
})
t.Run("nav left from All switches to Blocks", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
result := dc.HandleAction(ActionNavLeft)
if !result {
t.Error("nav left from All should succeed (Blocks has tasks)")
}
if dc.pluginConfig.GetSelectedLane() != depsLaneBlocks {
t.Errorf("expected lane %d, got %d", depsLaneBlocks, dc.pluginConfig.GetSelectedLane())
}
})
t.Run("nav right from All switches to Depends", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
result := dc.HandleAction(ActionNavRight)
if !result {
t.Error("nav right from All should succeed (Depends has tasks)")
}
if dc.pluginConfig.GetSelectedLane() != depsLaneDepends {
t.Errorf("expected lane %d, got %d", depsLaneDepends, dc.pluginConfig.GetSelectedLane())
}
})
t.Run("toggle view mode", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
before := dc.pluginConfig.GetViewMode()
result := dc.HandleAction(ActionToggleViewMode)
if !result {
t.Error("toggle view mode should return true")
}
after := dc.pluginConfig.GetViewMode()
if before == after {
t.Error("view mode should change after toggle")
}
})
t.Run("invalid action returns false", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
if dc.HandleAction("nonexistent_action") {
t.Error("unknown action should return false")
}
})
}
func TestDepsController_HandleLaneSwitch(t *testing.T) {
t.Run("right from Blocks lands on All", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
result := dc.handleLaneSwitch("right")
if !result {
t.Error("should succeed — All lane has tasks")
}
if dc.pluginConfig.GetSelectedLane() != depsLaneAll {
t.Errorf("expected lane %d, got %d", depsLaneAll, dc.pluginConfig.GetSelectedLane())
}
})
t.Run("left from All lands on Blocks", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
result := dc.handleLaneSwitch("left")
if !result {
t.Error("should succeed — Blocks lane has tasks")
}
if dc.pluginConfig.GetSelectedLane() != depsLaneBlocks {
t.Errorf("expected lane %d, got %d", depsLaneBlocks, dc.pluginConfig.GetSelectedLane())
}
})
t.Run("left from Blocks returns false (boundary)", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
if dc.handleLaneSwitch("left") {
t.Error("should fail — no lane to the left of Blocks")
}
})
t.Run("right from Depends returns false (boundary)", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
if dc.handleLaneSwitch("right") {
t.Error("should fail — no lane to the right of Depends")
}
})
}
func TestDepsController_EnsureFirstNonEmptyLaneSelection(t *testing.T) {
t.Run("current lane has tasks — no change", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
if dc.EnsureFirstNonEmptyLaneSelection() {
t.Error("should return false when current lane has tasks")
}
if dc.pluginConfig.GetSelectedLane() != depsLaneAll {
t.Error("lane should not change")
}
})
t.Run("current lane empty — switches to first non-empty", func(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
// move free task into depends so All lane becomes empty
free := taskStore.GetTask(testFreeID)
free.DependsOn = []string{testCtxID}
if err := taskStore.UpdateTask(free); err != nil {
t.Fatal(err)
}
dc.pluginConfig.SetSelectedLane(depsLaneAll)
result := dc.EnsureFirstNonEmptyLaneSelection()
if !result {
t.Error("should return true when lane was empty and switch occurred")
}
newLane := dc.pluginConfig.GetSelectedLane()
if newLane == depsLaneAll {
t.Error("should have switched away from empty All lane")
}
})
}
func TestDepsViewActions_NoOpenNewDelete(t *testing.T) {
registry := DepsViewActions()
actions := registry.GetActions()
forbidden := map[ActionID]bool{
ActionOpenFromPlugin: true,
ActionNewTask: true,
ActionDeleteTask: true,
}
for _, a := range actions {
if forbidden[a.ID] {
t.Errorf("DepsViewActions should not contain %s", a.ID)
}
}
required := map[ActionID]bool{
ActionNavUp: false,
ActionNavDown: false,
ActionMoveTaskLeft: false,
ActionMoveTaskRight: false,
ActionToggleViewMode: false,
ActionSearch: false,
}
for _, a := range actions {
if _, ok := required[a.ID]; ok {
required[a.ID] = true
}
}
for id, found := range required {
if !found {
t.Errorf("DepsViewActions missing required action %s", id)
}
}
}

View file

@ -34,6 +34,9 @@ func (dc *DokiController) GetPluginName() string {
return dc.pluginDef.Name
}
// ShowNavigation returns true — doki views show plugin navigation keys.
func (dc *DokiController) ShowNavigation() bool { return true }
// HandleAction processes a doki action
// Note: Most doki actions (Tab, Shift+Tab, Alt+Left, Alt+Right) are handled
// directly by the NavigableMarkdown component in the view. The controller

View file

@ -3,8 +3,11 @@ package controller
import (
"log/slog"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
@ -16,6 +19,16 @@ type PluginControllerInterface interface {
GetPluginName() string
HandleAction(ActionID) bool
HandleSearch(string)
ShowNavigation() bool
}
// TikiViewProvider is implemented by controllers that back a TikiPlugin view.
// The view factory uses this to create PluginView without knowing the concrete controller type.
type TikiViewProvider interface {
GetFilteredTasksForLane(lane int) []*task.Task
EnsureFirstNonEmptyLaneSelection() bool
GetActionRegistry() *ActionRegistry
ShowNavigation() bool
}
// InputRouter dispatches input events to appropriate controllers
@ -33,6 +46,7 @@ type InputRouter struct {
pluginControllers map[string]PluginControllerInterface // keyed by plugin name
globalActions *ActionRegistry
taskStore store.Store
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
}
// NewInputRouter creates an input router
@ -176,6 +190,52 @@ func (ir *InputRouter) maybeHandleTaskEditFieldFocus(activeView View, isTaskEdit
return false, false
}
// SetPluginRegistrar sets the callback used to register dynamically created plugins
// (e.g., the deps editor) with the view factory.
func (ir *InputRouter) SetPluginRegistrar(fn func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)) {
ir.registerPlugin = fn
}
// openDepsEditor creates (or reopens) a deps editor plugin for the given task ID.
func (ir *InputRouter) openDepsEditor(taskID string) bool {
name := "deps:" + taskID
viewID := model.MakePluginViewID(name)
// reopen if already created
if _, exists := ir.pluginControllers[name]; exists {
ir.navController.PushView(viewID, nil)
return true
}
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: name,
ConfigIndex: -1,
Type: "tiki",
Background: config.DepsEditorBackground,
},
TaskID: taskID,
Lanes: []plugin.TikiLane{
{Name: "Blocks"},
{Name: "All"},
{Name: "Depends"},
},
}
pluginConfig := model.NewPluginConfig(name)
pluginConfig.SetLaneLayout([]int{1, 2, 1})
ctrl := NewDepsController(ir.taskStore, pluginConfig, pluginDef, ir.navController)
if ir.registerPlugin != nil {
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)
}
ir.pluginControllers[name] = ctrl
ir.navController.PushView(viewID, nil)
return true
}
// handlePluginInput routes input to the appropriate plugin controller
func (ir *InputRouter) handlePluginInput(event *tcell.EventKey, viewID model.ViewID) bool {
pluginName := model.GetPluginName(viewID)
@ -285,6 +345,12 @@ func (ir *InputRouter) handleTaskInput(event *tcell.EventKey, params map[string]
return true
}
return false
case ActionEditDeps:
taskID := ir.taskController.GetCurrentTaskID()
if taskID == "" {
return false
}
return ir.openDepsEditor(taskID)
default:
return ir.taskController.HandleAction(action.ID)
}

View file

@ -236,3 +236,9 @@ type StatsProvider interface {
// GetStats returns stats to display in the header for this view
GetStats() []store.Stat
}
// NavigationProvider is implemented by views that declare whether plugin
// navigation keys should be shown in the header when this view is active.
type NavigationProvider interface {
ShowNavigation() bool
}

View file

@ -96,6 +96,9 @@ func (pc *PluginController) GetPluginName() string {
return pc.pluginDef.Name
}
// ShowNavigation returns true — regular plugin views show plugin navigation keys.
func (pc *PluginController) ShowNavigation() bool { return true }
// HandleAction processes a plugin action
func (pc *PluginController) HandleAction(actionID ActionID) bool {
switch actionID {

View file

@ -130,6 +130,11 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
viewFactory := view.NewViewFactory(taskStore)
viewFactory.SetPlugins(pluginConfigs, pluginDefs, controllers.Plugins)
// Wire dynamic plugin registration (deps editor creates plugins at runtime)
inputRouter.SetPluginRegistrar(func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl controller.PluginControllerInterface) {
viewFactory.RegisterPlugin(name, cfg, def, ctrl)
})
headerWidget := header.NewHeaderWidget(headerConfig)
rootLayout := view.NewRootLayout(headerWidget, headerConfig, layoutModel, viewFactory, taskStore, application)

View file

@ -61,6 +61,7 @@ type TikiPlugin struct {
Sort []SortRule // parsed sort rules (nil = default sort)
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
Actions []PluginAction // shortcut actions applied to the selected task
TaskID string // optional tiki associated with this plugin (code-only, not from workflow config)
}
// DokiPlugin is a documentation-based plugin

View file

@ -84,15 +84,15 @@ func (s *TikiStore) Search(query string, filterFunc func(*taskpkg.Task) bool) []
return results
}
// sortTasks sorts tasks by priority first (lower number = higher priority), then by title alphabetically
// sortTasks sorts tasks by priority first (lower number = higher priority), then by title, then by ID as tiebreaker
func sortTasks(tasks []*taskpkg.Task) {
sort.Slice(tasks, func(i, j int) bool {
// First compare by priority (lower number = higher priority)
if tasks[i].Priority != tasks[j].Priority {
return tasks[i].Priority < tasks[j].Priority
}
// If priority is the same, sort by Title alphabetically
return tasks[i].Title < tasks[j].Title
if tasks[i].Title != tasks[j].Title {
return tasks[i].Title < tasks[j].Title
}
return tasks[i].ID < tasks[j].ID
})
}

View file

@ -35,6 +35,15 @@ func TestSortTasks(t *testing.T) {
},
expected: []string{"TIKI-abc2zz", "TIKI-abc1zz", "TIKI-abc10z"}, // Apple, Mango, Zebra
},
{
name: "same priority and title - tiebreak by ID",
tasks: []*taskpkg.Task{
{ID: "TIKI-ccc333", Title: "Same", Priority: 2},
{ID: "TIKI-aaa111", Title: "Same", Priority: 2},
{ID: "TIKI-bbb222", Title: "Same", Priority: 2},
},
expected: []string{"TIKI-aaa111", "TIKI-bbb222", "TIKI-ccc333"},
},
{
name: "empty task list",
tasks: []*taskpkg.Task{},

87
task/due.go Normal file
View file

@ -0,0 +1,87 @@
package task
import (
"log/slog"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const DateFormat = "2006-01-02"
// DueValue is a custom type for due dates that provides lenient YAML unmarshaling.
// It gracefully handles invalid YAML by defaulting to zero time instead of failing.
// Embeds time.Time to inherit IsZero() method (required for yaml:",omitempty").
type DueValue struct {
time.Time
}
// UnmarshalYAML implements custom unmarshaling for due dates with lenient error handling.
// Valid date strings in YYYY-MM-DD format are parsed normally. Invalid formats or types
// default to zero time with a warning log instead of returning an error.
func (d *DueValue) UnmarshalYAML(value *yaml.Node) error {
// Try to decode as string (normal case)
var dateStr string
if err := value.Decode(&dateStr); err == nil {
// Empty string means no due date (valid)
trimmed := strings.TrimSpace(dateStr)
if trimmed == "" {
*d = DueValue{}
return nil
}
// Parse as date
parsed, err := time.Parse(DateFormat, trimmed)
if err == nil {
*d = DueValue{Time: parsed}
return nil
}
// Invalid date format - log and default to zero
slog.Warn("invalid due date format, defaulting to empty",
"value", dateStr,
"line", value.Line,
"column", value.Column)
*d = DueValue{}
return nil
}
// If decoding fails, log warning and default to zero
slog.Warn("invalid due field type, defaulting to empty",
"received_type", value.Kind,
"line", value.Line,
"column", value.Column)
*d = DueValue{}
return nil // Don't return error - use default instead
}
// MarshalYAML implements YAML marshaling for DueValue.
// Returns empty string for zero time, otherwise formats as YYYY-MM-DD.
func (d DueValue) MarshalYAML() (any, error) {
if d.IsZero() {
return "", nil
}
return d.Format(DateFormat), nil
}
// ToTime converts DueValue to time.Time for use with Task entity.
func (d DueValue) ToTime() time.Time {
return d.Time
}
// ParseDueDate parses a date string in YYYY-MM-DD format.
// Returns (time.Time, true) on success, (zero time, false) on failure.
// Empty string is treated as valid and returns zero time with true.
func ParseDueDate(s string) (time.Time, bool) {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return time.Time{}, true
}
parsed, err := time.Parse(DateFormat, trimmed)
if err != nil {
return time.Time{}, false
}
return parsed, true
}

354
task/due_test.go Normal file
View file

@ -0,0 +1,354 @@
package task
import (
"testing"
"time"
"gopkg.in/yaml.v3"
)
func TestDueValue_UnmarshalYAML(t *testing.T) {
tests := []struct {
name string
yaml string
expectZero bool
expectValue string // YYYY-MM-DD format if not zero
wantErr bool
}{
// Valid scenarios
{
name: "empty due (omitted)",
yaml: "other: value",
expectZero: true,
wantErr: false,
},
{
name: "empty string",
yaml: "due: ''",
expectZero: true,
wantErr: false,
},
{
name: "valid date",
yaml: "due: 2026-03-16",
expectZero: false,
expectValue: "2026-03-16",
wantErr: false,
},
{
name: "valid date with quotes",
yaml: "due: '2026-03-16'",
expectZero: false,
expectValue: "2026-03-16",
wantErr: false,
},
{
name: "valid date with whitespace",
yaml: "due: ' 2026-03-16 '",
expectZero: false,
expectValue: "2026-03-16",
wantErr: false,
},
// Invalid scenarios - should default to zero with no error
{
name: "invalid date format",
yaml: "due: 03/16/2026",
expectZero: true,
wantErr: false,
},
{
name: "invalid date value",
yaml: "due: 2026-13-45",
expectZero: true,
wantErr: false,
},
{
name: "number instead of string",
yaml: "due: 20260316",
expectZero: true,
wantErr: false,
},
{
name: "boolean instead of string",
yaml: "due: true",
expectZero: true,
wantErr: false,
},
{
name: "object instead of string",
yaml: "due:\n key: value",
expectZero: true,
wantErr: false,
},
{
name: "null value",
yaml: "due: null",
expectZero: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
Due DueValue `yaml:"due,omitempty"`
}
var result testStruct
err := yaml.Unmarshal([]byte(tt.yaml), &result)
if (err != nil) != tt.wantErr {
t.Errorf("UnmarshalYAML() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.expectZero {
if !result.Due.IsZero() {
t.Errorf("UnmarshalYAML() expected zero time, got = %v", result.Due.ToTime())
}
} else {
if result.Due.IsZero() {
t.Errorf("UnmarshalYAML() expected non-zero time, got zero")
}
got := result.Due.Format(DateFormat)
if got != tt.expectValue {
t.Errorf("UnmarshalYAML() got = %v, expected %v", got, tt.expectValue)
}
}
})
}
}
func TestDueValue_MarshalYAML(t *testing.T) {
tests := []struct {
name string
due DueValue
expected string
}{
{
name: "zero time",
due: DueValue{},
expected: "due: \"\"\n",
},
{
name: "valid date",
due: DueValue{Time: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)},
expected: "due: \"2026-03-16\"\n",
},
{
name: "different valid date",
due: DueValue{Time: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)},
expected: "due: \"2026-12-31\"\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
Due DueValue `yaml:"due"`
}
input := testStruct{Due: tt.due}
got, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
if string(got) != tt.expected {
t.Errorf("MarshalYAML() got = %q, expected %q", string(got), tt.expected)
}
})
}
}
func TestDueValue_ToTime(t *testing.T) {
tests := []struct {
name string
due DueValue
expected time.Time
}{
{
name: "zero time",
due: DueValue{},
expected: time.Time{},
},
{
name: "valid date",
due: DueValue{Time: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)},
expected: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.due.ToTime()
if !got.Equal(tt.expected) {
t.Errorf("ToTime() got = %v, expected %v", got, tt.expected)
}
})
}
}
func TestParseDueDate(t *testing.T) {
tests := []struct {
name string
input string
wantTime string // YYYY-MM-DD format, or empty for zero
wantValid bool
}{
{
name: "valid date",
input: "2026-03-16",
wantTime: "2026-03-16",
wantValid: true,
},
{
name: "valid date with whitespace",
input: " 2026-03-16 ",
wantTime: "2026-03-16",
wantValid: true,
},
{
name: "empty string",
input: "",
wantTime: "",
wantValid: true,
},
{
name: "whitespace only",
input: " ",
wantTime: "",
wantValid: true,
},
{
name: "invalid format",
input: "03/16/2026",
wantTime: "",
wantValid: false,
},
{
name: "invalid date",
input: "2026-13-45",
wantTime: "",
wantValid: false,
},
{
name: "partial date",
input: "2026-03",
wantTime: "",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, valid := ParseDueDate(tt.input)
if valid != tt.wantValid {
t.Errorf("ParseDueDate() valid = %v, wantValid %v", valid, tt.wantValid)
return
}
if tt.wantTime == "" {
if !got.IsZero() {
t.Errorf("ParseDueDate() expected zero time, got = %v", got)
}
} else {
if got.IsZero() {
t.Errorf("ParseDueDate() expected non-zero time, got zero")
}
gotStr := got.Format(DateFormat)
if gotStr != tt.wantTime {
t.Errorf("ParseDueDate() got = %v, wantTime %v", gotStr, tt.wantTime)
}
}
})
}
}
func TestDueValue_RoundTrip(t *testing.T) {
tests := []struct {
name string
due DueValue
}{
{
name: "zero time",
due: DueValue{},
},
{
name: "valid date",
due: DueValue{Time: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)},
},
{
name: "different valid date",
due: DueValue{Time: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
Due DueValue `yaml:"due"`
}
// Marshal
input := testStruct{Due: tt.due}
yamlBytes, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Unmarshal
var output testStruct
err = yaml.Unmarshal(yamlBytes, &output)
if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
// Compare
got := output.Due.ToTime()
expected := tt.due.ToTime()
if !got.Equal(expected) {
t.Errorf("Round trip failed: got = %v, expected %v", got, expected)
}
})
}
}
func TestDueValue_OmitEmpty(t *testing.T) {
type testStruct struct {
Due DueValue `yaml:"due,omitempty"`
}
t.Run("zero time omitted", func(t *testing.T) {
input := testStruct{Due: DueValue{}}
yamlBytes, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Should be empty (field omitted)
if string(yamlBytes) != "{}\n" {
t.Errorf("omitempty failed: got = %q, expected %q", string(yamlBytes), "{}\n")
}
})
t.Run("non-zero time included", func(t *testing.T) {
input := testStruct{Due: DueValue{Time: time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)}}
yamlBytes, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
// Should contain the due field
var output testStruct
err = yaml.Unmarshal(yamlBytes, &output)
if err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if output.Due.IsZero() {
t.Errorf("non-zero time should not be omitted")
}
})
}

146
task/recurrence.go Normal file
View file

@ -0,0 +1,146 @@
package task
import (
"log/slog"
"strings"
"gopkg.in/yaml.v3"
)
// Recurrence represents a task recurrence pattern as a cron expression.
// Empty string means no recurrence.
type Recurrence string
const (
RecurrenceNone Recurrence = ""
RecurrenceDaily Recurrence = "0 0 * * *"
RecurrenceMonthly Recurrence = "0 0 1 * *"
)
type recurrenceInfo struct {
cron Recurrence
display string
}
// known recurrence patterns — order matters for AllRecurrenceDisplayValues
var knownRecurrences = []recurrenceInfo{
{RecurrenceNone, "None"},
{RecurrenceDaily, "Daily"},
{"0 0 * * MON", "Weekly on Monday"},
{"0 0 * * TUE", "Weekly on Tuesday"},
{"0 0 * * WED", "Weekly on Wednesday"},
{"0 0 * * THU", "Weekly on Thursday"},
{"0 0 * * FRI", "Weekly on Friday"},
{"0 0 * * SAT", "Weekly on Saturday"},
{"0 0 * * SUN", "Weekly on Sunday"},
{RecurrenceMonthly, "Monthly"},
}
// built at init from knownRecurrences
var (
cronToDisplay map[Recurrence]string
displayToCron map[string]Recurrence
validCronSet map[Recurrence]bool
)
func init() {
cronToDisplay = make(map[Recurrence]string, len(knownRecurrences))
displayToCron = make(map[string]Recurrence, len(knownRecurrences))
validCronSet = make(map[Recurrence]bool, len(knownRecurrences))
for _, r := range knownRecurrences {
cronToDisplay[r.cron] = r.display
displayToCron[strings.ToLower(r.display)] = r.cron
validCronSet[r.cron] = true
}
}
// RecurrenceValue is a custom type for recurrence that provides lenient YAML unmarshaling.
type RecurrenceValue struct {
Value Recurrence
}
// UnmarshalYAML implements custom unmarshaling for recurrence with lenient error handling.
func (r *RecurrenceValue) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err == nil {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
*r = RecurrenceValue{}
return nil
}
if parsed, ok := ParseRecurrence(trimmed); ok {
*r = RecurrenceValue{Value: parsed}
return nil
}
slog.Warn("invalid recurrence format, defaulting to empty",
"value", s,
"line", value.Line,
"column", value.Column)
*r = RecurrenceValue{}
return nil
}
slog.Warn("invalid recurrence field type, defaulting to empty",
"received_type", value.Kind,
"line", value.Line,
"column", value.Column)
*r = RecurrenceValue{}
return nil
}
// MarshalYAML implements YAML marshaling for RecurrenceValue.
func (r RecurrenceValue) MarshalYAML() (any, error) {
return string(r.Value), nil
}
// IsZero reports whether the recurrence is empty (needed for omitempty).
func (r RecurrenceValue) IsZero() bool {
return r.Value == RecurrenceNone
}
// ToRecurrence converts RecurrenceValue to Recurrence.
func (r RecurrenceValue) ToRecurrence() Recurrence {
return r.Value
}
// ParseRecurrence validates a cron string against known patterns.
func ParseRecurrence(s string) (Recurrence, bool) {
normalized := Recurrence(strings.ToLower(strings.TrimSpace(s)))
// accept both lowercase and original casing
for _, r := range knownRecurrences {
if Recurrence(strings.ToLower(string(r.cron))) == normalized {
return r.cron, true
}
}
return RecurrenceNone, false
}
// RecurrenceDisplay converts a cron expression to English display.
func RecurrenceDisplay(r Recurrence) string {
if d, ok := cronToDisplay[r]; ok {
return d
}
return "None"
}
// RecurrenceFromDisplay converts an English display string to a cron expression.
func RecurrenceFromDisplay(display string) Recurrence {
if c, ok := displayToCron[strings.ToLower(strings.TrimSpace(display))]; ok {
return c
}
return RecurrenceNone
}
// AllRecurrenceDisplayValues returns the ordered list of display values for UI selection.
func AllRecurrenceDisplayValues() []string {
result := make([]string, len(knownRecurrences))
for i, r := range knownRecurrences {
result[i] = r.display
}
return result
}
// IsValidRecurrence returns true if the recurrence is empty or matches a known pattern.
func IsValidRecurrence(r Recurrence) bool {
return validCronSet[r]
}

366
task/recurrence_test.go Normal file
View file

@ -0,0 +1,366 @@
package task
import (
"testing"
"gopkg.in/yaml.v3"
)
func TestRecurrenceValue_UnmarshalYAML(t *testing.T) {
tests := []struct {
name string
yaml string
expectEmpty bool
expectValue Recurrence
}{
{
name: "empty (omitted)",
yaml: "other: value",
expectEmpty: true,
},
{
name: "empty string",
yaml: "recurrence: ''",
expectEmpty: true,
},
{
name: "valid daily cron",
yaml: "recurrence: '0 0 * * *'",
expectEmpty: false,
expectValue: RecurrenceDaily,
},
{
name: "valid weekly monday",
yaml: "recurrence: '0 0 * * MON'",
expectEmpty: false,
expectValue: "0 0 * * MON",
},
{
name: "valid monthly",
yaml: "recurrence: '0 0 1 * *'",
expectEmpty: false,
expectValue: RecurrenceMonthly,
},
{
name: "case insensitive",
yaml: "recurrence: '0 0 * * mon'",
expectEmpty: false,
expectValue: "0 0 * * MON",
},
{
name: "invalid cron defaults to empty",
yaml: "recurrence: '*/5 * * * *'",
expectEmpty: true,
},
{
name: "random string defaults to empty",
yaml: "recurrence: 'every tuesday'",
expectEmpty: true,
},
{
name: "number defaults to empty",
yaml: "recurrence: 42",
expectEmpty: true,
},
{
name: "boolean defaults to empty",
yaml: "recurrence: true",
expectEmpty: true,
},
{
name: "null value",
yaml: "recurrence: null",
expectEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
Recurrence RecurrenceValue `yaml:"recurrence,omitempty"`
}
var result testStruct
err := yaml.Unmarshal([]byte(tt.yaml), &result)
if err != nil {
t.Fatalf("UnmarshalYAML() error = %v", err)
}
if tt.expectEmpty {
if result.Recurrence.Value != RecurrenceNone {
t.Errorf("expected empty recurrence, got %q", result.Recurrence.Value)
}
} else {
if result.Recurrence.Value != tt.expectValue {
t.Errorf("got %q, expected %q", result.Recurrence.Value, tt.expectValue)
}
}
})
}
}
func TestRecurrenceValue_MarshalYAML(t *testing.T) {
tests := []struct {
name string
value RecurrenceValue
expected string
}{
{
name: "empty",
value: RecurrenceValue{},
expected: "recurrence: \"\"\n",
},
{
name: "daily",
value: RecurrenceValue{Value: RecurrenceDaily},
expected: "recurrence: 0 0 * * *\n",
},
{
name: "weekly monday",
value: RecurrenceValue{Value: "0 0 * * MON"},
expected: "recurrence: 0 0 * * MON\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
type testStruct struct {
Recurrence RecurrenceValue `yaml:"recurrence"`
}
got, err := yaml.Marshal(testStruct{Recurrence: tt.value})
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
if string(got) != tt.expected {
t.Errorf("got %q, expected %q", string(got), tt.expected)
}
})
}
}
func TestRecurrenceValue_RoundTrip(t *testing.T) {
values := []Recurrence{
RecurrenceNone,
RecurrenceDaily,
"0 0 * * MON",
"0 0 * * FRI",
RecurrenceMonthly,
}
for _, v := range values {
t.Run(string(v), func(t *testing.T) {
type testStruct struct {
Recurrence RecurrenceValue `yaml:"recurrence"`
}
input := testStruct{Recurrence: RecurrenceValue{Value: v}}
data, err := yaml.Marshal(input)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var output testStruct
if err := yaml.Unmarshal(data, &output); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if output.Recurrence.Value != v {
t.Errorf("round-trip failed: got %q, expected %q", output.Recurrence.Value, v)
}
})
}
}
func TestRecurrenceValue_OmitEmpty(t *testing.T) {
type testStruct struct {
Recurrence RecurrenceValue `yaml:"recurrence,omitempty"`
}
t.Run("empty omitted", func(t *testing.T) {
got, err := yaml.Marshal(testStruct{})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if string(got) != "{}\n" {
t.Errorf("omitempty failed: got %q, expected %q", string(got), "{}\n")
}
})
t.Run("non-empty included", func(t *testing.T) {
got, err := yaml.Marshal(testStruct{Recurrence: RecurrenceValue{Value: RecurrenceDaily}})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if string(got) == "{}\n" {
t.Error("non-empty value should not be omitted")
}
})
}
func TestParseRecurrence(t *testing.T) {
tests := []struct {
input string
want Recurrence
ok bool
}{
{"0 0 * * *", RecurrenceDaily, true},
{"0 0 * * MON", "0 0 * * MON", true},
{"0 0 * * mon", "0 0 * * MON", true},
{"0 0 1 * *", RecurrenceMonthly, true},
{"0 0 * * SUN", "0 0 * * SUN", true},
{"", RecurrenceNone, true},
{"*/5 * * * *", RecurrenceNone, false},
{"every day", RecurrenceNone, false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, ok := ParseRecurrence(tt.input)
if ok != tt.ok {
t.Errorf("ParseRecurrence(%q) ok = %v, want %v", tt.input, ok, tt.ok)
}
if got != tt.want {
t.Errorf("ParseRecurrence(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRecurrenceDisplay(t *testing.T) {
tests := []struct {
input Recurrence
want string
}{
{RecurrenceNone, "None"},
{RecurrenceDaily, "Daily"},
{"0 0 * * MON", "Weekly on Monday"},
{"0 0 * * TUE", "Weekly on Tuesday"},
{"0 0 * * WED", "Weekly on Wednesday"},
{"0 0 * * THU", "Weekly on Thursday"},
{"0 0 * * FRI", "Weekly on Friday"},
{"0 0 * * SAT", "Weekly on Saturday"},
{"0 0 * * SUN", "Weekly on Sunday"},
{RecurrenceMonthly, "Monthly"},
{"unknown", "None"},
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
got := RecurrenceDisplay(tt.input)
if got != tt.want {
t.Errorf("RecurrenceDisplay(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRecurrenceFromDisplay(t *testing.T) {
tests := []struct {
input string
want Recurrence
}{
{"None", RecurrenceNone},
{"Daily", RecurrenceDaily},
{"Weekly on Monday", "0 0 * * MON"},
{"weekly on monday", "0 0 * * MON"},
{"Monthly", RecurrenceMonthly},
{"unknown", RecurrenceNone},
{"", RecurrenceNone},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := RecurrenceFromDisplay(tt.input)
if got != tt.want {
t.Errorf("RecurrenceFromDisplay(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestAllRecurrenceDisplayValues(t *testing.T) {
values := AllRecurrenceDisplayValues()
if len(values) == 0 {
t.Fatal("expected at least one display value")
}
if values[0] != "None" {
t.Errorf("first value should be None, got %q", values[0])
}
if values[len(values)-1] != "Monthly" {
t.Errorf("last value should be Monthly, got %q", values[len(values)-1])
}
// every display value must round-trip through RecurrenceFromDisplay → RecurrenceDisplay
for _, v := range values {
cron := RecurrenceFromDisplay(v)
got := RecurrenceDisplay(cron)
if got != v {
t.Errorf("round-trip failed for %q: RecurrenceDisplay(RecurrenceFromDisplay(%q)) = %q", v, v, got)
}
}
}
func TestIsValidRecurrence(t *testing.T) {
tests := []struct {
input Recurrence
want bool
}{
{RecurrenceNone, true},
{RecurrenceDaily, true},
{"0 0 * * MON", true},
{"0 0 * * TUE", true},
{"0 0 * * WED", true},
{"0 0 * * THU", true},
{"0 0 * * FRI", true},
{"0 0 * * SAT", true},
{"0 0 * * SUN", true},
{RecurrenceMonthly, true},
{"*/5 * * * *", false},
{"bogus", false},
{"0 0 * * MON ", false}, // trailing space
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
got := IsValidRecurrence(tt.input)
if got != tt.want {
t.Errorf("IsValidRecurrence(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestRecurrenceValue_ToRecurrence(t *testing.T) {
t.Run("zero value returns RecurrenceNone", func(t *testing.T) {
var rv RecurrenceValue
if rv.ToRecurrence() != RecurrenceNone {
t.Errorf("got %q, want %q", rv.ToRecurrence(), RecurrenceNone)
}
})
t.Run("daily value returns RecurrenceDaily", func(t *testing.T) {
rv := RecurrenceValue{Value: RecurrenceDaily}
if rv.ToRecurrence() != RecurrenceDaily {
t.Errorf("got %q, want %q", rv.ToRecurrence(), RecurrenceDaily)
}
})
}
func TestRecurrenceValue_IsZero(t *testing.T) {
tests := []struct {
name string
rv RecurrenceValue
want bool
}{
{"zero value", RecurrenceValue{}, true},
{"explicit none", RecurrenceValue{Value: RecurrenceNone}, true},
{"daily", RecurrenceValue{Value: RecurrenceDaily}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.rv.IsZero(); got != tt.want {
t.Errorf("IsZero() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -136,6 +136,9 @@ func (dv *DokiView) rebuildLayout() {
dv.root.AddItem(dv.markdown.Viewer(), 0, 1, true)
}
// ShowNavigation returns true — doki views always show plugin navigation keys.
func (dv *DokiView) ShowNavigation() bool { return true }
func (dv *DokiView) GetPrimitive() tview.Primitive {
return dv.root
}

View file

@ -55,6 +55,13 @@ func (f *ViewFactory) SetPlugins(
f.pluginControllers = controllers
}
// RegisterPlugin registers a dynamically created plugin (e.g., deps editor) with the view factory.
func (f *ViewFactory) RegisterPlugin(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl controller.PluginControllerInterface) {
f.pluginConfigs[name] = cfg
f.pluginDefs[name] = def
f.pluginControllers[name] = ctrl
}
// CreateView instantiates a view by ID with optional parameters
func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interface{}) controller.View {
var v controller.View
@ -84,18 +91,18 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
if pluginDef != nil {
if tikiPlugin, ok := pluginDef.(*plugin.TikiPlugin); ok && pluginConfig != nil && pluginControllerInterface != nil {
// For TikiPlugins, we need the specific PluginController for GetFilteredTasks
if tikiController, ok := pluginControllerInterface.(*controller.PluginController); ok {
if tikiCtrl, ok := pluginControllerInterface.(controller.TikiViewProvider); ok {
v = NewPluginView(
f.taskStore,
pluginConfig,
tikiPlugin,
tikiController.GetFilteredTasksForLane,
tikiController.EnsureFirstNonEmptyLaneSelection,
tikiController.GetActionRegistry(),
tikiCtrl.GetFilteredTasksForLane,
tikiCtrl.EnsureFirstNonEmptyLaneSelection,
tikiCtrl.GetActionRegistry(),
tikiCtrl.ShowNavigation(),
)
} else {
slog.Error("plugin controller type mismatch", "plugin", pluginName)
slog.Error("plugin controller does not implement TikiViewProvider", "plugin", pluginName)
}
} else if dokiPlugin, ok := pluginDef.(*plugin.DokiPlugin); ok {
v = NewDokiView(dokiPlugin, f.imageManager, f.mermaidOpts)

View file

@ -123,13 +123,10 @@ func (rl *RootLayout) onLayoutChange() {
// Update header with new view's actions
rl.headerConfig.SetViewActions(convertActionRegistry(newView.GetActionRegistry()))
// Clear plugin actions for non-plugin views (task detail, task edit)
// Plugin navigation keys only work in plugin views
if model.IsPluginViewID(viewID) {
// Restore plugin actions for plugin views
// Show or hide plugin navigation keys based on the view's declaration
if np, ok := newView.(controller.NavigationProvider); ok && np.ShowNavigation() {
rl.headerConfig.SetPluginActions(convertActionRegistry(controller.GetPluginActions()))
} else {
// Clear plugin actions for task detail/edit views
rl.headerConfig.SetPluginActions(nil)
}

View file

@ -58,6 +58,11 @@ func (s *ScrollableList) GetScrollOffset() int {
return s.scrollOffset
}
// ResetScrollOffset resets the scroll offset to 0
func (s *ScrollableList) ResetScrollOffset() {
s.scrollOffset = 0
}
// ensureSelectionVisible adjusts scrollOffset to keep selectionIndex in view
func (s *ScrollableList) ensureSelectionVisible() {
// If no items, preserve scrollOffset (will be adjusted after items are added)

View file

@ -0,0 +1,218 @@
package taskdetail
// metadata_layout.go contains the pure responsive layout algorithm for the
// task detail metadata box. It has no tview or config dependencies — just
// integers in, plan out — so it can be tested and swapped independently.
const maxLeftSideGap = 8
// SectionID identifies a metadata section in left-to-right display order.
type SectionID int
const (
SectionStatusGroup SectionID = iota // status, type, priority, points
SectionPeopleGroup // assignee, author, created, updated
SectionDueGroup // due, recurrence
SectionTags
SectionDependsOn
SectionBlocks
)
// rightSideSections are hidden in this order when space is tight.
var rightSideHideOrder = []SectionID{SectionTags, SectionBlocks, SectionDependsOn}
// SectionInput describes one candidate section for the layout.
type SectionInput struct {
ID SectionID
Width int // minimum width this section needs
HasContent bool // false = skip entirely (optional section with no data)
}
// PlannedSection is a section that made it into the final layout.
type PlannedSection struct {
ID SectionID
Width int
}
// LayoutPlan is the output of the layout algorithm.
type LayoutPlan struct {
Sections []PlannedSection
Gaps []int // len = len(Sections) - 1; gap[i] is between Sections[i] and Sections[i+1]
}
// isRightSide returns true for sections 3-5 (Tags, DependsOn, Blocks).
func isRightSide(id SectionID) bool {
return id >= SectionTags
}
// CalculateMetadataLayout computes which sections to show and how to distribute
// horizontal space among them.
//
// Algorithm:
// 1. Include only sections that have content.
// 2. Check whether all included sections + 1-char minimum gaps fit.
// 3. If not, hide right-side sections in order: Tags → Blocks → DependsOn.
// 4. Distribute remaining free space evenly across gaps.
// 5. Any remainder goes to the "bridge gap" (between last left-side and first
// right-side section). If no right-side sections remain, remainder goes to
// the last gap.
func CalculateMetadataLayout(availableWidth int, sections []SectionInput) LayoutPlan {
// step 1: filter to sections that have content
active := filterActive(sections)
if len(active) == 0 {
return LayoutPlan{}
}
// step 2-3: shed right-side sections until everything fits
active = shedUntilFit(active, availableWidth)
if len(active) == 0 {
return LayoutPlan{}
}
if len(active) == 1 {
return LayoutPlan{
Sections: []PlannedSection{{ID: active[0].ID, Width: active[0].Width}},
}
}
// step 4-5: distribute free space
return distributeSpace(active, availableWidth)
}
// filterActive keeps only sections whose HasContent is true.
func filterActive(sections []SectionInput) []SectionInput {
var out []SectionInput
for _, s := range sections {
if s.HasContent {
out = append(out, s)
}
}
return out
}
// totalMinWidth returns the minimum width required for sections + 1-char gaps.
func totalMinWidth(sections []SectionInput) int {
w := 0
for _, s := range sections {
w += s.Width
}
if len(sections) > 1 {
w += len(sections) - 1 // 1-char gap between each
}
return w
}
// shedUntilFit removes right-side sections in hide order until the remaining
// sections fit within availableWidth.
func shedUntilFit(active []SectionInput, availableWidth int) []SectionInput {
for _, hideID := range rightSideHideOrder {
if totalMinWidth(active) <= availableWidth {
return active
}
active = removeSection(active, hideID)
}
return active
}
// removeSection returns a new slice with the given section ID removed.
func removeSection(sections []SectionInput, id SectionID) []SectionInput {
var out []SectionInput
for _, s := range sections {
if s.ID != id {
out = append(out, s)
}
}
return out
}
// distributeSpace assigns widths and gaps given sections that are known to fit.
//
// 1. All sections start at their declared min width, all gaps at 1.
// 2. Remaining free space is split: left-side gaps expand (capped at
// maxLeftSideGap); then right-side sections expand equally.
// 3. Any leftover from the left-gap cap goes to the bridge gap.
func distributeSpace(active []SectionInput, availableWidth int) LayoutPlan {
numGaps := len(active) - 1
bridgeIdx := findBridgeGap(active)
planned := make([]PlannedSection, len(active))
for i, s := range active {
planned[i] = PlannedSection{ID: s.ID, Width: s.Width}
}
gaps := make([]int, numGaps)
for i := range gaps {
gaps[i] = 1
}
// classify sections
var rightIndices []int
for i, s := range active {
if isRightSide(s.ID) {
rightIndices = append(rightIndices, i)
}
}
totalUsed := func() int {
w := 0
for _, p := range planned {
w += p.Width
}
for _, g := range gaps {
w += g
}
return w
}
free := availableWidth - totalUsed()
if free <= 0 {
return LayoutPlan{Sections: planned, Gaps: gaps}
}
// step 1: expand left-side gaps (capped at maxLeftSideGap)
leftGapCount := bridgeIdx
if bridgeIdx < 0 {
leftGapCount = numGaps
}
if leftGapCount > 0 {
perGap := min(free/leftGapCount, maxLeftSideGap-1)
for i := 0; i < leftGapCount; i++ {
gaps[i] += perGap
}
free = availableWidth - totalUsed()
}
// step 2: expand right-side sections equally with remaining space
if len(rightIndices) > 0 && free > 0 {
perRight := free / len(rightIndices)
remainder := free % len(rightIndices)
for j, ri := range rightIndices {
planned[ri].Width += perRight
if j == len(rightIndices)-1 {
planned[ri].Width += remainder
}
}
free = availableWidth - totalUsed()
}
// step 3: any rounding leftover goes to bridge gap (or last gap)
if free > 0 {
sinkIdx := numGaps - 1
if bridgeIdx >= 0 {
sinkIdx = bridgeIdx
}
gaps[sinkIdx] += free
}
return LayoutPlan{Sections: planned, Gaps: gaps}
}
// findBridgeGap returns the gap index between the last left-side section and
// the first right-side section, or -1 if no right-side sections exist.
func findBridgeGap(active []SectionInput) int {
for i := 0; i < len(active)-1; i++ {
if !isRightSide(active[i].ID) && isRightSide(active[i+1].ID) {
return i
}
}
return -1
}

View file

@ -0,0 +1,392 @@
package taskdetail
import (
"testing"
)
// allSixSections returns the standard 6-section input with all content present.
// Widths mirror realistic values: left-side=30, tags=10, dep/blk=30.
func allSixSections() []SectionInput {
return []SectionInput{
{ID: SectionStatusGroup, Width: 30, HasContent: true},
{ID: SectionPeopleGroup, Width: 30, HasContent: true},
{ID: SectionDueGroup, Width: 30, HasContent: true},
{ID: SectionTags, Width: 10, HasContent: true},
{ID: SectionDependsOn, Width: 30, HasContent: true},
{ID: SectionBlocks, Width: 30, HasContent: true},
}
}
func TestAllSectionsFit(t *testing.T) {
// 30+30+30+10+30+30 = 160 min widths + 5 gaps = 165 min
// give 190: left gaps expand to 8 each, right sections expand with remaining
plan := CalculateMetadataLayout(190, allSixSections())
if len(plan.Sections) != 6 {
t.Fatalf("expected 6 sections, got %d", len(plan.Sections))
}
if len(plan.Gaps) != 5 {
t.Fatalf("expected 5 gaps, got %d", len(plan.Gaps))
}
// left-side gaps capped at 8, bridge+right gaps stay at 1
expectedGaps := []int{8, 8, 1, 1, 1}
for i, g := range plan.Gaps {
if g != expectedGaps[i] {
t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i])
}
}
// right-side sections expand: (190-90-19)/3 = 27, remainder distributed
verifyTotalWidth(t, plan, 190)
}
func TestAllSectionsFit_RemainderInBridgeGap(t *testing.T) {
// 160 widths + 5 gaps min = 165; give 178
// left gaps: free=13, perGap=13/2=6, gaps=[7,7], then right sections expand
plan := CalculateMetadataLayout(178, allSixSections())
if len(plan.Sections) != 6 {
t.Fatalf("expected 6 sections, got %d", len(plan.Sections))
}
expectedGaps := []int{7, 7, 1, 1, 1}
for i, g := range plan.Gaps {
if g != expectedGaps[i] {
t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i])
}
}
verifyTotalWidth(t, plan, 178)
}
func TestTagsHiddenFirst(t *testing.T) {
// min with all 6: 160 + 5 = 165. With Tags removed: 150 + 4 = 154
// give 160 — doesn't fit all 6 (165), but fits without Tags (154)
plan := CalculateMetadataLayout(160, allSixSections())
for _, s := range plan.Sections {
if s.ID == SectionTags {
t.Error("Tags should be hidden but is present")
}
}
if len(plan.Sections) != 5 {
t.Fatalf("expected 5 sections, got %d", len(plan.Sections))
}
verifyTotalWidth(t, plan, 160)
}
func TestTagsAndBlocksHidden(t *testing.T) {
// without Tags+Blocks: 30+30+30+30 = 120 + 3 gaps = 123
// give 130 — doesn't fit 5 sections (154 without Tags), but fits 4 (123)
plan := CalculateMetadataLayout(130, allSixSections())
ids := sectionIDs(plan)
if contains(ids, SectionTags) {
t.Error("Tags should be hidden")
}
if contains(ids, SectionBlocks) {
t.Error("Blocks should be hidden")
}
if !contains(ids, SectionDependsOn) {
t.Error("DependsOn should still be visible")
}
if len(plan.Sections) != 4 {
t.Fatalf("expected 4 sections, got %d", len(plan.Sections))
}
verifyTotalWidth(t, plan, 130)
}
func TestAllRightSideHidden(t *testing.T) {
// without all right-side: 30+30+30 = 90 + 2 gaps = 92
// give 95
plan := CalculateMetadataLayout(95, allSixSections())
if len(plan.Sections) != 3 {
t.Fatalf("expected 3 sections, got %d", len(plan.Sections))
}
for _, s := range plan.Sections {
if isRightSide(s.ID) {
t.Errorf("right-side section %d should be hidden", s.ID)
}
}
// free = 95-92 = 3, 2 left gaps, perGap=min(3/2,7)=1, gaps=[2, 2]
// leftover 1 goes to last gap → [2, 3]
expected := []int{2, 3}
for i, g := range plan.Gaps {
if g != expected[i] {
t.Errorf("gap[%d] = %d, want %d", i, g, expected[i])
}
}
verifyTotalWidth(t, plan, 95)
}
func TestDueGroupEmpty_BridgeShifts(t *testing.T) {
// Due/Recurrence empty — bridge gap between section 1 and first right-side
sections := []SectionInput{
{ID: SectionStatusGroup, Width: 30, HasContent: true},
{ID: SectionPeopleGroup, Width: 30, HasContent: true},
{ID: SectionDueGroup, Width: 30, HasContent: false},
{ID: SectionTags, Width: 10, HasContent: true},
{ID: SectionDependsOn, Width: 30, HasContent: true},
{ID: SectionBlocks, Width: 30, HasContent: true},
}
// active: Status(30) People(30) Tags(10) DepsOn(30) Blocks(30) = 130 + 4 gaps
plan := CalculateMetadataLayout(149, sections)
if len(plan.Sections) != 5 {
t.Fatalf("expected 5 sections, got %d", len(plan.Sections))
}
if contains(sectionIDs(plan), SectionDueGroup) {
t.Error("DueGroup should be excluded (no content)")
}
// bridge at index 1 (between People and Tags), 1 left gap
// free=149-130-4=15, leftGapCount=1, perGap=min(15/1,7)=7, gap[0]=8
// right expand: remaining=149-60-8-1-1-1=78, 78/3=26 each
expectedGaps := []int{8, 1, 1, 1}
for i, g := range plan.Gaps {
if g != expectedGaps[i] {
t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i])
}
}
verifyTotalWidth(t, plan, 149)
}
func TestOnlyRequiredSections(t *testing.T) {
sections := []SectionInput{
{ID: SectionStatusGroup, Width: 30, HasContent: true},
{ID: SectionPeopleGroup, Width: 30, HasContent: true},
{ID: SectionDueGroup, Width: 30, HasContent: false},
{ID: SectionTags, Width: 10, HasContent: false},
{ID: SectionDependsOn, Width: 30, HasContent: false},
{ID: SectionBlocks, Width: 30, HasContent: false},
}
plan := CalculateMetadataLayout(100, sections)
if len(plan.Sections) != 2 {
t.Fatalf("expected 2 sections, got %d", len(plan.Sections))
}
if len(plan.Gaps) != 1 {
t.Fatalf("expected 1 gap, got %d", len(plan.Gaps))
}
// free = 100 - 60 = 40, 1 left gap → gap = min(40/1, 7)+1 = 8, leftover 32 goes to last gap
// total: 30+30+40 = 100
if plan.Gaps[0] != 40 {
t.Errorf("gap = %d, want 40", plan.Gaps[0])
}
}
func TestExactFit(t *testing.T) {
// 160 + 5 = 165 exactly — no free space, all gaps stay at 1
plan := CalculateMetadataLayout(165, allSixSections())
if len(plan.Sections) != 6 {
t.Fatalf("expected 6 sections, got %d", len(plan.Sections))
}
for i, g := range plan.Gaps {
if g != 1 {
t.Errorf("gap[%d] = %d, want 1", i, g)
}
}
}
func TestExtremelyNarrow(t *testing.T) {
// even min required (30+30+1=61) doesn't fit — but algorithm guarantees at least 1 gap
plan := CalculateMetadataLayout(50, allSixSections())
// all right-side sections should be shed; only left-side remain
for _, s := range plan.Sections {
if isRightSide(s.ID) {
t.Errorf("right-side section %d should be hidden", s.ID)
}
}
for i, g := range plan.Gaps {
if g < 1 {
t.Errorf("gap[%d] = %d, should be at least 1", i, g)
}
}
}
func TestSingleSection(t *testing.T) {
sections := []SectionInput{
{ID: SectionStatusGroup, Width: 30, HasContent: true},
{ID: SectionPeopleGroup, Width: 30, HasContent: false},
}
plan := CalculateMetadataLayout(100, sections)
if len(plan.Sections) != 1 {
t.Fatalf("expected 1 section, got %d", len(plan.Sections))
}
if len(plan.Gaps) != 0 {
t.Fatalf("expected 0 gaps, got %d", len(plan.Gaps))
}
}
func TestNoActiveSections(t *testing.T) {
sections := []SectionInput{
{ID: SectionStatusGroup, Width: 30, HasContent: false},
}
plan := CalculateMetadataLayout(100, sections)
if len(plan.Sections) != 0 {
t.Errorf("expected 0 sections, got %d", len(plan.Sections))
}
}
func TestLeftSideGapsCapped(t *testing.T) {
// wide terminal: all 6 sections, width 260
// left gaps cap at 8. Right-side sections expand with remaining space.
plan := CalculateMetadataLayout(260, allSixSections())
if len(plan.Sections) != 6 {
t.Fatalf("expected 6 sections, got %d", len(plan.Sections))
}
// left-side gaps capped at 8, bridge+right gaps at 1
expectedGaps := []int{8, 8, 1, 1, 1}
for i, g := range plan.Gaps {
if g != expectedGaps[i] {
t.Errorf("gap[%d] = %d, want %d", i, g, expectedGaps[i])
}
}
// verify left-side gaps are all <= maxLeftSideGap
for i := 0; i < len(plan.Gaps); i++ {
leftSide := !isRightSide(plan.Sections[i].ID) && !isRightSide(plan.Sections[i+1].ID)
if leftSide && plan.Gaps[i] > maxLeftSideGap {
t.Errorf("left-side gap[%d] = %d, exceeds max %d", i, plan.Gaps[i], maxLeftSideGap)
}
}
// right-side sections should have expanded significantly
for _, s := range plan.Sections {
if isRightSide(s.ID) && s.Width <= 30 {
t.Errorf("right-side section %d width=%d, should be expanded beyond min 30", s.ID, s.Width)
}
}
verifyTotalWidth(t, plan, 260)
}
func TestLeftSideGapsCapped_AllLeftSide(t *testing.T) {
// 3 left-side sections only, wide terminal
// widths = 90, free = 110, 2 gaps
// left gaps: perGap=min(110/2,7)=7, gaps=[8,8], used=106, leftover=94 → last gap
plan := CalculateMetadataLayout(200, allSixSections()[:3])
if len(plan.Sections) != 3 {
t.Fatalf("expected 3 sections, got %d", len(plan.Sections))
}
expected := []int{8, 102}
for i, g := range plan.Gaps {
if g != expected[i] {
t.Errorf("gap[%d] = %d, want %d", i, g, expected[i])
}
}
}
func TestTagsMinWidth(t *testing.T) {
tests := []struct {
name string
tags []string
want int
}{
{"longest tag wins", []string{"backend", "bug", "frontend", "search"}, 8},
{"label wins over short tags", []string{"a", "b"}, 5},
{"single long tag", []string{"infrastructure"}, 14},
{"empty tags", nil, 5},
{"tag exactly label length", []string{"abcd"}, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tagsMinWidth(tt.tags)
if got != tt.want {
t.Errorf("tagsMinWidth(%v) = %d, want %d", tt.tags, got, tt.want)
}
})
}
}
func TestDynamicTagsWidth_KeepsTagsVisible(t *testing.T) {
// with dynamic tags(8) and dep/blk at 30: total = 158+5 = 163
// give 170 — comfortably fits all 6 sections
sections := []SectionInput{
{ID: SectionStatusGroup, Width: 30, HasContent: true},
{ID: SectionPeopleGroup, Width: 30, HasContent: true},
{ID: SectionDueGroup, Width: 30, HasContent: true},
{ID: SectionTags, Width: 8, HasContent: true},
{ID: SectionDependsOn, Width: 30, HasContent: true},
{ID: SectionBlocks, Width: 30, HasContent: true},
}
plan := CalculateMetadataLayout(170, sections)
if !contains(sectionIDs(plan), SectionTags) {
t.Error("Tags should be visible with dynamic width 8, but was hidden")
}
if len(plan.Sections) != 6 {
t.Errorf("expected 6 sections, got %d", len(plan.Sections))
}
}
func TestRightSideSectionsExpand(t *testing.T) {
// verify right-side sections get expanded widths, not just their minimum
// 160 min + 5 gaps = 165, give 200 → 35 extra
// left gaps: min(35/2, 7)=7 each → 14 used, 21 remaining for right sections
plan := CalculateMetadataLayout(200, allSixSections())
if len(plan.Sections) != 6 {
t.Fatalf("expected 6 sections, got %d", len(plan.Sections))
}
// right-side sections should be wider than their minimum
rightTotal := 0
for _, s := range plan.Sections {
if isRightSide(s.ID) {
rightTotal += s.Width
}
}
// min right total = 10+30+30 = 70; should be 70+21 = 91
if rightTotal <= 70 {
t.Errorf("right-side total width=%d, should be expanded beyond minimum 70", rightTotal)
}
verifyTotalWidth(t, plan, 200)
}
// helpers
func verifyTotalWidth(t *testing.T, plan LayoutPlan, expected int) {
t.Helper()
total := 0
for _, s := range plan.Sections {
total += s.Width
}
for _, g := range plan.Gaps {
total += g
}
if total != expected {
t.Errorf("total width = %d, want %d", total, expected)
}
}
func sectionIDs(plan LayoutPlan) []SectionID {
ids := make([]SectionID, len(plan.Sections))
for i, s := range plan.Sections {
ids[i] = s.ID
}
return ids
}
func contains(ids []SectionID, target SectionID) bool {
for _, id := range ids {
if id == target {
return true
}
}
return false
}

View file

@ -27,6 +27,7 @@ type PluginView struct {
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
registry *controller.ActionRegistry
showNavigation bool
storeListenerID int
selectionListenerID int
getLaneTasks func(lane int) []*task.Task // injected from controller
@ -41,12 +42,14 @@ func NewPluginView(
getLaneTasks func(lane int) []*task.Task,
ensureSelection func() bool,
registry *controller.ActionRegistry,
showNavigation bool,
) *PluginView {
pv := &PluginView{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
registry: registry,
showNavigation: showNavigation,
getLaneTasks: getLaneTasks,
ensureSelection: ensureSelection,
}
@ -171,6 +174,7 @@ func (pv *PluginView) refresh() {
laneContainer.SetSelection(selectedRow)
} else {
laneContainer.SetSelection(-1)
laneContainer.ResetScrollOffset()
}
// Sync scroll offset from view to model for later lane navigation
@ -188,6 +192,9 @@ func (pv *PluginView) GetActionRegistry() *controller.ActionRegistry {
return pv.registry
}
// ShowNavigation returns whether plugin navigation keys should be shown in the header.
func (pv *PluginView) ShowNavigation() bool { return pv.showNavigation }
// GetViewID returns the view identifier
func (pv *PluginView) GetViewID() model.ViewID {
return model.MakePluginViewID(pv.pluginDef.Name)

View file

@ -12,6 +12,67 @@ import (
"github.com/boolean-maybe/tiki/task"
)
func TestPluginViewRefreshResetsNonSelectedLaneScrollOffset(t *testing.T) {
taskStore := store.NewInMemoryStore()
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetLaneLayout([]int{1, 1})
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Lanes: []plugin.TikiLane{
{Name: "Lane0", Columns: 1},
{Name: "Lane1", Columns: 1},
},
}
tasks := make([]*task.Task, 10)
for i := range tasks {
tasks[i] = &task.Task{
ID: fmt.Sprintf("T-%d", i),
Title: fmt.Sprintf("Task %d", i),
Status: task.StatusReady,
Type: task.TypeStory,
}
}
pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(lane int) []*task.Task {
return tasks
}, nil, controller.PluginViewActions(), true)
itemHeight := config.TaskBoxHeight
for _, lb := range pv.laneBoxes {
lb.SetRect(0, 0, 80, itemHeight*5)
}
// select last task in lane 0 to force scroll offset
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, len(tasks)-1)
pv.refresh()
if pv.laneBoxes[0].scrollOffset == 0 {
t.Fatalf("expected lane 0 scroll offset > 0 after selecting last item")
}
// non-selected lane 1 must have scroll offset 0
if pv.laneBoxes[1].scrollOffset != 0 {
t.Fatalf("expected non-selected lane 1 scroll offset 0, got %d", pv.laneBoxes[1].scrollOffset)
}
// switch selection to lane 1, scroll it, then verify lane 0 resets
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, len(tasks)-1)
pv.refresh()
if pv.laneBoxes[1].scrollOffset == 0 {
t.Fatalf("expected lane 1 scroll offset > 0 after selecting last item")
}
if pv.laneBoxes[0].scrollOffset != 0 {
t.Fatalf("expected non-selected lane 0 scroll offset 0, got %d", pv.laneBoxes[0].scrollOffset)
}
}
func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) {
taskStore := store.NewInMemoryStore()
pluginConfig := model.NewPluginConfig("TestPlugin")
@ -38,7 +99,7 @@ func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) {
pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(lane int) []*task.Task {
return tasks
}, nil, controller.PluginViewActions())
}, nil, controller.PluginViewActions(), true)
if len(pv.laneBoxes) != 1 {
t.Fatalf("expected 1 lane box, got %d", len(pv.laneBoxes))