mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
dependency editor
This commit is contained in:
parent
f0765feee3
commit
839e6c45be
29 changed files with 3078 additions and 28 deletions
6
Makefile
6
Makefile
|
|
@ -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
237
component/task_list.go
Normal 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
283
component/task_list_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
7
component/testinit_test.go
Normal file
7
component/testinit_test.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package component
|
||||
|
||||
import "github.com/boolean-maybe/tiki/internal/teststatuses"
|
||||
|
||||
func init() {
|
||||
teststatuses.Init()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
334
controller/deps.go
Normal 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
418
controller/deps_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
87
task/due.go
Normal 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
354
task/due_test.go
Normal 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
146
task/recurrence.go
Normal 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
366
task/recurrence_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
218
view/taskdetail/metadata_layout.go
Normal file
218
view/taskdetail/metadata_layout.go
Normal 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
|
||||
}
|
||||
392
view/taskdetail/metadata_layout_test.go
Normal file
392
view/taskdetail/metadata_layout_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue