multi-pane plugins

This commit is contained in:
booleanmaybe 2026-01-21 16:16:54 -05:00
parent d8faf72a6d
commit 264d2ef65f
51 changed files with 2356 additions and 1026 deletions

View file

@ -19,6 +19,10 @@ type ColorConfig struct {
BoardColumnTitleText tcell.Color
BoardColumnBorder tcell.Color
BoardColumnTitleGradient Gradient
BoardPaneTitleBackground tcell.Color
BoardPaneTitleText tcell.Color
BoardPaneBorder tcell.Color
BoardPaneTitleGradient Gradient
// Task box colors
TaskBoxSelectedBackground tcell.Color
@ -81,6 +85,13 @@ func DefaultColors() *ColorConfig {
Start: [3]int{25, 25, 112}, // Midnight Blue (center)
End: [3]int{65, 105, 225}, // Royal Blue (edges)
},
BoardPaneTitleBackground: tcell.ColorNavy,
BoardPaneTitleText: tcell.PaletteColor(153), // Sky Blue (ANSI 153)
BoardPaneBorder: tcell.ColorDefault, // transparent/no border
BoardPaneTitleGradient: Gradient{
Start: [3]int{25, 25, 112}, // Midnight Blue (center)
End: [3]int{65, 105, 225}, // Royal Blue (edges)
},
// Task box
TaskBoxSelectedBackground: tcell.PaletteColor(33), // Blue (ANSI 33)

View file

@ -400,6 +400,8 @@ func PluginViewActions() *ActionRegistry {
// plugin actions (shown in header)
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
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})
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})

View file

@ -9,8 +9,7 @@ import (
"github.com/boolean-maybe/tiki/task"
)
// BoardController handles board view actions: column/task navigation, moving tasks, create/delete.
// BoardController handles board view actions: pane/task navigation, moving tasks, create/delete.
// BoardController handles board-specific actions
type BoardController struct {
taskStore store.Store
@ -68,12 +67,12 @@ func (bc *BoardController) HandleAction(actionID ActionID) bool {
}
func (bc *BoardController) handleNavLeft() bool {
columns := bc.boardConfig.GetColumns()
panes := bc.boardConfig.GetPanes()
currentIdx := -1
currentColID := bc.boardConfig.GetSelectedColumnID()
currentPaneID := bc.boardConfig.GetSelectedPaneID()
for i, col := range columns {
if col.ID == currentColID {
for i, pane := range panes {
if pane.ID == currentPaneID {
currentIdx = i
break
}
@ -83,12 +82,12 @@ func (bc *BoardController) handleNavLeft() bool {
return false
}
// find first non-empty column to the left
// find first non-empty pane to the left
for i := currentIdx - 1; i >= 0; i-- {
status := bc.boardConfig.GetStatusForColumn(columns[i].ID)
status := bc.boardConfig.GetStatusForPane(panes[i].ID)
tasks := bc.taskStore.GetTasksByStatus(status)
if len(tasks) > 0 {
bc.boardConfig.SetSelection(columns[i].ID, 0)
bc.boardConfig.SetSelection(panes[i].ID, 0)
return true
}
}
@ -96,12 +95,12 @@ func (bc *BoardController) handleNavLeft() bool {
}
func (bc *BoardController) handleNavRight() bool {
columns := bc.boardConfig.GetColumns()
panes := bc.boardConfig.GetPanes()
currentIdx := -1
currentColID := bc.boardConfig.GetSelectedColumnID()
currentPaneID := bc.boardConfig.GetSelectedPaneID()
for i, col := range columns {
if col.ID == currentColID {
for i, pane := range panes {
if pane.ID == currentPaneID {
currentIdx = i
break
}
@ -111,12 +110,12 @@ func (bc *BoardController) handleNavRight() bool {
return false
}
// find first non-empty column to the right
for i := currentIdx + 1; i < len(columns); i++ {
status := bc.boardConfig.GetStatusForColumn(columns[i].ID)
// find first non-empty pane to the right
for i := currentIdx + 1; i < len(panes); i++ {
status := bc.boardConfig.GetStatusForPane(panes[i].ID)
tasks := bc.taskStore.GetTasksByStatus(status)
if len(tasks) > 0 {
bc.boardConfig.SetSelection(columns[i].ID, 0)
bc.boardConfig.SetSelection(panes[i].ID, 0)
return true
}
}
@ -133,9 +132,9 @@ func (bc *BoardController) handleNavUp() bool {
}
func (bc *BoardController) handleNavDown() bool {
// get task count for current column to validate
colID := bc.boardConfig.GetSelectedColumnID()
status := bc.boardConfig.GetStatusForColumn(colID)
// get task count for current pane to validate
paneID := bc.boardConfig.GetSelectedPaneID()
status := bc.boardConfig.GetStatusForPane(paneID)
tasks := bc.taskStore.GetTasksByStatus(status)
row := bc.boardConfig.GetSelectedRow()
@ -165,21 +164,21 @@ func (bc *BoardController) handleMoveTaskLeft() bool {
return false
}
colID := bc.boardConfig.GetSelectedColumnID()
prevColID := bc.boardConfig.GetPreviousColumnID(colID)
if prevColID == "" {
paneID := bc.boardConfig.GetSelectedPaneID()
prevPaneID := bc.boardConfig.GetPreviousPaneID(paneID)
if prevPaneID == "" {
return false
}
newStatus := bc.boardConfig.GetStatusForColumn(prevColID)
newStatus := bc.boardConfig.GetStatusForPane(prevPaneID)
if !bc.taskStore.UpdateStatus(taskID, newStatus) {
slog.Error("failed to move task left", "task_id", taskID, "error", "update status failed")
return false
}
slog.Info("task moved left", "task_id", taskID, "from_col_id", colID, "to_col_id", prevColID, "new_status", newStatus)
slog.Info("task moved left", "task_id", taskID, "from_pane_id", paneID, "to_pane_id", prevPaneID, "new_status", newStatus)
// move selection to follow the task
bc.selectTaskInColumn(prevColID, taskID)
bc.selectTaskInPane(prevPaneID, taskID)
return true
}
@ -189,27 +188,27 @@ func (bc *BoardController) handleMoveTaskRight() bool {
return false
}
colID := bc.boardConfig.GetSelectedColumnID()
nextColID := bc.boardConfig.GetNextColumnID(colID)
if nextColID == "" {
paneID := bc.boardConfig.GetSelectedPaneID()
nextPaneID := bc.boardConfig.GetNextPaneID(paneID)
if nextPaneID == "" {
return false
}
newStatus := bc.boardConfig.GetStatusForColumn(nextColID)
newStatus := bc.boardConfig.GetStatusForPane(nextPaneID)
if !bc.taskStore.UpdateStatus(taskID, newStatus) {
slog.Error("failed to move task right", "task_id", taskID, "error", "update status failed")
return false
}
slog.Info("task moved right", "task_id", taskID, "from_col_id", colID, "to_col_id", nextColID, "new_status", newStatus)
slog.Info("task moved right", "task_id", taskID, "from_pane_id", paneID, "to_pane_id", nextPaneID, "new_status", newStatus)
// move selection to follow the task
bc.selectTaskInColumn(nextColID, taskID)
bc.selectTaskInPane(nextPaneID, taskID)
return true
}
// selectTaskInColumn moves selection to a specific task in a column
func (bc *BoardController) selectTaskInColumn(colID, taskID string) {
status := bc.boardConfig.GetStatusForColumn(colID)
// selectTaskInPane moves selection to a specific task in a pane
func (bc *BoardController) selectTaskInPane(paneID, taskID string) {
status := bc.boardConfig.GetStatusForPane(paneID)
tasks := bc.taskStore.GetTasksByStatus(status)
row := 0
@ -220,8 +219,8 @@ func (bc *BoardController) selectTaskInColumn(colID, taskID string) {
}
}
// always update selection to target column, even if task not found (use row 0)
bc.boardConfig.SetSelection(colID, row)
// always update selection to target pane, even if task not found (use row 0)
bc.boardConfig.SetSelection(paneID, row)
}
func (bc *BoardController) handleNewTask() bool {
@ -252,8 +251,8 @@ func (bc *BoardController) handleDeleteTask() bool {
// getSelectedTaskID returns the ID of the currently selected task
func (bc *BoardController) getSelectedTaskID() string {
colID := bc.boardConfig.GetSelectedColumnID()
status := bc.boardConfig.GetStatusForColumn(colID)
paneID := bc.boardConfig.GetSelectedPaneID()
status := bc.boardConfig.GetStatusForPane(paneID)
allTasks := bc.taskStore.GetTasksByStatus(status)
// Filter tasks by search results if search is active
@ -286,7 +285,7 @@ func (bc *BoardController) handleToggleViewMode() bool {
}
// HandleSearch processes a search query for the board view, filtering tasks by title.
// It saves the current selection, searches all board columns, and displays matching results.
// It saves the current selection, searches all board panes, and displays matching results.
// Empty queries are ignored.
func (bc *BoardController) HandleSearch(query string) {
query = strings.TrimSpace(query)
@ -294,14 +293,14 @@ func (bc *BoardController) HandleSearch(query string) {
return // Don't search empty/whitespace
}
// Save current position (column + row)
// Save current position (pane + row)
bc.boardConfig.SavePreSearchState()
// Search all tasks visible on the board (all board columns: todo, in_progress, review, done, etc.)
// Build set of statuses from board columns
// Search all tasks visible on the board (all board panes: todo, in_progress, review, done, etc.)
// Build set of statuses from board panes
boardStatuses := make(map[task.Status]bool)
for _, col := range bc.boardConfig.GetColumns() {
boardStatuses[task.Status(col.Status)] = true
for _, pane := range bc.boardConfig.GetPanes() {
boardStatuses[task.Status(pane.Status)] = true
}
// Filter: tasks with board statuses only
@ -314,12 +313,12 @@ func (bc *BoardController) HandleSearch(query string) {
// Store results
bc.boardConfig.SetSearchResults(results, query)
// Jump to first result's column
// Jump to first result's pane
if len(results) > 0 {
firstTask := results[0].Task
col := bc.boardConfig.GetColumnByStatus(firstTask.Status)
if col != nil {
bc.boardConfig.SetSelection(col.ID, 0)
pane := bc.boardConfig.GetPaneByStatus(firstTask.Status)
if pane != nil {
bc.boardConfig.SetSelection(pane.ID, 0)
}
}
}

View file

@ -62,6 +62,10 @@ func (pc *PluginController) HandleAction(actionID ActionID) bool {
return pc.handleNav("left")
case ActionNavRight:
return pc.handleNav("right")
case ActionMoveTaskLeft:
return pc.handleMoveTask(-1)
case ActionMoveTaskRight:
return pc.handleMoveTask(1)
case ActionOpenFromPlugin:
return pc.handleOpenTask()
case ActionNewTask:
@ -76,7 +80,14 @@ func (pc *PluginController) HandleAction(actionID ActionID) bool {
}
func (pc *PluginController) handleNav(direction string) bool {
tasks := pc.GetFilteredTasks()
pane := pc.pluginConfig.GetSelectedPane()
tasks := pc.GetFilteredTasksForPane(pane)
if direction == "left" || direction == "right" {
if pc.pluginConfig.MoveSelection(direction, len(tasks)) {
return true
}
return pc.handlePaneSwitch(direction)
}
return pc.pluginConfig.MoveSelection(direction, len(tasks))
}
@ -92,6 +103,35 @@ func (pc *PluginController) handleOpenTask() bool {
return true
}
func (pc *PluginController) handlePaneSwitch(direction string) bool {
currentPane := pc.pluginConfig.GetSelectedPane()
nextPane := currentPane
switch direction {
case "left":
nextPane--
case "right":
nextPane++
default:
return false
}
for nextPane >= 0 && nextPane < len(pc.pluginDef.Panes) {
tasks := pc.GetFilteredTasksForPane(nextPane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedPane(nextPane)
pc.pluginConfig.ClampSelection(len(tasks))
return true
}
switch direction {
case "left":
nextPane--
case "right":
nextPane++
}
}
return false
}
func (pc *PluginController) handleNewTask() bool {
task, err := pc.taskStore.NewTaskTemplate()
if err != nil {
@ -123,6 +163,44 @@ func (pc *PluginController) handleToggleViewMode() bool {
return true
}
func (pc *PluginController) handleMoveTask(offset int) bool {
taskID := pc.getSelectedTaskID()
if taskID == "" {
return false
}
if pc.pluginDef == nil || len(pc.pluginDef.Panes) == 0 {
return false
}
currentPane := pc.pluginConfig.GetSelectedPane()
targetPane := currentPane + offset
if targetPane < 0 || targetPane >= len(pc.pluginDef.Panes) {
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
updated, err := plugin.ApplyPaneAction(taskItem, pc.pluginDef.Panes[targetPane].Action, currentUser)
if err != nil {
slog.Error("failed to apply pane action", "task_id", taskID, "error", err)
return false
}
if err := pc.taskStore.UpdateTask(updated); err != nil {
slog.Error("failed to update task after pane move", "task_id", taskID, "error", err)
return false
}
pc.ensureSearchResultIncludesTask(updated)
pc.selectTaskInPane(targetPane, taskID)
return true
}
// HandleSearch processes a search query for the plugin view
func (pc *PluginController) HandleSearch(query string) {
query = strings.TrimSpace(query)
@ -133,66 +211,66 @@ func (pc *PluginController) HandleSearch(query string) {
// Save current position
pc.pluginConfig.SavePreSearchState()
// Get current user and time ONCE before filtering (not per task!)
now := time.Now()
currentUser, _, _ := pc.taskStore.GetCurrentUser()
// Get plugin's filter as a function
filterFunc := func(t *task.Task) bool {
if pc.pluginDef.Filter == nil {
return true
}
return pc.pluginDef.Filter.Evaluate(t, now, currentUser)
// Search across all tasks; pane membership is decided per pane
results := pc.taskStore.Search(query, nil)
if len(results) == 0 {
pc.pluginConfig.ClearSearchResults()
return
}
// Search within filtered results
results := pc.taskStore.Search(query, filterFunc)
pc.pluginConfig.SetSearchResults(results, query)
pc.pluginConfig.SetSelectedIndex(0)
if pc.selectFirstSearchPane() {
return
}
}
// getSelectedTaskID returns the ID of the currently selected task
func (pc *PluginController) getSelectedTaskID() string {
tasks := pc.GetFilteredTasks()
idx := pc.pluginConfig.GetSelectedIndex()
pane := pc.pluginConfig.GetSelectedPane()
tasks := pc.GetFilteredTasksForPane(pane)
idx := pc.pluginConfig.GetSelectedIndexForPane(pane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
// GetFilteredTasks returns tasks filtered and sorted according to plugin rules
func (pc *PluginController) GetFilteredTasks() []*task.Task {
// GetFilteredTasksForPane returns tasks filtered and sorted for a specific pane.
func (pc *PluginController) GetFilteredTasksForPane(pane int) []*task.Task {
if pc.pluginDef == nil {
return nil
}
if pane < 0 || pane >= len(pc.pluginDef.Panes) {
return nil
}
// Check if search is active - if so, return search results instead
searchResults := pc.pluginConfig.GetSearchResults()
if searchResults != nil {
// Extract tasks from search results
tasks := make([]*task.Task, len(searchResults))
for i, result := range searchResults {
tasks[i] = result.Task
}
return tasks
}
// Normal filtering path when search is not active
allTasks := pc.taskStore.GetAllTasks()
now := time.Now()
// Get current user for "my tasks" type filters
currentUser := ""
if user, _, err := pc.taskStore.GetCurrentUser(); err == nil {
currentUser = user
}
currentUser := getCurrentUserName(pc.taskStore)
// Apply filter
var filtered []*task.Task
for _, task := range allTasks {
if pc.pluginDef.Filter == nil || pc.pluginDef.Filter.Evaluate(task, now, currentUser) {
paneFilter := pc.pluginDef.Panes[pane].Filter
if paneFilter == nil || paneFilter.Evaluate(task, now, currentUser) {
filtered = append(filtered, task)
}
}
if searchResults != nil {
searchTaskMap := make(map[string]bool, len(searchResults))
for _, result := range searchResults {
searchTaskMap[result.Task.ID] = true
}
filtered = filterTasksBySearch(filtered, searchTaskMap)
}
// Apply sort
if len(pc.pluginDef.Sort) > 0 {
plugin.SortTasks(filtered, pc.pluginDef.Sort)
@ -200,3 +278,67 @@ func (pc *PluginController) GetFilteredTasks() []*task.Task {
return filtered
}
func (pc *PluginController) selectTaskInPane(pane int, taskID string) {
if pane < 0 || pane >= len(pc.pluginDef.Panes) {
return
}
tasks := pc.GetFilteredTasksForPane(pane)
targetIndex := 0
for i, task := range tasks {
if task.ID == taskID {
targetIndex = i
break
}
}
pc.pluginConfig.SetSelectedPane(pane)
pc.pluginConfig.SetSelectedIndexForPane(pane, targetIndex)
}
func (pc *PluginController) selectFirstSearchPane() bool {
for pane := range pc.pluginDef.Panes {
tasks := pc.GetFilteredTasksForPane(pane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedPane(pane)
pc.pluginConfig.SetSelectedIndexForPane(pane, 0)
return true
}
}
return false
}
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
if updated == nil {
return
}
searchResults := pc.pluginConfig.GetSearchResults()
if searchResults == nil {
return
}
for _, result := range searchResults {
if result.Task != nil && result.Task.ID == updated.ID {
return
}
}
searchResults = append(searchResults, task.SearchResult{
Task: updated,
Score: 1.0,
})
pc.pluginConfig.SetSearchResults(searchResults, pc.pluginConfig.GetSearchQuery())
}
func filterTasksBySearch(tasks []*task.Task, searchMap map[string]bool) []*task.Task {
if searchMap == nil {
return tasks
}
filtered := make([]*task.Task, 0, len(tasks))
for _, t := range tasks {
if searchMap[t.ID] {
filtered = append(filtered, t)
}
}
return filtered
}

View file

@ -21,7 +21,7 @@ func newMockNavigationController() *NavigationController {
// newTestTask creates a test task with default values
func newTestTask() *task.Task {
return &task.Task{
ID: "TEST-1",
ID: "TIKI-1",
Title: "Test Task",
Status: task.StatusTodo,
Type: task.TypeStory,

View file

@ -38,3 +38,11 @@ func setAuthorFromGit(task *task.Task, taskStore store.Store) {
task.CreatedBy = email
}
}
func getCurrentUserName(taskStore store.Store) string {
name, _, err := taskStore.GetCurrentUser()
if err != nil {
return ""
}
return name
}

View file

@ -16,7 +16,7 @@ func TestBoardSearch_OpenSearchBox(t *testing.T) {
defer ta.Cleanup()
// Create test tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -44,13 +44,13 @@ func TestBoardSearch_FilterResults(t *testing.T) {
defer ta.Cleanup()
// Create multiple tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Special Feature", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Special Feature", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -64,30 +64,30 @@ func TestBoardSearch_FilterResults(t *testing.T) {
// Open search
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
// Type "Task" to match TEST-1 and TEST-2
// Type "Task" to match TIKI-1 and TIKI-2
ta.SendText("Task")
// Press Enter to submit search
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify TEST-1 and TEST-2 are visible
found1, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 and TIKI-2 are visible
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible in search results")
t.Errorf("TIKI-1 should be visible in search results")
}
found2, _, _ := ta.FindText("TEST-2")
found2, _, _ := ta.FindText("TIKI-2")
if !found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible in search results")
t.Errorf("TIKI-2 should be visible in search results")
}
// Verify TEST-3 is NOT visible (doesn't match "Task")
found3, _, _ := ta.FindText("TEST-3")
// Verify TIKI-3 is NOT visible (doesn't match "Task")
found3, _, _ := ta.FindText("TIKI-3")
if found3 {
ta.DumpScreen()
t.Errorf("TEST-3 should NOT be visible (doesn't match 'Task')")
t.Errorf("TIKI-3 should NOT be visible (doesn't match 'Task')")
}
}
@ -97,7 +97,7 @@ func TestBoardSearch_NoMatches(t *testing.T) {
defer ta.Cleanup()
// Create test task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -115,11 +115,11 @@ func TestBoardSearch_NoMatches(t *testing.T) {
ta.SendText("NoMatch")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify TEST-1 is NOT visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is NOT visible
found, _, _ := ta.FindText("TIKI-1")
if found {
ta.DumpScreen()
t.Errorf("TEST-1 should NOT be visible when search has no matches")
t.Errorf("TIKI-1 should NOT be visible when search has no matches")
}
}
@ -129,10 +129,10 @@ func TestBoardSearch_EscapeClears(t *testing.T) {
defer ta.Cleanup()
// Create test tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -143,49 +143,49 @@ func TestBoardSearch_EscapeClears(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Move to second task (TEST-2)
// Move to second task (TIKI-2)
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
// Verify we're on TEST-2 (row 1)
// Verify we're on TIKI-2 (row 1)
if ta.BoardConfig.GetSelectedRow() != 1 {
t.Fatalf("expected row 1, got %d", ta.BoardConfig.GetSelectedRow())
}
// Open search and search for "First" (matches TEST-1 only)
// Open search and search for "First" (matches TIKI-1 only)
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
ta.SendText("First")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify only TEST-1 is visible
found1, _, _ := ta.FindText("TEST-1")
// Verify only TIKI-1 is visible
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible in search results")
t.Errorf("TIKI-1 should be visible in search results")
}
found2, _, _ := ta.FindText("TEST-2")
found2, _, _ := ta.FindText("TIKI-2")
if found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should NOT be visible in filtered view")
t.Errorf("TIKI-2 should NOT be visible in filtered view")
}
// Press Esc to clear search
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Verify all tasks are visible again
found1After, _, _ := ta.FindText("TEST-1")
found1After, _, _ := ta.FindText("TIKI-1")
if !found1After {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible after clearing search")
t.Errorf("TIKI-1 should be visible after clearing search")
}
found2After, _, _ := ta.FindText("TEST-2")
found2After, _, _ := ta.FindText("TIKI-2")
if !found2After {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible after clearing search")
t.Errorf("TIKI-2 should be visible after clearing search")
}
// Verify selection was restored to row 1 (TEST-2)
// Verify selection was restored to row 1 (TIKI-2)
if ta.BoardConfig.GetSelectedRow() != 1 {
t.Errorf("selection should be restored to row 1, got %d", ta.BoardConfig.GetSelectedRow())
}
@ -197,7 +197,7 @@ func TestBoardSearch_EscapeFromSearchBox(t *testing.T) {
defer ta.Cleanup()
// Create test task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -226,10 +226,10 @@ func TestBoardSearch_EscapeFromSearchBox(t *testing.T) {
}
// Verify task is still visible (no filtering happened)
foundTask, _, _ := ta.FindText("TEST-1")
foundTask, _, _ := ta.FindText("TIKI-1")
if !foundTask {
ta.DumpScreen()
t.Errorf("TEST-1 should still be visible (search was cancelled)")
t.Errorf("TIKI-1 should still be visible (search was cancelled)")
}
}
@ -239,13 +239,13 @@ func TestBoardSearch_MultipleSequentialSearches(t *testing.T) {
defer ta.Cleanup()
// Create test tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Alpha Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Alpha Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Beta Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Beta Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Gamma Feature", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Gamma Feature", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -261,11 +261,11 @@ func TestBoardSearch_MultipleSequentialSearches(t *testing.T) {
ta.SendText("Alpha")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify only TEST-1 visible
found1, _, _ := ta.FindText("TEST-1")
// Verify only TIKI-1 visible
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible after searching 'Alpha'")
t.Errorf("TIKI-1 should be visible after searching 'Alpha'")
}
// Clear search
@ -276,43 +276,43 @@ func TestBoardSearch_MultipleSequentialSearches(t *testing.T) {
ta.SendText("Beta")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify only TEST-2 visible
found2, _, _ := ta.FindText("TEST-2")
// Verify only TIKI-2 visible
found2, _, _ := ta.FindText("TIKI-2")
if !found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible after searching 'Beta'")
t.Errorf("TIKI-2 should be visible after searching 'Beta'")
}
found1After, _, _ := ta.FindText("TEST-1")
found1After, _, _ := ta.FindText("TIKI-1")
if found1After {
ta.DumpScreen()
t.Errorf("TEST-1 should NOT be visible after searching 'Beta'")
t.Errorf("TIKI-1 should NOT be visible after searching 'Beta'")
}
// Clear search
ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone)
// Third search: "Task" (matches both TEST-1 and TEST-2)
// Third search: "Task" (matches both TIKI-1 and TIKI-2)
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
ta.SendText("Task")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify TEST-1 and TEST-2 visible, TEST-3 not visible
found1Final, _, _ := ta.FindText("TEST-1")
found2Final, _, _ := ta.FindText("TEST-2")
found3Final, _, _ := ta.FindText("TEST-3")
// Verify TIKI-1 and TIKI-2 visible, TIKI-3 not visible
found1Final, _, _ := ta.FindText("TIKI-1")
found2Final, _, _ := ta.FindText("TIKI-2")
found3Final, _, _ := ta.FindText("TIKI-3")
if !found1Final {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible after searching 'Task'")
t.Errorf("TIKI-1 should be visible after searching 'Task'")
}
if !found2Final {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible after searching 'Task'")
t.Errorf("TIKI-2 should be visible after searching 'Task'")
}
if found3Final {
ta.DumpScreen()
t.Errorf("TEST-3 should NOT be visible after searching 'Task'")
t.Errorf("TIKI-3 should NOT be visible after searching 'Task'")
}
}
@ -322,7 +322,7 @@ func TestBoardSearch_CaseInsensitive(t *testing.T) {
defer ta.Cleanup()
// Create test task with mixed case
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "MySpecialTask", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "MySpecialTask", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -338,11 +338,11 @@ func TestBoardSearch_CaseInsensitive(t *testing.T) {
ta.SendText("special")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify TEST-1 is found (case-insensitive match)
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is found (case-insensitive match)
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Errorf("TEST-1 should be found with case-insensitive search")
t.Errorf("TIKI-1 should be found with case-insensitive search")
}
}
@ -352,13 +352,13 @@ func TestBoardSearch_NavigateResults(t *testing.T) {
defer ta.Cleanup()
// Create multiple matching tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Feature A", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Feature A", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Feature B", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Feature B", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Feature C", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Feature C", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -410,10 +410,10 @@ func TestBoardSearch_OpenTaskFromResults(t *testing.T) {
defer ta.Cleanup()
// Create test tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Alpha Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Alpha Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Beta Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Beta Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -438,11 +438,11 @@ func TestBoardSearch_OpenTaskFromResults(t *testing.T) {
t.Errorf("should be on task detail view, got %v", currentView.ViewID)
}
// Verify TEST-2 is displayed in task detail
found, _, _ := ta.FindText("TEST-2")
// Verify TIKI-2 is displayed in task detail
found, _, _ := ta.FindText("TIKI-2")
if !found {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible in task detail view")
t.Errorf("TIKI-2 should be visible in task detail view")
}
}
@ -452,10 +452,10 @@ func TestBoardSearch_SpecialCharacters(t *testing.T) {
defer ta.Cleanup()
// Create task with special characters
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Fix bug #123", taskpkg.StatusTodo, taskpkg.TypeBug); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Fix bug #123", taskpkg.StatusTodo, taskpkg.TypeBug); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Normal Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Normal Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -471,18 +471,18 @@ func TestBoardSearch_SpecialCharacters(t *testing.T) {
ta.SendText("bug")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify TEST-1 is found (contains "bug")
found1, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is found (contains "bug")
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should be found when searching for 'bug'")
t.Errorf("TIKI-1 should be found when searching for 'bug'")
}
// Verify TEST-2 is NOT found
found2, _, _ := ta.FindText("TEST-2")
// Verify TIKI-2 is NOT found
found2, _, _ := ta.FindText("TIKI-2")
if found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should NOT be found when searching for 'bug'")
t.Errorf("TIKI-2 should NOT be found when searching for 'bug'")
}
}
@ -492,7 +492,7 @@ func TestBoardSearch_EmptyQuery(t *testing.T) {
defer ta.Cleanup()
// Create test task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -510,10 +510,10 @@ func TestBoardSearch_EmptyQuery(t *testing.T) {
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify task is still visible (no filtering happened)
found, _, _ := ta.FindText("TEST-1")
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Errorf("TEST-1 should still be visible (empty search ignored)")
t.Errorf("TIKI-1 should still be visible (empty search ignored)")
}
// Note: Search box stays open on empty query (expected behavior)

View file

@ -14,15 +14,15 @@ import (
"github.com/gdamore/tcell/v2"
)
func TestBoardView_ColumnHeadersRender(t *testing.T) {
func TestBoardView_PaneHeadersRender(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create sample tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Task in Todo", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Task in Todo", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Task in Progress", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Task in Progress", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -36,24 +36,24 @@ func TestBoardView_ColumnHeadersRender(t *testing.T) {
}
ta.Draw()
// Verify column headers appear
// Columns may be abbreviated/truncated based on terminal width
// Verify pane headers appear
// Panes may be abbreviated/truncated based on terminal width
// The actual rendering shows: "To", "In", "Revi", "Done" (or similar)
tests := []struct {
name string
searchText string
}{
{"todo column", "To"},
{"in progress column", "In"},
{"review column", "Revi"},
{"done column", "Done"},
{"todo pane", "To"},
{"in progress pane", "In"},
{"review pane", "Revi"},
{"done pane", "Done"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
found, _, _ := ta.FindText(tt.searchText)
if !found {
t.Errorf("column header %q not found on screen", tt.searchText)
t.Errorf("pane header %q not found on screen", tt.searchText)
}
})
}
@ -63,14 +63,14 @@ func TestBoardView_ArrowKeyNavigation(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create 3 tasks in todo column
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
// Create 3 tasks in todo pane
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -82,19 +82,19 @@ func TestBoardView_ArrowKeyNavigation(t *testing.T) {
// Draw to render the board with tasks
ta.Draw()
// Initial state: TEST-1 should be selected (verify by finding it on screen)
found, _, _ := ta.FindText("TEST-1")
// Initial state: TIKI-1 should be selected (verify by finding it on screen)
found, _, _ := ta.FindText("TIKI-1")
if !found {
t.Fatalf("initial task TEST-1 not found")
t.Fatalf("initial task TIKI-1 not found")
}
// Press Down arrow
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
// Verify TEST-2 visible (selection moved down)
found, _, _ = ta.FindText("TEST-2")
// Verify TIKI-2 visible (selection moved down)
found, _, _ = ta.FindText("TIKI-2")
if !found {
t.Errorf("after Down arrow, TEST-2 not found")
t.Errorf("after Down arrow, TIKI-2 not found")
}
// Verify board config selection changed to row 1
@ -106,10 +106,10 @@ func TestBoardView_ArrowKeyNavigation(t *testing.T) {
// Press Down arrow again to move to row 2
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
// Verify TEST-3 visible (selection moved down)
found, _, _ = ta.FindText("TEST-3")
// Verify TIKI-3 visible (selection moved down)
found, _, _ = ta.FindText("TIKI-3")
if !found {
t.Errorf("after second Down arrow, TEST-3 not found")
t.Errorf("after second Down arrow, TIKI-3 not found")
}
// Verify board config selection changed to row 2
@ -123,8 +123,8 @@ func TestBoardView_MoveTaskWithShiftArrow(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create task in todo column
taskID := "TEST-1"
// Create task in todo pane
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Task to Move", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -137,13 +137,13 @@ func TestBoardView_MoveTaskWithShiftArrow(t *testing.T) {
// Draw to render the board with tasks
ta.Draw()
// Verify task starts in TODO column
found, _, _ := ta.FindText("TEST-1")
// Verify task starts in TODO pane
found, _, _ := ta.FindText("TIKI-1")
if !found {
t.Fatalf("task TEST-1 not found initially")
t.Fatalf("task TIKI-1 not found initially")
}
// Press Shift+Right to move to next column
// Press Shift+Right to move to next pane
ta.SendKey(tcell.KeyRight, 0, tcell.ModShift)
// Reload tasks from disk
@ -161,7 +161,7 @@ func TestBoardView_MoveTaskWithShiftArrow(t *testing.T) {
}
// Verify file on disk was updated
taskPath := filepath.Join(ta.TaskDir, "test-1.md")
taskPath := filepath.Join(ta.TaskDir, "tiki-1.md")
content, err := os.ReadFile(taskPath)
if err != nil {
t.Fatalf("failed to read task file: %v", err)
@ -172,7 +172,7 @@ func TestBoardView_MoveTaskWithShiftArrow(t *testing.T) {
}
// ============================================================================
// Phase 3: View Mode Toggle and Column Navigation Tests
// Phase 3: View Mode Toggle and Pane Navigation Tests
// ============================================================================
// TestBoardView_ViewModeToggle verifies 'v' key toggles view mode
@ -181,7 +181,7 @@ func TestBoardView_ViewModeToggle(t *testing.T) {
defer ta.Cleanup()
// Create task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Task 1", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Task 1", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -223,7 +223,7 @@ func TestBoardView_ViewModeTogglePreservesSelection(t *testing.T) {
// Create multiple tasks
for i := 1; i <= 3; i++ {
taskID := "TEST-" + string(rune('0'+i))
taskID := "TIKI-" + string(rune('0'+i))
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Task "+string(rune('0'+i)), taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -241,7 +241,7 @@ func TestBoardView_ViewModeTogglePreservesSelection(t *testing.T) {
// Get selected row before toggle
selectedRowBefore := ta.BoardConfig.GetSelectedRow()
selectedColBefore := ta.BoardConfig.GetSelectedColumnID()
selectedPaneBefore := ta.BoardConfig.GetSelectedPaneID()
if selectedRowBefore != 1 {
t.Fatalf("expected row 1, got %d", selectedRowBefore)
@ -252,29 +252,29 @@ func TestBoardView_ViewModeTogglePreservesSelection(t *testing.T) {
// Verify selection preserved
selectedRowAfter := ta.BoardConfig.GetSelectedRow()
selectedColAfter := ta.BoardConfig.GetSelectedColumnID()
selectedPaneAfter := ta.BoardConfig.GetSelectedPaneID()
if selectedRowAfter != selectedRowBefore {
t.Errorf("selected row = %d, want %d (should be preserved)", selectedRowAfter, selectedRowBefore)
}
if selectedColAfter != selectedColBefore {
t.Errorf("selected column = %s, want %s (should be preserved)", selectedColAfter, selectedColBefore)
if selectedPaneAfter != selectedPaneBefore {
t.Errorf("selected pane = %s, want %s (should be preserved)", selectedPaneAfter, selectedPaneBefore)
}
}
// TestBoardView_LeftRightArrowMovesBetweenColumns verifies column navigation
func TestBoardView_LeftRightArrowMovesBetweenColumns(t *testing.T) {
// TestBoardView_LeftRightArrowMovesBetweenPanes verifies pane navigation
func TestBoardView_LeftRightArrowMovesBetweenPanes(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create tasks in different columns
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
// Create tasks in different panes
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "In Progress Task", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "In Progress Task", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Review Task", taskpkg.StatusReview, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Review Task", taskpkg.StatusReview, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -285,54 +285,54 @@ func TestBoardView_LeftRightArrowMovesBetweenColumns(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Initial column should be col-todo
if ta.BoardConfig.GetSelectedColumnID() != "col-todo" {
t.Fatalf("expected initial column col-todo, got %s", ta.BoardConfig.GetSelectedColumnID())
// Initial pane should be col-todo
if ta.BoardConfig.GetSelectedPaneID() != "col-todo" {
t.Fatalf("expected initial pane col-todo, got %s", ta.BoardConfig.GetSelectedPaneID())
}
// Press Right arrow to move to in_progress column
// Press Right arrow to move to in_progress pane
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
// Verify moved to col-progress
if ta.BoardConfig.GetSelectedColumnID() != "col-progress" {
t.Errorf("selected column = %s, want col-progress", ta.BoardConfig.GetSelectedColumnID())
if ta.BoardConfig.GetSelectedPaneID() != "col-progress" {
t.Errorf("selected pane = %s, want col-progress", ta.BoardConfig.GetSelectedPaneID())
}
// Press Right arrow again to move to review column
// Press Right arrow again to move to review pane
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
// Verify moved to col-review
if ta.BoardConfig.GetSelectedColumnID() != "col-review" {
t.Errorf("selected column = %s, want col-review", ta.BoardConfig.GetSelectedColumnID())
if ta.BoardConfig.GetSelectedPaneID() != "col-review" {
t.Errorf("selected pane = %s, want col-review", ta.BoardConfig.GetSelectedPaneID())
}
// Press Left arrow to move back to in_progress
ta.SendKey(tcell.KeyLeft, 0, tcell.ModNone)
// Verify moved back to col-progress
if ta.BoardConfig.GetSelectedColumnID() != "col-progress" {
t.Errorf("selected column = %s, want col-progress", ta.BoardConfig.GetSelectedColumnID())
if ta.BoardConfig.GetSelectedPaneID() != "col-progress" {
t.Errorf("selected pane = %s, want col-progress", ta.BoardConfig.GetSelectedPaneID())
}
// Press Left arrow again to move back to todo
ta.SendKey(tcell.KeyLeft, 0, tcell.ModNone)
// Verify moved back to col-todo
if ta.BoardConfig.GetSelectedColumnID() != "col-todo" {
t.Errorf("selected column = %s, want col-todo", ta.BoardConfig.GetSelectedColumnID())
if ta.BoardConfig.GetSelectedPaneID() != "col-todo" {
t.Errorf("selected pane = %s, want col-todo", ta.BoardConfig.GetSelectedPaneID())
}
}
// TestBoardView_NavigateToEmptyColumn verifies navigation skips or handles empty columns
func TestBoardView_NavigateToEmptyColumn(t *testing.T) {
// TestBoardView_NavigateToEmptyPane verifies navigation skips or handles empty panes
func TestBoardView_NavigateToEmptyPane(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create task only in todo column (leave in_progress empty)
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
// Create task only in todo pane (leave in_progress empty)
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Review Task", taskpkg.StatusReview, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Review Task", taskpkg.StatusReview, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -343,34 +343,34 @@ func TestBoardView_NavigateToEmptyColumn(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Start at todo column
if ta.BoardConfig.GetSelectedColumnID() != "col-todo" {
t.Fatalf("expected initial column col-todo, got %s", ta.BoardConfig.GetSelectedColumnID())
// Start at todo pane
if ta.BoardConfig.GetSelectedPaneID() != "col-todo" {
t.Fatalf("expected initial pane col-todo, got %s", ta.BoardConfig.GetSelectedPaneID())
}
// Press Right arrow to move to in_progress column (empty)
// Press Right arrow to move to in_progress pane (empty)
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
// Verify moved to a valid column (implementation may skip empty or stay)
selectedColumn := ta.BoardConfig.GetSelectedColumnID()
validCols := map[string]bool{"col-todo": true, "col-progress": true, "col-review": true, "col-done": true}
if !validCols[selectedColumn] {
t.Errorf("selected column %s should be valid", selectedColumn)
// Verify moved to a valid pane (implementation may skip empty or stay)
selectedPane := ta.BoardConfig.GetSelectedPaneID()
validPanes := map[string]bool{"col-todo": true, "col-progress": true, "col-review": true, "col-done": true}
if !validPanes[selectedPane] {
t.Errorf("selected pane %s should be valid", selectedPane)
}
// Verify selection row is valid (0 in empty column)
// Verify selection row is valid (0 in empty pane)
selectedRow := ta.BoardConfig.GetSelectedRow()
if selectedRow < 0 {
t.Errorf("selected row %d should be non-negative", selectedRow)
}
}
// TestBoardView_MultipleColumnsNavigation verifies full column traversal
func TestBoardView_MultipleColumnsNavigation(t *testing.T) {
// TestBoardView_MultiplePanesNavigation verifies full pane traversal
func TestBoardView_MultiplePanesNavigation(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create tasks in all columns
// Create tasks in all panes
statuses := []taskpkg.Status{
taskpkg.StatusTodo,
taskpkg.StatusInProgress,
@ -379,7 +379,7 @@ func TestBoardView_MultipleColumnsNavigation(t *testing.T) {
}
for i, status := range statuses {
taskID := "TEST-" + string(rune('1'+i))
taskID := "TIKI-" + string(rune('1'+i))
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Task "+string(rune('1'+i)), status, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -392,24 +392,24 @@ func TestBoardView_MultipleColumnsNavigation(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Navigate through all columns with Right arrow
expectedCols := []string{"col-todo", "col-progress", "col-review", "col-done"}
for i, expectedCol := range expectedCols {
actualCol := ta.BoardConfig.GetSelectedColumnID()
if actualCol != expectedCol {
t.Errorf("after %d Right presses, column = %s, want %s", i, actualCol, expectedCol)
// Navigate through all panes with Right arrow
expectedPanes := []string{"col-todo", "col-progress", "col-review", "col-done"}
for i, expectedPane := range expectedPanes {
actualPane := ta.BoardConfig.GetSelectedPaneID()
if actualPane != expectedPane {
t.Errorf("after %d Right presses, pane = %s, want %s", i, actualPane, expectedPane)
}
if i < 3 {
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
}
}
// Navigate back through all columns with Left arrow
reversedCols := []string{"col-done", "col-review", "col-progress", "col-todo"}
for i, expectedCol := range reversedCols {
actualCol := ta.BoardConfig.GetSelectedColumnID()
if actualCol != expectedCol {
t.Errorf("after %d Left presses, column = %s, want %s", i, actualCol, expectedCol)
// Navigate back through all panes with Left arrow
reversedPanes := []string{"col-done", "col-review", "col-progress", "col-todo"}
for i, expectedPane := range reversedPanes {
actualPane := ta.BoardConfig.GetSelectedPaneID()
if actualPane != expectedPane {
t.Errorf("after %d Left presses, pane = %s, want %s", i, actualPane, expectedPane)
}
if i < 3 {
ta.SendKey(tcell.KeyLeft, 0, tcell.ModNone)

View file

@ -0,0 +1,100 @@
package integration
import (
"testing"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/testutil"
"github.com/gdamore/tcell/v2"
"github.com/spf13/viper"
)
func TestPluginView_MoveTaskAppliesPaneAction(t *testing.T) {
originalPlugins := viper.Get("plugins")
viper.Set("plugins", []plugin.PluginRef{
{
Name: "ActionTest",
Key: "F4",
Panes: []plugin.PluginPaneConfig{
{
Name: "Backlog",
Columns: 1,
Filter: "status = 'backlog'",
Action: "status=backlog, tags-=[moved]",
},
{
Name: "Done",
Columns: 1,
Filter: "status = 'done'",
Action: "status=done, tags+=[moved]",
},
},
},
})
t.Cleanup(func() {
viper.Set("plugins", originalPlugins)
})
ta := testutil.NewTestApp(t)
if err := ta.LoadPlugins(); err != nil {
t.Fatalf("failed to load plugins: %v", err)
}
defer ta.Cleanup()
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Backlog Task", task.StatusBacklog, task.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Done Task", task.StatusDone, task.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload tasks: %v", err)
}
ta.NavController.PushView(model.MakePluginViewID("ActionTest"), nil)
ta.Draw()
ta.SendKey(tcell.KeyRight, 0, tcell.ModShift)
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload tasks: %v", err)
}
updated := ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatalf("expected task TIKI-1 to exist")
}
if updated.Status != task.StatusDone {
t.Fatalf("expected status done, got %v", updated.Status)
}
if !containsTag(updated.Tags, "moved") {
t.Fatalf("expected moved tag, got %v", updated.Tags)
}
ta.SendKey(tcell.KeyLeft, 0, tcell.ModShift)
if err := ta.TaskStore.Reload(); err != nil {
t.Fatalf("failed to reload tasks: %v", err)
}
updated = ta.TaskStore.GetTask("TIKI-1")
if updated == nil {
t.Fatalf("expected task TIKI-1 to exist")
}
if updated.Status != task.StatusBacklog {
t.Fatalf("expected status backlog, got %v", updated.Status)
}
if containsTag(updated.Tags, "moved") {
t.Fatalf("expected moved tag removed, got %v", updated.Tags)
}
}
func containsTag(tags []string, target string) bool {
for _, tag := range tags {
if tag == target {
return true
}
}
return false
}

View file

@ -32,19 +32,19 @@ func setupPluginTestData(t *testing.T, ta *testutil.TestApp) {
recent bool // needs UpdatedAt within 2 hours
}{
// Backlog plugin: status = 'backlog'
{"TEST-1", "Backlog Task 1", taskpkg.StatusBacklog, taskpkg.TypeStory, false},
{"TEST-2", "Backlog Task 2", taskpkg.StatusBacklog, taskpkg.TypeBug, false},
{"TIKI-1", "Backlog Task 1", taskpkg.StatusBacklog, taskpkg.TypeStory, false},
{"TIKI-2", "Backlog Task 2", taskpkg.StatusBacklog, taskpkg.TypeBug, false},
// Recent plugin: UpdatedAt within 2 hours
{"TEST-3", "Recent Task 1", taskpkg.StatusTodo, taskpkg.TypeStory, true},
{"TEST-4", "Recent Task 2", taskpkg.StatusInProgress, taskpkg.TypeBug, true},
{"TIKI-3", "Recent Task 1", taskpkg.StatusTodo, taskpkg.TypeStory, true},
{"TIKI-4", "Recent Task 2", taskpkg.StatusInProgress, taskpkg.TypeBug, true},
// Roadmap plugin: type = 'epic'
{"TEST-5", "Roadmap Epic 1", taskpkg.StatusTodo, taskpkg.TypeEpic, false},
{"TEST-6", "Roadmap Epic 2", taskpkg.StatusInProgress, taskpkg.TypeEpic, false},
{"TIKI-5", "Roadmap Epic 1", taskpkg.StatusTodo, taskpkg.TypeEpic, false},
{"TIKI-6", "Roadmap Epic 2", taskpkg.StatusInProgress, taskpkg.TypeEpic, false},
// Multi-plugin match
{"TEST-7", "Recent Backlog", taskpkg.StatusBacklog, taskpkg.TypeStory, true},
{"TIKI-7", "Recent Backlog", taskpkg.StatusBacklog, taskpkg.TypeStory, true},
}
for _, task := range tasks {
@ -270,7 +270,7 @@ func TestPluginActions_Navigation_ArrowKeys(t *testing.T) {
ta := setupTestAppWithPlugins(t)
defer ta.Cleanup()
// Navigate to Backlog plugin (has at least 3 tasks: TEST-1, TEST-2, TEST-7)
// Navigate to Backlog plugin (has at least 3 tasks: TIKI-1, TIKI-2, TIKI-7)
ta.NavController.PushView(model.MakePluginViewID("Backlog"), nil)
ta.Draw()

View file

@ -26,11 +26,11 @@ func setupPluginViewTest(t *testing.T) *testutil.TestApp {
status taskpkg.Status
typ taskpkg.Type
}{
{"TEST-1", "First Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeStory},
{"TEST-2", "Second Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeBug},
{"TEST-3", "Third Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeStory},
{"TEST-4", "Fourth Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeBug},
{"TEST-5", "Todo Task (not in backlog)", taskpkg.StatusTodo, taskpkg.TypeStory},
{"TIKI-1", "First Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeStory},
{"TIKI-2", "Second Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeBug},
{"TIKI-3", "Third Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeStory},
{"TIKI-4", "Fourth Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeBug},
{"TIKI-5", "Todo Task (not in backlog)", taskpkg.StatusTodo, taskpkg.TypeStory},
}
for _, task := range tasks {
@ -130,18 +130,18 @@ func TestPluginView_FilterByStatus(t *testing.T) {
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone) // F3 = Backlog plugin
// Verify backlog tasks are visible
found1, _, _ := ta.FindText("TEST-1")
found2, _, _ := ta.FindText("TEST-2")
found1, _, _ := ta.FindText("TIKI-1")
found2, _, _ := ta.FindText("TIKI-2")
if !found1 || !found2 {
ta.DumpScreen()
t.Errorf("backlog tasks should be visible in backlog plugin")
}
// Verify non-backlog task is NOT visible
found5, _, _ := ta.FindText("TEST-5")
found5, _, _ := ta.FindText("TIKI-5")
if found5 {
ta.DumpScreen()
t.Errorf("todo task TEST-5 should NOT be visible in backlog plugin")
t.Errorf("todo task TIKI-5 should NOT be visible in backlog plugin")
}
}
@ -165,10 +165,10 @@ func TestPluginView_OpenTask(t *testing.T) {
}
// Verify correct task is displayed
found, _, _ := ta.FindText("TEST-1")
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Errorf("TEST-1 should be displayed in task detail")
t.Errorf("TIKI-1 should be displayed in task detail")
}
}
@ -227,14 +227,14 @@ func TestPluginView_DeleteTask(t *testing.T) {
ta.Draw()
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
// Verify TEST-1 is visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Fatalf("TEST-1 should be visible before delete")
t.Fatalf("TIKI-1 should be visible before delete")
}
// Press 'd' to delete first task (TEST-1)
// Press 'd' to delete first task (TIKI-1)
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Reload and verify task is deleted
@ -242,15 +242,15 @@ func TestPluginView_DeleteTask(t *testing.T) {
t.Fatalf("failed to reload: %v", err)
}
task := ta.TaskStore.GetTask("TEST-1")
task := ta.TaskStore.GetTask("TIKI-1")
if task != nil {
t.Errorf("TEST-1 should be deleted from store")
t.Errorf("TIKI-1 should be deleted from store")
}
// Verify file is removed
taskPath := filepath.Join(ta.TaskDir, "test-1.md")
taskPath := filepath.Join(ta.TaskDir, "tiki-1.md")
if _, err := os.Stat(taskPath); !os.IsNotExist(err) {
t.Errorf("TEST-1 file should be deleted")
t.Errorf("TIKI-1 file should be deleted")
}
}
@ -265,8 +265,8 @@ func TestPluginView_Search(t *testing.T) {
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone)
// Verify multiple tasks visible initially
found1, _, _ := ta.FindText("TEST-1")
found2, _, _ := ta.FindText("TEST-2")
found1, _, _ := ta.FindText("TIKI-1")
found2, _, _ := ta.FindText("TIKI-2")
if !found1 || !found2 {
ta.DumpScreen()
t.Fatalf("both tasks should be visible initially")
@ -286,16 +286,16 @@ func TestPluginView_Search(t *testing.T) {
ta.SendText("First")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify only TEST-1 is visible
found1After, _, _ := ta.FindText("TEST-1")
found2After, _, _ := ta.FindText("TEST-2")
// Verify only TIKI-1 is visible
found1After, _, _ := ta.FindText("TIKI-1")
found2After, _, _ := ta.FindText("TIKI-2")
if !found1After {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible after search")
t.Errorf("TIKI-1 should be visible after search")
}
if found2After {
ta.DumpScreen()
t.Errorf("TEST-2 should NOT be visible after search")
t.Errorf("TIKI-2 should NOT be visible after search")
}
}
@ -309,7 +309,7 @@ func TestPluginView_EmptyPlugin(t *testing.T) {
}
// Create only todo tasks (no backlog tasks)
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -322,7 +322,7 @@ func TestPluginView_EmptyPlugin(t *testing.T) {
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone) // F3 = Backlog plugin
// Verify no tasks are visible (empty plugin)
found, _, _ := ta.FindText("TEST-1")
found, _, _ := ta.FindText("TIKI-1")
if found {
ta.DumpScreen()
t.Errorf("todo task should NOT be visible in backlog plugin")
@ -410,12 +410,12 @@ func TestPluginView_MultiplePlugins(t *testing.T) {
// Create tasks for multiple plugins
// Backlog: status = backlog (also recent since just created)
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Backlog Task", taskpkg.StatusBacklog, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
// Recent: status = todo (also recent since just created)
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Recent Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Recent Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -429,13 +429,13 @@ func TestPluginView_MultiplePlugins(t *testing.T) {
ta.SendKey(tcell.KeyF3, 0, tcell.ModNone) // F3 = Backlog
// Verify only backlog task visible in Backlog plugin
found1, _, _ := ta.FindText("TEST-1")
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("backlog task should be visible in backlog plugin")
}
found2InBacklog, _, _ := ta.FindText("TEST-2")
found2InBacklog, _, _ := ta.FindText("TIKI-2")
if found2InBacklog {
ta.DumpScreen()
t.Errorf("todo task should NOT be visible in backlog plugin (filtered by status)")
@ -446,13 +446,13 @@ func TestPluginView_MultiplePlugins(t *testing.T) {
// Verify BOTH tasks visible in Recent plugin (both were just created)
// Recent shows all recently modified/created tasks regardless of status
found1InRecent, _, _ := ta.FindText("TEST-1")
found1InRecent, _, _ := ta.FindText("TIKI-1")
if !found1InRecent {
ta.DumpScreen()
t.Errorf("backlog task should be visible in recent plugin (recently created)")
}
found2InRecent, _, _ := ta.FindText("TEST-2")
found2InRecent, _, _ := ta.FindText("TIKI-2")
if !found2InRecent {
ta.DumpScreen()
t.Errorf("todo task should be visible in recent plugin (recently created)")

View file

@ -18,7 +18,7 @@ func TestRefresh_FromBoard(t *testing.T) {
defer ta.Cleanup()
// Create initial task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -29,26 +29,26 @@ func TestRefresh_FromBoard(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Verify TEST-1 is visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible initially")
t.Errorf("TIKI-1 should be visible initially")
}
// Create a new task externally (simulating external modification)
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "New External Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "New External Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create external task: %v", err)
}
// Press 'r' to refresh
ta.SendKey(tcell.KeyRune, 'r', tcell.ModNone)
// Verify TEST-2 is now visible
found2, _, _ := ta.FindText("TEST-2")
// Verify TIKI-2 is now visible
found2, _, _ := ta.FindText("TIKI-2")
if !found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible after refresh")
t.Errorf("TIKI-2 should be visible after refresh")
}
}
@ -58,7 +58,7 @@ func TestRefresh_ExternalModification(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -110,10 +110,10 @@ func TestRefresh_ExternalDeletion(t *testing.T) {
defer ta.Cleanup()
// Create two tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -125,15 +125,15 @@ func TestRefresh_ExternalDeletion(t *testing.T) {
ta.Draw()
// Verify both tasks visible
found1, _, _ := ta.FindText("TEST-1")
found2, _, _ := ta.FindText("TEST-2")
found1, _, _ := ta.FindText("TIKI-1")
found2, _, _ := ta.FindText("TIKI-2")
if !found1 || !found2 {
ta.DumpScreen()
t.Errorf("both tasks should be visible initially")
}
// Delete TEST-1 externally
taskPath := filepath.Join(ta.TaskDir, "test-1.md")
// Delete TIKI-1 externally
taskPath := filepath.Join(ta.TaskDir, "tiki-1.md")
if err := os.Remove(taskPath); err != nil {
t.Fatalf("failed to delete task file: %v", err)
}
@ -141,18 +141,18 @@ func TestRefresh_ExternalDeletion(t *testing.T) {
// Press 'r' to refresh
ta.SendKey(tcell.KeyRune, 'r', tcell.ModNone)
// Verify TEST-1 is gone
found1After, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is gone
found1After, _, _ := ta.FindText("TIKI-1")
if found1After {
ta.DumpScreen()
t.Errorf("TEST-1 should NOT be visible after deletion and refresh")
t.Errorf("TIKI-1 should NOT be visible after deletion and refresh")
}
// Verify TEST-2 still visible
found2After, _, _ := ta.FindText("TEST-2")
// Verify TIKI-2 still visible
found2After, _, _ := ta.FindText("TIKI-2")
if !found2After {
ta.DumpScreen()
t.Errorf("TEST-2 should still be visible after refresh")
t.Errorf("TIKI-2 should still be visible after refresh")
}
// Verify task store count
@ -168,10 +168,10 @@ func TestRefresh_PreservesSelection(t *testing.T) {
defer ta.Cleanup()
// Create tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -185,13 +185,13 @@ func TestRefresh_PreservesSelection(t *testing.T) {
// Move to second task
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
// Verify we're on row 1 (TEST-2)
// Verify we're on row 1 (TIKI-2)
if ta.BoardConfig.GetSelectedRow() != 1 {
t.Fatalf("expected row 1, got %d", ta.BoardConfig.GetSelectedRow())
}
// Create a new task externally (doesn't affect selection)
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -212,10 +212,10 @@ func TestRefresh_ResetsSelectionWhenTaskDeleted(t *testing.T) {
defer ta.Cleanup()
// Create tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -229,13 +229,13 @@ func TestRefresh_ResetsSelectionWhenTaskDeleted(t *testing.T) {
// Move to second task
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
// Verify we're on row 1 (TEST-2)
// Verify we're on row 1 (TIKI-2)
if ta.BoardConfig.GetSelectedRow() != 1 {
t.Fatalf("expected row 1, got %d", ta.BoardConfig.GetSelectedRow())
}
// Delete TEST-2 externally (the selected task)
taskPath := filepath.Join(ta.TaskDir, "test-2.md")
// Delete TIKI-2 externally (the selected task)
taskPath := filepath.Join(ta.TaskDir, "tiki-2.md")
if err := os.Remove(taskPath); err != nil {
t.Fatalf("failed to delete task file: %v", err)
}
@ -248,11 +248,11 @@ func TestRefresh_ResetsSelectionWhenTaskDeleted(t *testing.T) {
t.Errorf("selection should reset to row 0 when selected task deleted, got %d", ta.BoardConfig.GetSelectedRow())
}
// Verify TEST-1 is still visible
found1, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is still visible
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible after refresh")
t.Errorf("TIKI-1 should be visible after refresh")
}
}
@ -262,7 +262,7 @@ func TestRefresh_FromTaskDetail(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -304,10 +304,10 @@ func TestRefresh_WithActiveSearch(t *testing.T) {
defer ta.Cleanup()
// Create tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Alpha Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Alpha Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Beta Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Beta Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -318,17 +318,17 @@ func TestRefresh_WithActiveSearch(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Search for "Alpha" (should show only TEST-1)
// Search for "Alpha" (should show only TIKI-1)
ta.SendKey(tcell.KeyRune, '/', tcell.ModNone)
ta.SendText("Alpha")
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify only TEST-1 visible
found1, _, _ := ta.FindText("TEST-1")
found2, _, _ := ta.FindText("TEST-2")
// Verify only TIKI-1 visible
found1, _, _ := ta.FindText("TIKI-1")
found2, _, _ := ta.FindText("TIKI-2")
if !found1 || found2 {
ta.DumpScreen()
t.Errorf("search should filter to only TEST-1")
t.Errorf("search should filter to only TIKI-1")
}
// Press 'r' to refresh
@ -338,11 +338,11 @@ func TestRefresh_WithActiveSearch(t *testing.T) {
// User must press Esc to clear search manually
// This test just verifies refresh doesn't crash with active search
// Verify TEST-1 is still visible (search still active)
found1After, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is still visible (search still active)
found1After, _, _ := ta.FindText("TIKI-1")
if !found1After {
ta.DumpScreen()
t.Errorf("TEST-1 should still be visible (search persists after refresh)")
t.Errorf("TIKI-1 should still be visible (search persists after refresh)")
}
}
@ -352,7 +352,7 @@ func TestRefresh_MultipleRefreshes(t *testing.T) {
defer ta.Cleanup()
// Create initial task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -366,14 +366,14 @@ func TestRefresh_MultipleRefreshes(t *testing.T) {
// First refresh (no changes)
ta.SendKey(tcell.KeyRune, 'r', tcell.ModNone)
// Verify TEST-1 still visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 still visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
t.Errorf("TEST-1 should be visible after first refresh")
t.Errorf("TIKI-1 should be visible after first refresh")
}
// Add a new task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -381,8 +381,8 @@ func TestRefresh_MultipleRefreshes(t *testing.T) {
ta.SendKey(tcell.KeyRune, 'r', tcell.ModNone)
// Verify both tasks visible
found1, _, _ := ta.FindText("TEST-1")
found2, _, _ := ta.FindText("TEST-2")
found1, _, _ := ta.FindText("TIKI-1")
found2, _, _ := ta.FindText("TIKI-2")
if !found1 || !found2 {
ta.DumpScreen()
t.Errorf("both tasks should be visible after second refresh")
@ -392,8 +392,8 @@ func TestRefresh_MultipleRefreshes(t *testing.T) {
ta.SendKey(tcell.KeyRune, 'r', tcell.ModNone)
// Verify both tasks still visible
found1Again, _, _ := ta.FindText("TEST-1")
found2Again, _, _ := ta.FindText("TEST-2")
found1Again, _, _ := ta.FindText("TIKI-1")
found2Again, _, _ := ta.FindText("TIKI-2")
if !found1Again || !found2Again {
ta.DumpScreen()
t.Errorf("both tasks should be visible after third refresh")

View file

@ -19,10 +19,10 @@ func TestTaskDeletion_FromBoard(t *testing.T) {
defer ta.Cleanup()
// Create tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -33,11 +33,11 @@ func TestTaskDeletion_FromBoard(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Verify TEST-1 visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Fatalf("TEST-1 should be visible before delete")
t.Fatalf("TIKI-1 should be visible before delete")
}
// Press 'd' to delete first task
@ -48,22 +48,22 @@ func TestTaskDeletion_FromBoard(t *testing.T) {
t.Fatalf("failed to reload: %v", err)
}
task := ta.TaskStore.GetTask("TEST-1")
task := ta.TaskStore.GetTask("TIKI-1")
if task != nil {
t.Errorf("TEST-1 should be deleted from store")
t.Errorf("TIKI-1 should be deleted from store")
}
// Verify file removed
taskPath := filepath.Join(ta.TaskDir, "test-1.md")
taskPath := filepath.Join(ta.TaskDir, "tiki-1.md")
if _, err := os.Stat(taskPath); !os.IsNotExist(err) {
t.Errorf("TEST-1 file should be deleted")
t.Errorf("TIKI-1 file should be deleted")
}
// Verify TEST-2 still visible
found2, _, _ := ta.FindText("TEST-2")
// Verify TIKI-2 still visible
found2, _, _ := ta.FindText("TIKI-2")
if !found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should still be visible after deleting TEST-1")
t.Errorf("TIKI-2 should still be visible after deleting TIKI-1")
}
}
@ -73,13 +73,13 @@ func TestTaskDeletion_SelectionMoves(t *testing.T) {
defer ta.Cleanup()
// Create three tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -98,30 +98,30 @@ func TestTaskDeletion_SelectionMoves(t *testing.T) {
t.Fatalf("expected row 1, got %d", ta.BoardConfig.GetSelectedRow())
}
// Delete TEST-2
// Delete TIKI-2
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Selection should move to next task (TEST-3, which is now at row 1)
// Selection should move to next task (TIKI-3, which is now at row 1)
selectedRow := ta.BoardConfig.GetSelectedRow()
if selectedRow != 1 {
t.Errorf("selection after delete = row %d, want row 1", selectedRow)
}
// Verify TEST-3 is visible
found3, _, _ := ta.FindText("TEST-3")
// Verify TIKI-3 is visible
found3, _, _ := ta.FindText("TIKI-3")
if !found3 {
ta.DumpScreen()
t.Errorf("TEST-3 should be visible after deleting TEST-2")
t.Errorf("TIKI-3 should be visible after deleting TIKI-2")
}
}
// TestTaskDeletion_LastTaskInColumn verifies deleting last task resets selection
func TestTaskDeletion_LastTaskInColumn(t *testing.T) {
// TestTaskDeletion_LastTaskInPane verifies deleting last task resets selection
func TestTaskDeletion_LastTaskInPane(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create only one task in todo column
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Only Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
// Create only one task in todo pane
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Only Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -132,11 +132,11 @@ func TestTaskDeletion_LastTaskInColumn(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Verify TEST-1 visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Fatalf("TEST-1 should be visible")
t.Fatalf("TIKI-1 should be visible")
}
// Delete the only task
@ -148,9 +148,9 @@ func TestTaskDeletion_LastTaskInColumn(t *testing.T) {
}
// Verify task deleted
task := ta.TaskStore.GetTask("TEST-1")
task := ta.TaskStore.GetTask("TIKI-1")
if task != nil {
t.Errorf("TEST-1 should be deleted")
t.Errorf("TIKI-1 should be deleted")
}
// Verify selection reset to 0
@ -169,7 +169,7 @@ func TestTaskDeletion_MultipleSequential(t *testing.T) {
// Create five tasks
for i := 1; i <= 5; i++ {
taskID := fmt.Sprintf("TEST-%d", i)
taskID := fmt.Sprintf("TIKI-%d", i)
title := fmt.Sprintf("Task %d", i)
if err := testutil.CreateTestTask(ta.TaskDir, taskID, title, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
@ -186,10 +186,10 @@ func TestTaskDeletion_MultipleSequential(t *testing.T) {
// Delete first task
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Delete first task again (was TEST-2, now at top)
// Delete first task again (was TIKI-2, now at top)
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Delete first task again (was TEST-3, now at top)
// Delete first task again (was TIKI-3, now at top)
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Reload and verify only 2 tasks remain
@ -202,21 +202,21 @@ func TestTaskDeletion_MultipleSequential(t *testing.T) {
t.Errorf("expected 2 tasks remaining, got %d", len(allTasks))
}
// Verify TEST-4 and TEST-5 still exist
task4 := ta.TaskStore.GetTask("TEST-4")
task5 := ta.TaskStore.GetTask("TEST-5")
// Verify TIKI-4 and TIKI-5 still exist
task4 := ta.TaskStore.GetTask("TIKI-4")
task5 := ta.TaskStore.GetTask("TIKI-5")
if task4 == nil || task5 == nil {
t.Errorf("TEST-4 and TEST-5 should still exist")
t.Errorf("TIKI-4 and TIKI-5 should still exist")
}
}
// TestTaskDeletion_FromDifferentColumn verifies deleting from non-todo column
func TestTaskDeletion_FromDifferentColumn(t *testing.T) {
// TestTaskDeletion_FromDifferentPane verifies deleting from non-todo pane
func TestTaskDeletion_FromDifferentPane(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create task in in_progress column
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "In Progress Task", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
// Create task in in_progress pane
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "In Progress Task", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -227,14 +227,14 @@ func TestTaskDeletion_FromDifferentColumn(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Move to in_progress column (Right arrow)
// Move to in_progress pane (Right arrow)
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
// Verify TEST-1 visible
found, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Fatalf("TEST-1 should be visible in in_progress column")
t.Fatalf("TIKI-1 should be visible in in_progress pane")
}
// Delete task
@ -245,9 +245,9 @@ func TestTaskDeletion_FromDifferentColumn(t *testing.T) {
t.Fatalf("failed to reload: %v", err)
}
task := ta.TaskStore.GetTask("TEST-1")
task := ta.TaskStore.GetTask("TIKI-1")
if task != nil {
t.Errorf("TEST-1 should be deleted")
t.Errorf("TIKI-1 should be deleted")
}
}
@ -257,7 +257,7 @@ func TestTaskDeletion_CannotDeleteFromTaskDetail(t *testing.T) {
defer ta.Cleanup()
// Create task
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Task to Not Delete", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Task to Not Delete", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -283,9 +283,9 @@ func TestTaskDeletion_CannotDeleteFromTaskDetail(t *testing.T) {
t.Fatalf("failed to reload: %v", err)
}
task := ta.TaskStore.GetTask("TEST-1")
task := ta.TaskStore.GetTask("TIKI-1")
if task == nil {
t.Errorf("TEST-1 should NOT be deleted from task detail view")
t.Errorf("TIKI-1 should NOT be deleted from task detail view")
}
// Verify we're still on task detail (or moved somewhere else, but task exists)
@ -294,19 +294,19 @@ func TestTaskDeletion_CannotDeleteFromTaskDetail(t *testing.T) {
}
}
// TestTaskDeletion_WithMultipleColumns verifies deletion doesn't affect other columns
func TestTaskDeletion_WithMultipleColumns(t *testing.T) {
// TestTaskDeletion_WithMultiplePanes verifies deletion doesn't affect other panes
func TestTaskDeletion_WithMultiplePanes(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
// Create tasks in different columns
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
// Create tasks in different panes
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Todo Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "In Progress Task", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "In Progress Task", taskpkg.StatusInProgress, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Done Task", taskpkg.StatusDone, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Done Task", taskpkg.StatusDone, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -317,7 +317,7 @@ func TestTaskDeletion_WithMultipleColumns(t *testing.T) {
ta.NavController.PushView(model.BoardViewID, nil)
ta.Draw()
// Delete TEST-1 from todo column
// Delete TIKI-1 from todo pane
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Reload
@ -325,16 +325,16 @@ func TestTaskDeletion_WithMultipleColumns(t *testing.T) {
t.Fatalf("failed to reload: %v", err)
}
// Verify TEST-1 deleted
if ta.TaskStore.GetTask("TEST-1") != nil {
t.Errorf("TEST-1 should be deleted")
// Verify TIKI-1 deleted
if ta.TaskStore.GetTask("TIKI-1") != nil {
t.Errorf("TIKI-1 should be deleted")
}
// Verify TEST-2 and TEST-3 still exist (in other columns)
if ta.TaskStore.GetTask("TEST-2") == nil {
t.Errorf("TEST-2 (in different column) should still exist")
// Verify TIKI-2 and TIKI-3 still exist (in other panes)
if ta.TaskStore.GetTask("TIKI-2") == nil {
t.Errorf("TIKI-2 (in different pane) should still exist")
}
if ta.TaskStore.GetTask("TEST-3") == nil {
t.Errorf("TEST-3 (in different column) should still exist")
if ta.TaskStore.GetTask("TIKI-3") == nil {
t.Errorf("TIKI-3 (in different pane) should still exist")
}
}

View file

@ -17,7 +17,7 @@ func TestTaskDetailView_RenderMetadata(t *testing.T) {
defer ta.Cleanup()
// Create a task with all fields populated
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task Title", taskpkg.StatusInProgress, taskpkg.TypeBug); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -31,10 +31,10 @@ func TestTaskDetailView_RenderMetadata(t *testing.T) {
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone) // Open task detail
// Verify task ID is visible
found, _, _ := ta.FindText("TEST-1")
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Errorf("task ID 'TEST-1' not found in task detail view")
t.Errorf("task ID 'TIKI-1' not found in task detail view")
}
// Verify title is visible
@ -72,7 +72,7 @@ func TestTaskDetailView_RenderDescription(t *testing.T) {
defer ta.Cleanup()
// Create task (description is set to the title by CreateTestTask)
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Task with description", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -100,7 +100,7 @@ func TestTaskDetailView_NavigateBack(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -135,7 +135,7 @@ func TestTaskDetailView_InlineTitleEdit_Save(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -178,7 +178,7 @@ func TestTaskDetailView_InlineTitleEdit_Cancel(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -221,10 +221,10 @@ func TestTaskDetailView_FromBoard(t *testing.T) {
defer ta.Cleanup()
// Create tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -241,18 +241,18 @@ func TestTaskDetailView_FromBoard(t *testing.T) {
// Open task detail
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
// Verify we're on task detail for TEST-2
found, _, _ := ta.FindText("TEST-2")
// Verify we're on task detail for TIKI-2
found, _, _ := ta.FindText("TIKI-2")
if !found {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible in task detail view")
t.Errorf("TIKI-2 should be visible in task detail view")
}
// Verify TEST-1 is NOT visible (we're viewing TEST-2)
found1, _, _ := ta.FindText("TEST-1")
// Verify TIKI-1 is NOT visible (we're viewing TIKI-2)
found1, _, _ := ta.FindText("TIKI-1")
if found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should NOT be visible (we opened TEST-2)")
t.Errorf("TIKI-1 should NOT be visible (we opened TIKI-2)")
}
}
@ -262,7 +262,7 @@ func TestTaskDetailView_EmptyDescription(t *testing.T) {
defer ta.Cleanup()
// Create task with minimal content
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Task Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -296,13 +296,13 @@ func TestTaskDetailView_MultipleOpen(t *testing.T) {
defer ta.Cleanup()
// Create multiple tasks
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "First Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-2", "Second Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := testutil.CreateTestTask(ta.TaskDir, "TEST-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-3", "Third Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
if err := ta.TaskStore.Reload(); err != nil {
@ -315,10 +315,10 @@ func TestTaskDetailView_MultipleOpen(t *testing.T) {
// Open first task
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
found1, _, _ := ta.FindText("TEST-1")
found1, _, _ := ta.FindText("TIKI-1")
if !found1 {
ta.DumpScreen()
t.Errorf("TEST-1 should be visible after opening")
t.Errorf("TIKI-1 should be visible after opening")
}
// Go back
@ -327,10 +327,10 @@ func TestTaskDetailView_MultipleOpen(t *testing.T) {
// Move to second task and open
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
found2, _, _ := ta.FindText("TEST-2")
found2, _, _ := ta.FindText("TIKI-2")
if !found2 {
ta.DumpScreen()
t.Errorf("TEST-2 should be visible after opening")
t.Errorf("TIKI-2 should be visible after opening")
}
// Go back
@ -339,10 +339,10 @@ func TestTaskDetailView_MultipleOpen(t *testing.T) {
// Move to third task and open
ta.SendKey(tcell.KeyDown, 0, tcell.ModNone)
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
found3, _, _ := ta.FindText("TEST-3")
found3, _, _ := ta.FindText("TIKI-3")
if !found3 {
ta.DumpScreen()
t.Errorf("TEST-3 should be visible after opening")
t.Errorf("TIKI-3 should be visible after opening")
}
}
@ -360,7 +360,7 @@ func TestTaskDetailView_AllStatuses(t *testing.T) {
}
for i, status := range statuses {
taskID := fmt.Sprintf("TEST-%d", i+1)
taskID := fmt.Sprintf("TIKI-%d", i+1)
title := fmt.Sprintf("Task %s", status)
if err := testutil.CreateTestTask(ta.TaskDir, taskID, title, status, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -377,11 +377,11 @@ func TestTaskDetailView_AllStatuses(t *testing.T) {
// For each status, navigate to first task with that status and verify detail view
for i, status := range statuses {
// Find the task on board (may need to navigate between columns)
taskID := fmt.Sprintf("TEST-%d", i+1)
// Find the task on board (may need to navigate between panes)
taskID := fmt.Sprintf("TIKI-%d", i+1)
// Navigate to correct column based on status
// For simplicity, we'll just open first task in todo column for this test
// Navigate to correct pane based on status
// For simplicity, we'll just open first task in todo pane for this test
if status == taskpkg.StatusTodo {
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)
@ -410,7 +410,7 @@ func TestTaskDetailView_AllTypes(t *testing.T) {
}
for i, taskType := range types {
taskID := fmt.Sprintf("TEST-%d", i+1)
taskID := fmt.Sprintf("TIKI-%d", i+1)
title := fmt.Sprintf("Task %s", taskType)
if err := testutil.CreateTestTask(ta.TaskDir, taskID, title, taskpkg.StatusTodo, taskType); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -440,7 +440,7 @@ func TestTaskDetailView_InlineEdit_PreservesOtherFields(t *testing.T) {
defer ta.Cleanup()
// Create task with specific values
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeBug); err != nil {
t.Fatalf("failed to create test task: %v", err)
}

View file

@ -16,7 +16,7 @@ func TestTaskEdit_ShiftTabBackward(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -65,7 +65,7 @@ func TestTaskEdit_StatusCycling(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -130,7 +130,7 @@ func TestTaskEdit_TypeToggling(t *testing.T) {
defer ta.Cleanup()
// Create task with Story type
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -195,7 +195,7 @@ func TestTaskEdit_AssigneeInput(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -243,7 +243,7 @@ func TestTaskEdit_MultipleEditCycles(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -298,7 +298,7 @@ func TestTaskEdit_EscapeAndReEdit(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
@ -356,7 +356,7 @@ func TestTaskEdit_PriorityRange(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -403,7 +403,7 @@ func TestTaskEdit_PointsRange(t *testing.T) {
defer ta.Cleanup()
// Create task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}

View file

@ -178,7 +178,7 @@ func TestTaskEdit_EnterInPointsFieldDoesNotSave(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -225,7 +225,7 @@ func TestTaskEdit_TitleChangesSaved(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -272,7 +272,7 @@ func TestTaskEdit_CtrlS_FromPointsField_Saves(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -319,7 +319,7 @@ func TestTaskEdit_Escape_FromTitleField_Cancels(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -361,7 +361,7 @@ func TestTaskEdit_Escape_ClearsEditSessionState(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -399,7 +399,7 @@ func TestTaskEdit_Escape_FromPointsField_Cancels(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
originalTitle := "Original Title"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, originalTitle, taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
@ -450,7 +450,7 @@ func TestTaskEdit_Tab_NavigatesForward(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Test Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -511,7 +511,7 @@ func TestTaskEdit_Navigation_PreservesChanges(t *testing.T) {
defer ta.Cleanup()
// Create a task
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -569,7 +569,7 @@ func TestTaskEdit_MultipleFields_AllSaved(t *testing.T) {
defer ta.Cleanup()
// Create a task with initial values
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -634,7 +634,7 @@ func TestTaskEdit_MultipleFields_AllDiscarded(t *testing.T) {
defer ta.Cleanup()
// Create a task with initial values
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Original Title", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}
@ -762,7 +762,7 @@ func TestNewTask_AfterEditingExistingTask_StatusAndTypeNotCorrupted(t *testing.T
defer ta.Cleanup()
// Create and edit an existing task first
taskID := "TEST-1"
taskID := "TIKI-1"
if err := testutil.CreateTestTask(ta.TaskDir, taskID, "Existing Task", taskpkg.StatusTodo, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create test task: %v", err)
}

View file

@ -46,8 +46,15 @@ func BuildPluginConfigsAndDefs(plugins []plugin.Plugin) (map[string]*model.Plugi
pc := model.NewPluginConfig(p.GetName())
pc.SetConfigIndex(p.GetConfigIndex()) // Pass ConfigIndex for saving view mode changes
if tp, ok := p.(*plugin.TikiPlugin); ok && tp.ViewMode == "expanded" {
pc.SetViewMode("expanded")
if tp, ok := p.(*plugin.TikiPlugin); ok {
if tp.ViewMode == "expanded" {
pc.SetViewMode("expanded")
}
columns := make([]int, len(tp.Panes))
for i, pane := range tp.Panes {
columns[i] = pane.Columns
}
pc.SetPaneLayout(columns)
}
pluginConfigs[p.GetName()] = pc

View file

@ -8,107 +8,107 @@ import (
"github.com/boolean-maybe/tiki/task"
)
// BoardConfig defines board columns, status-to-column mappings, and selection state.
// It tracks which column and row is currently selected.
// BoardConfig defines board panes, status-to-pane mappings, and selection state.
// It tracks which pane and row is currently selected.
// SelectionListener is called when board selection changes
type SelectionListener func()
// BoardConfig holds column definitions and status mappings for the board view
// BoardConfig holds pane definitions and status mappings for the board view
type BoardConfig struct {
mu sync.RWMutex // protects selectedColID and selectedRow
columns []*Column
statusToCol map[task.Status]string // status -> column ID
colToStatus map[string]task.Status // column ID -> status
selectedColID string // currently selected column
selectedRow int // selected task index within column
mu sync.RWMutex // protects selectedPaneID and selectedRow
panes []*Pane
statusToPane map[task.Status]string // status -> pane ID
paneToStatus map[string]task.Status // pane ID -> status
selectedPaneID string // currently selected pane
selectedRow int // selected task index within pane
viewMode ViewMode // compact or expanded display
listeners map[int]SelectionListener // listener ID -> listener
nextListenerID int
searchState SearchState // search state (embedded)
}
// NewBoardConfig creates a board config with default columns
// NewBoardConfig creates a board config with default panes
func NewBoardConfig() *BoardConfig {
bc := &BoardConfig{
statusToCol: make(map[task.Status]string),
colToStatus: make(map[string]task.Status),
statusToPane: make(map[task.Status]string),
paneToStatus: make(map[string]task.Status),
viewMode: ViewModeCompact,
listeners: make(map[int]SelectionListener),
nextListenerID: 1, // Start at 1 to avoid conflict with zero-value sentinel
}
// default kanban columns
defaultColumns := []*Column{
// default kanban panes
defaultPanes := []*Pane{
{ID: "col-todo", Name: "To Do", Status: string(task.StatusTodo), Position: 0},
{ID: "col-progress", Name: "In Progress", Status: string(task.StatusInProgress), Position: 1},
{ID: "col-review", Name: "Review", Status: string(task.StatusReview), Position: 2},
{ID: "col-done", Name: "Done", Status: string(task.StatusDone), Position: 3},
}
for _, col := range defaultColumns {
bc.AddColumn(col)
for _, pane := range defaultPanes {
bc.AddPane(pane)
}
if len(bc.columns) > 0 {
bc.selectedColID = bc.columns[0].ID
if len(bc.panes) > 0 {
bc.selectedPaneID = bc.panes[0].ID
}
return bc
}
// AddColumn adds a column and updates mappings
func (bc *BoardConfig) AddColumn(col *Column) {
bc.columns = append(bc.columns, col)
bc.statusToCol[task.Status(col.Status)] = col.ID
bc.colToStatus[col.ID] = task.Status(col.Status)
// AddPane adds a pane and updates mappings
func (bc *BoardConfig) AddPane(pane *Pane) {
bc.panes = append(bc.panes, pane)
bc.statusToPane[task.Status(pane.Status)] = pane.ID
bc.paneToStatus[pane.ID] = task.Status(pane.Status)
}
// GetColumns returns all columns in position order
func (bc *BoardConfig) GetColumns() []*Column {
return bc.columns
// GetPanes returns all panes in position order
func (bc *BoardConfig) GetPanes() []*Pane {
return bc.panes
}
// GetColumnByID returns a column by its ID
func (bc *BoardConfig) GetColumnByID(id string) *Column {
for _, col := range bc.columns {
if col.ID == id {
return col
// GetPaneByID returns a pane by its ID
func (bc *BoardConfig) GetPaneByID(id string) *Pane {
for _, pane := range bc.panes {
if pane.ID == id {
return pane
}
}
return nil
}
// GetColumnByStatus returns the column for a given status
func (bc *BoardConfig) GetColumnByStatus(status task.Status) *Column {
colID, ok := bc.statusToCol[task.StatusColumn(status)]
// GetPaneByStatus returns the pane for a given status
func (bc *BoardConfig) GetPaneByStatus(status task.Status) *Pane {
paneID, ok := bc.statusToPane[task.StatusPane(status)]
if !ok {
return nil
}
return bc.GetColumnByID(colID)
return bc.GetPaneByID(paneID)
}
// GetStatusForColumn returns the status mapped to a column
func (bc *BoardConfig) GetStatusForColumn(colID string) task.Status {
return bc.colToStatus[colID]
// GetStatusForPane returns the status mapped to a pane
func (bc *BoardConfig) GetStatusForPane(paneID string) task.Status {
return bc.paneToStatus[paneID]
}
// GetSelectedColumnID returns the currently selected column ID
func (bc *BoardConfig) GetSelectedColumnID() string {
// GetSelectedPaneID returns the currently selected pane ID
func (bc *BoardConfig) GetSelectedPaneID() string {
bc.mu.RLock()
defer bc.mu.RUnlock()
return bc.selectedColID
return bc.selectedPaneID
}
// SetSelectedColumn sets the selected column by ID
func (bc *BoardConfig) SetSelectedColumn(colID string) {
// SetSelectedPane sets the selected pane by ID
func (bc *BoardConfig) SetSelectedPane(paneID string) {
bc.mu.Lock()
bc.selectedColID = colID
bc.selectedPaneID = paneID
bc.mu.Unlock()
bc.notifyListeners()
}
// GetSelectedRow returns the selected task index within current column
// GetSelectedRow returns the selected task index within current pane
func (bc *BoardConfig) GetSelectedRow() int {
bc.mu.RLock()
defer bc.mu.RUnlock()
@ -123,11 +123,11 @@ func (bc *BoardConfig) SetSelectedRow(row int) {
bc.notifyListeners()
}
// SetSelection sets both column and row atomically with a single notification.
// SetSelection sets both pane and row atomically with a single notification.
// use when changing both values together to avoid double refresh.
func (bc *BoardConfig) SetSelection(colID string, row int) {
func (bc *BoardConfig) SetSelection(paneID string, row int) {
bc.mu.Lock()
bc.selectedColID = colID
bc.selectedPaneID = paneID
bc.selectedRow = row
bc.mu.Unlock()
bc.notifyListeners()
@ -171,50 +171,50 @@ func (bc *BoardConfig) notifyListeners() {
}
}
// MoveSelectionLeft moves selection to the previous column
// MoveSelectionLeft moves selection to the previous pane
func (bc *BoardConfig) MoveSelectionLeft() bool {
idx := bc.getColumnIndex(bc.selectedColID)
idx := bc.getPaneIndex(bc.selectedPaneID)
if idx > 0 {
bc.SetSelection(bc.columns[idx-1].ID, 0)
bc.SetSelection(bc.panes[idx-1].ID, 0)
return true
}
return false
}
// MoveSelectionRight moves selection to the next column
// MoveSelectionRight moves selection to the next pane
func (bc *BoardConfig) MoveSelectionRight() bool {
idx := bc.getColumnIndex(bc.selectedColID)
if idx < len(bc.columns)-1 {
bc.SetSelection(bc.columns[idx+1].ID, 0)
idx := bc.getPaneIndex(bc.selectedPaneID)
if idx < len(bc.panes)-1 {
bc.SetSelection(bc.panes[idx+1].ID, 0)
return true
}
return false
}
// getColumnIndex returns the index of a column by ID
func (bc *BoardConfig) getColumnIndex(colID string) int {
for i, col := range bc.columns {
if col.ID == colID {
// getPaneIndex returns the index of a pane by ID
func (bc *BoardConfig) getPaneIndex(paneID string) int {
for i, pane := range bc.panes {
if pane.ID == paneID {
return i
}
}
return -1
}
// GetNextColumnID returns the column to the right, or empty if at edge
func (bc *BoardConfig) GetNextColumnID(colID string) string {
idx := bc.getColumnIndex(colID)
if idx >= 0 && idx < len(bc.columns)-1 {
return bc.columns[idx+1].ID
// GetNextPaneID returns the pane to the right, or empty if at edge
func (bc *BoardConfig) GetNextPaneID(paneID string) string {
idx := bc.getPaneIndex(paneID)
if idx >= 0 && idx < len(bc.panes)-1 {
return bc.panes[idx+1].ID
}
return ""
}
// GetPreviousColumnID returns the column to the left, or empty if at edge
func (bc *BoardConfig) GetPreviousColumnID(colID string) string {
idx := bc.getColumnIndex(colID)
// GetPreviousPaneID returns the pane to the left, or empty if at edge
func (bc *BoardConfig) GetPreviousPaneID(paneID string) string {
idx := bc.getPaneIndex(paneID)
if idx > 0 {
return bc.columns[idx-1].ID
return bc.panes[idx-1].ID
}
return ""
}
@ -249,9 +249,9 @@ func (bc *BoardConfig) SetViewMode(mode string) {
}
}
// SavePreSearchState saves current column and row for later restoration
// SavePreSearchState saves current pane and row for later restoration
func (bc *BoardConfig) SavePreSearchState() {
bc.searchState.SavePreSearchColumnState(bc.selectedColID, bc.selectedRow)
bc.searchState.SavePreSearchPaneState(bc.selectedPaneID, bc.selectedRow)
}
// SetSearchResults sets filtered search results and query
@ -262,8 +262,8 @@ func (bc *BoardConfig) SetSearchResults(results []task.SearchResult, query strin
// ClearSearchResults clears search and restores pre-search selection
func (bc *BoardConfig) ClearSearchResults() {
_, preSearchCol, preSearchRow := bc.searchState.ClearSearchResults()
bc.selectedColID = preSearchCol
_, preSearchPane, preSearchRow := bc.searchState.ClearSearchResults()
bc.selectedPaneID = preSearchPane
bc.selectedRow = preSearchRow
bc.notifyListeners()
}

View file

@ -9,14 +9,14 @@ import (
func TestBoardConfig_Initialization(t *testing.T) {
config := NewBoardConfig()
// Verify default columns exist
columns := config.GetColumns()
if len(columns) != 4 {
t.Fatalf("column count = %d, want 4", len(columns))
// Verify default panes exist
panes := config.GetPanes()
if len(panes) != 4 {
t.Fatalf("pane count = %d, want 4", len(panes))
}
// Verify column order
expectedColumns := []struct {
// Verify pane order
expectedPanes := []struct {
id string
name string
status string
@ -28,67 +28,67 @@ func TestBoardConfig_Initialization(t *testing.T) {
{"col-done", "Done", string(task.StatusDone), 3},
}
for i, expected := range expectedColumns {
col := columns[i]
if col.ID != expected.id {
t.Errorf("columns[%d].ID = %q, want %q", i, col.ID, expected.id)
for i, expected := range expectedPanes {
pane := panes[i]
if pane.ID != expected.id {
t.Errorf("panes[%d].ID = %q, want %q", i, pane.ID, expected.id)
}
if col.Name != expected.name {
t.Errorf("columns[%d].Name = %q, want %q", i, col.Name, expected.name)
if pane.Name != expected.name {
t.Errorf("panes[%d].Name = %q, want %q", i, pane.Name, expected.name)
}
if col.Status != expected.status {
t.Errorf("columns[%d].Status = %q, want %q", i, col.Status, expected.status)
if pane.Status != expected.status {
t.Errorf("panes[%d].Status = %q, want %q", i, pane.Status, expected.status)
}
if col.Position != expected.pos {
t.Errorf("columns[%d].Position = %d, want %d", i, col.Position, expected.pos)
if pane.Position != expected.pos {
t.Errorf("panes[%d].Position = %d, want %d", i, pane.Position, expected.pos)
}
}
// Verify first column is selected by default
if config.GetSelectedColumnID() != "col-todo" {
t.Errorf("default selected column = %q, want %q", config.GetSelectedColumnID(), "col-todo")
// Verify first pane is selected by default
if config.GetSelectedPaneID() != "col-todo" {
t.Errorf("default selected pane = %q, want %q", config.GetSelectedPaneID(), "col-todo")
}
}
func TestBoardConfig_ColumnLookup(t *testing.T) {
func TestBoardConfig_PaneLookup(t *testing.T) {
config := NewBoardConfig()
// Test GetColumnByID
col := config.GetColumnByID("col-progress")
if col == nil {
t.Fatal("GetColumnByID(col-progress) returned nil")
// Test GetPaneByID
pane := config.GetPaneByID("col-progress")
if pane == nil {
t.Fatal("GetPaneByID(col-progress) returned nil")
}
if col.Name != "In Progress" {
t.Errorf("column name = %q, want %q", col.Name, "In Progress")
if pane.Name != "In Progress" {
t.Errorf("pane name = %q, want %q", pane.Name, "In Progress")
}
// Test non-existent ID
col = config.GetColumnByID("non-existent")
if col != nil {
t.Error("GetColumnByID(non-existent) should return nil")
pane = config.GetPaneByID("non-existent")
if pane != nil {
t.Error("GetPaneByID(non-existent) should return nil")
}
// Test GetColumnByStatus
col = config.GetColumnByStatus(task.StatusReview)
if col == nil {
t.Fatal("GetColumnByStatus(review) returned nil")
// Test GetPaneByStatus
pane = config.GetPaneByStatus(task.StatusReview)
if pane == nil {
t.Fatal("GetPaneByStatus(review) returned nil")
}
if col.ID != "col-review" {
t.Errorf("column ID = %q, want %q", col.ID, "col-review")
if pane.ID != "col-review" {
t.Errorf("pane ID = %q, want %q", pane.ID, "col-review")
}
col = config.GetColumnByStatus(task.StatusWaiting)
if col == nil {
t.Fatal("GetColumnByStatus(waiting) returned nil")
pane = config.GetPaneByStatus(task.StatusWaiting)
if pane == nil {
t.Fatal("GetPaneByStatus(waiting) returned nil")
}
if col.ID != "col-review" {
t.Errorf("column ID = %q, want %q", col.ID, "col-review")
if pane.ID != "col-review" {
t.Errorf("pane ID = %q, want %q", pane.ID, "col-review")
}
// Test non-mapped status (backlog not in default columns)
col = config.GetColumnByStatus(task.StatusBacklog)
if col != nil {
t.Error("GetColumnByStatus(backlog) should return nil for unmapped status")
// Test non-mapped status (backlog not in default panes)
pane = config.GetPaneByStatus(task.StatusBacklog)
if pane != nil {
t.Error("GetPaneByStatus(backlog) should return nil for unmapped status")
}
}
@ -107,25 +107,25 @@ func TestBoardConfig_StatusMapping(t *testing.T) {
for _, tt := range tests {
t.Run(tt.colID, func(t *testing.T) {
status := config.GetStatusForColumn(tt.colID)
status := config.GetStatusForPane(tt.colID)
if status != tt.expected {
t.Errorf("GetStatusForColumn(%q) = %q, want %q", tt.colID, status, tt.expected)
t.Errorf("GetStatusForPane(%q) = %q, want %q", tt.colID, status, tt.expected)
}
})
}
// Test unmapped column
status := config.GetStatusForColumn("non-existent")
// Test unmapped pane
status := config.GetStatusForPane("non-existent")
if status != "" {
t.Errorf("GetStatusForColumn(non-existent) = %q, want empty string", status)
t.Errorf("GetStatusForPane(non-existent) = %q, want empty string", status)
}
}
func TestBoardConfig_MoveSelectionLeft(t *testing.T) {
config := NewBoardConfig()
// Start at second column
config.SetSelectedColumn("col-progress")
// Start at second pane
config.SetSelectedPane("col-progress")
config.SetSelectedRow(5)
// Move left should succeed and reset row to 0
@ -133,8 +133,8 @@ func TestBoardConfig_MoveSelectionLeft(t *testing.T) {
if !moved {
t.Error("MoveSelectionLeft() returned false, want true")
}
if config.GetSelectedColumnID() != "col-todo" {
t.Errorf("selected column = %q, want %q", config.GetSelectedColumnID(), "col-todo")
if config.GetSelectedPaneID() != "col-todo" {
t.Errorf("selected pane = %q, want %q", config.GetSelectedPaneID(), "col-todo")
}
if config.GetSelectedRow() != 0 {
t.Errorf("selected row = %d, want 0", config.GetSelectedRow())
@ -145,15 +145,15 @@ func TestBoardConfig_MoveSelectionLeft(t *testing.T) {
if moved {
t.Error("MoveSelectionLeft() at leftmost returned true, want false")
}
if config.GetSelectedColumnID() != "col-todo" {
t.Error("column should not change when blocked")
if config.GetSelectedPaneID() != "col-todo" {
t.Error("pane should not change when blocked")
}
}
func TestBoardConfig_MoveSelectionRight(t *testing.T) {
config := NewBoardConfig()
// Start at first column (default)
// Start at first pane (default)
config.SetSelectedRow(3)
// Move right should succeed and reset row to 0
@ -161,8 +161,8 @@ func TestBoardConfig_MoveSelectionRight(t *testing.T) {
if !moved {
t.Error("MoveSelectionRight() returned false, want true")
}
if config.GetSelectedColumnID() != "col-progress" {
t.Errorf("selected column = %q, want %q", config.GetSelectedColumnID(), "col-progress")
if config.GetSelectedPaneID() != "col-progress" {
t.Errorf("selected pane = %q, want %q", config.GetSelectedPaneID(), "col-progress")
}
if config.GetSelectedRow() != 0 {
t.Errorf("selected row = %d, want 0", config.GetSelectedRow())
@ -177,12 +177,12 @@ func TestBoardConfig_MoveSelectionRight(t *testing.T) {
if moved {
t.Error("MoveSelectionRight() at rightmost returned true, want false")
}
if config.GetSelectedColumnID() != "col-done" {
t.Error("column should not change when blocked")
if config.GetSelectedPaneID() != "col-done" {
t.Error("pane should not change when blocked")
}
}
func TestBoardConfig_GetNextColumnID(t *testing.T) {
func TestBoardConfig_GetNextPaneID(t *testing.T) {
config := NewBoardConfig()
tests := []struct {
@ -199,15 +199,15 @@ func TestBoardConfig_GetNextColumnID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := config.GetNextColumnID(tt.colID)
result := config.GetNextPaneID(tt.colID)
if result != tt.expected {
t.Errorf("GetNextColumnID(%q) = %q, want %q", tt.colID, result, tt.expected)
t.Errorf("GetNextPaneID(%q) = %q, want %q", tt.colID, result, tt.expected)
}
})
}
}
func TestBoardConfig_GetPreviousColumnID(t *testing.T) {
func TestBoardConfig_GetPreviousPaneID(t *testing.T) {
config := NewBoardConfig()
tests := []struct {
@ -224,9 +224,9 @@ func TestBoardConfig_GetPreviousColumnID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := config.GetPreviousColumnID(tt.colID)
result := config.GetPreviousPaneID(tt.colID)
if result != tt.expected {
t.Errorf("GetPreviousColumnID(%q) = %q, want %q", tt.colID, result, tt.expected)
t.Errorf("GetPreviousPaneID(%q) = %q, want %q", tt.colID, result, tt.expected)
}
})
}
@ -247,8 +247,8 @@ func TestBoardConfig_SetSelectionAtomicity(t *testing.T) {
if notificationCount != 1 {
t.Errorf("SetSelection triggered %d notifications, want 1", notificationCount)
}
if config.GetSelectedColumnID() != "col-review" {
t.Errorf("column = %q, want col-review", config.GetSelectedColumnID())
if config.GetSelectedPaneID() != "col-review" {
t.Errorf("pane = %q, want col-review", config.GetSelectedPaneID())
}
if config.GetSelectedRow() != 7 {
t.Errorf("row = %d, want 7", config.GetSelectedRow())
@ -256,7 +256,7 @@ func TestBoardConfig_SetSelectionAtomicity(t *testing.T) {
// Separate calls should trigger two notifications
notificationCount = 0
config.SetSelectedColumn("col-done")
config.SetSelectedPane("col-done")
config.SetSelectedRow(3)
if notificationCount != 2 {
t.Errorf("separate calls triggered %d notifications, want 2", notificationCount)
@ -299,11 +299,11 @@ func TestBoardConfig_ListenerNotification(t *testing.T) {
}
listenerID := config.AddSelectionListener(listener)
// Test SetSelectedColumn
// Test SetSelectedPane
notified = false
config.SetSelectedColumn("col-progress")
config.SetSelectedPane("col-progress")
if !notified {
t.Error("SetSelectedColumn() did not trigger listener")
t.Error("SetSelectedPane() did not trigger listener")
}
// Test SetSelectedRow
@ -351,7 +351,7 @@ func TestBoardConfig_MultipleListeners(t *testing.T) {
id2 := config.AddSelectionListener(listener2)
// Both should be notified
config.SetSelectedColumn("col-review")
config.SetSelectedPane("col-review")
if count1 != 1 {
t.Errorf("listener1 count = %d, want 1", count1)
}
@ -363,7 +363,7 @@ func TestBoardConfig_MultipleListeners(t *testing.T) {
config.RemoveSelectionListener(id2)
// Only first should be notified
config.SetSelectedColumn("col-done")
config.SetSelectedPane("col-done")
if count1 != 2 {
t.Errorf("listener1 count = %d, want 2", count1)
}

View file

@ -1,10 +1,10 @@
package model
// Column represents a board column with its status mapping
type Column struct {
// Pane represents a board pane with its status mapping
type Pane struct {
ID string
Name string
Status string // which status this column displays
Status string // which status this pane displays
Position int // display order (left to right)
}

View file

@ -13,27 +13,31 @@ type PluginSelectionListener func()
// PluginConfig holds selection state for a plugin view
type PluginConfig struct {
mu sync.RWMutex
pluginName string
selectedIndex int
columns int // number of columns in grid (same as backlog: 4)
viewMode ViewMode // compact or expanded display
configIndex int // index in config.yaml plugins array (-1 if embedded/not in config)
listeners map[int]PluginSelectionListener
nextListenerID int
searchState SearchState // search state (embedded)
mu sync.RWMutex
pluginName string
selectedPane int
selectedIndices []int
paneColumns []int
preSearchPane int
preSearchIndices []int
viewMode ViewMode // compact or expanded display
configIndex int // index in config.yaml plugins array (-1 if embedded/not in config)
listeners map[int]PluginSelectionListener
nextListenerID int
searchState SearchState // search state (embedded)
}
// NewPluginConfig creates a plugin config
func NewPluginConfig(name string) *PluginConfig {
return &PluginConfig{
pc := &PluginConfig{
pluginName: name,
columns: 4,
viewMode: ViewModeCompact,
configIndex: -1, // Default to -1 (not in config)
listeners: make(map[int]PluginSelectionListener),
nextListenerID: 1, // Start at 1 to avoid conflict with zero-value sentinel
}
pc.SetPaneLayout([]int{4})
return pc
}
// SetConfigIndex sets the config index for this plugin
@ -48,24 +52,84 @@ func (pc *PluginConfig) GetPluginName() string {
return pc.pluginName
}
// GetSelectedIndex returns the selected task index
// SetPaneLayout configures pane columns and resets selection state as needed.
func (pc *PluginConfig) SetPaneLayout(columns []int) {
pc.mu.Lock()
defer pc.mu.Unlock()
pc.paneColumns = normalizePaneColumns(columns)
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.paneColumns))
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.paneColumns))
if pc.selectedPane < 0 || pc.selectedPane >= len(pc.paneColumns) {
pc.selectedPane = 0
}
}
// GetPaneCount returns the number of panes.
func (pc *PluginConfig) GetPaneCount() int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return len(pc.paneColumns)
}
// GetSelectedPane returns the selected pane index.
func (pc *PluginConfig) GetSelectedPane() int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.selectedPane
}
// SetSelectedPane sets the selected pane index.
func (pc *PluginConfig) SetSelectedPane(pane int) {
pc.mu.Lock()
if pane < 0 || pane >= len(pc.paneColumns) {
pc.mu.Unlock()
return
}
changed := pc.selectedPane != pane
pc.selectedPane = pane
pc.mu.Unlock()
if changed {
pc.notifyListeners()
}
}
// GetSelectedIndex returns the selected task index for the current pane.
func (pc *PluginConfig) GetSelectedIndex() int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.selectedIndex
return pc.indexForPane(pc.selectedPane)
}
// SetSelectedIndex sets the selected task index
// GetSelectedIndexForPane returns the selected index for a pane.
func (pc *PluginConfig) GetSelectedIndexForPane(pane int) int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.indexForPane(pane)
}
// SetSelectedIndex sets the selected task index for the current pane.
func (pc *PluginConfig) SetSelectedIndex(idx int) {
pc.mu.Lock()
pc.selectedIndex = idx
pc.setIndexForPane(pc.selectedPane, idx)
pc.mu.Unlock()
pc.notifyListeners()
}
// GetColumns returns the number of grid columns
func (pc *PluginConfig) GetColumns() int {
return pc.columns
// SetSelectedIndexForPane sets the selected index for a specific pane.
func (pc *PluginConfig) SetSelectedIndexForPane(pane int, idx int) {
pc.mu.Lock()
pc.setIndexForPane(pane, idx)
pc.mu.Unlock()
pc.notifyListeners()
}
// GetColumnsForPane returns the number of grid columns for a pane.
func (pc *PluginConfig) GetColumnsForPane(pane int) int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.columnsForPane(pane)
}
// AddSelectionListener registers a callback for selection changes
@ -98,39 +162,41 @@ func (pc *PluginConfig) notifyListeners() {
}
}
// MoveSelection moves selection in a direction given task count, returns true if moved
// MoveSelection moves selection in a direction within the current pane.
func (pc *PluginConfig) MoveSelection(direction string, taskCount int) bool {
if taskCount == 0 {
return false
}
pc.mu.Lock()
oldIndex := pc.selectedIndex
row := pc.selectedIndex / pc.columns
col := pc.selectedIndex % pc.columns
numRows := (taskCount + pc.columns - 1) / pc.columns
pane := pc.selectedPane
columns := pc.columnsForPane(pane)
oldIndex := pc.indexForPane(pane)
row := oldIndex / columns
col := oldIndex % columns
numRows := (taskCount + columns - 1) / columns
switch direction {
case "up":
if row > 0 {
pc.selectedIndex -= pc.columns
pc.setIndexForPane(pane, oldIndex-columns)
}
case "down":
newIdx := pc.selectedIndex + pc.columns
newIdx := oldIndex + columns
if row < numRows-1 && newIdx < taskCount {
pc.selectedIndex = newIdx
pc.setIndexForPane(pane, newIdx)
}
case "left":
if col > 0 {
pc.selectedIndex--
pc.setIndexForPane(pane, oldIndex-1)
}
case "right":
if col < pc.columns-1 && pc.selectedIndex+1 < taskCount {
pc.selectedIndex++
if col < columns-1 && oldIndex+1 < taskCount {
pc.setIndexForPane(pane, oldIndex+1)
}
}
moved := pc.selectedIndex != oldIndex
moved := pc.indexForPane(pane) != oldIndex
pc.mu.Unlock()
if moved {
@ -139,14 +205,16 @@ func (pc *PluginConfig) MoveSelection(direction string, taskCount int) bool {
return moved
}
// ClampSelection ensures selection is within bounds
// ClampSelection ensures selection is within bounds for the current pane.
func (pc *PluginConfig) ClampSelection(taskCount int) {
pc.mu.Lock()
if pc.selectedIndex >= taskCount {
pc.selectedIndex = taskCount - 1
pane := pc.selectedPane
index := pc.indexForPane(pane)
if index >= taskCount {
pc.setIndexForPane(pane, taskCount-1)
}
if pc.selectedIndex < 0 {
pc.selectedIndex = 0
if pc.indexForPane(pane) < 0 {
pc.setIndexForPane(pane, 0)
}
pc.mu.Unlock()
}
@ -193,9 +261,12 @@ func (pc *PluginConfig) SetViewMode(mode string) {
// SavePreSearchState saves current selection for later restoration
func (pc *PluginConfig) SavePreSearchState() {
pc.mu.RLock()
selectedIndex := pc.selectedIndex
pc.mu.RUnlock()
pc.mu.Lock()
pc.preSearchPane = pc.selectedPane
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.paneColumns))
copy(pc.preSearchIndices, pc.selectedIndices)
selectedIndex := pc.indexForPane(pc.selectedPane)
pc.mu.Unlock()
pc.searchState.SavePreSearchState(selectedIndex)
}
@ -207,9 +278,16 @@ func (pc *PluginConfig) SetSearchResults(results []task.SearchResult, query stri
// ClearSearchResults clears search and restores pre-search selection
func (pc *PluginConfig) ClearSearchResults() {
preSearchIndex, _, _ := pc.searchState.ClearSearchResults()
pc.searchState.ClearSearchResults()
pc.mu.Lock()
pc.selectedIndex = preSearchIndex
if len(pc.preSearchIndices) == len(pc.paneColumns) {
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.paneColumns))
copy(pc.selectedIndices, pc.preSearchIndices)
pc.selectedPane = pc.preSearchPane
} else if len(pc.selectedIndices) > 0 {
pc.selectedPane = 0
pc.setIndexForPane(0, 0)
}
pc.mu.Unlock()
pc.notifyListeners()
}
@ -228,3 +306,63 @@ func (pc *PluginConfig) IsSearchActive() bool {
func (pc *PluginConfig) GetSearchQuery() string {
return pc.searchState.GetSearchQuery()
}
func (pc *PluginConfig) indexForPane(pane int) int {
if len(pc.selectedIndices) == 0 {
return 0
}
if pane < 0 || pane >= len(pc.selectedIndices) {
slog.Warn("pane index out of range", "pane", pane, "count", len(pc.selectedIndices))
return 0
}
return pc.selectedIndices[pane]
}
func (pc *PluginConfig) setIndexForPane(pane int, idx int) {
if len(pc.selectedIndices) == 0 {
return
}
if pane < 0 || pane >= len(pc.selectedIndices) {
slog.Warn("pane index out of range", "pane", pane, "count", len(pc.selectedIndices))
return
}
pc.selectedIndices[pane] = idx
}
func (pc *PluginConfig) columnsForPane(pane int) int {
if len(pc.paneColumns) == 0 {
return 1
}
if pane < 0 || pane >= len(pc.paneColumns) {
slog.Warn("pane columns out of range", "pane", pane, "count", len(pc.paneColumns))
return 1
}
return pc.paneColumns[pane]
}
func normalizePaneColumns(columns []int) []int {
if len(columns) == 0 {
return []int{1}
}
normalized := make([]int, len(columns))
for i, value := range columns {
if value <= 0 {
normalized[i] = 1
} else {
normalized[i] = value
}
}
return normalized
}
func ensureSelectionLength(current []int, size int) []int {
if size <= 0 {
return []int{}
}
if len(current) == size {
return current
}
next := make([]int, size)
copy(next, current)
return next
}

View file

@ -23,8 +23,8 @@ func TestNewPluginConfig(t *testing.T) {
t.Errorf("initial GetSelectedIndex() = %d, want 0", pc.GetSelectedIndex())
}
if pc.GetColumns() != 4 {
t.Errorf("GetColumns() = %d, want 4", pc.GetColumns())
if pc.GetColumnsForPane(0) != 4 {
t.Errorf("GetColumnsForPane(0) = %d, want 4", pc.GetColumnsForPane(0))
}
if pc.GetViewMode() != ViewModeCompact {

View file

@ -11,8 +11,8 @@ type SearchState struct {
mu sync.RWMutex
searchResults []task.SearchResult // nil = no active search
preSearchIndex int // for grid views (backlog, plugin)
preSearchCol string // for board view (column ID)
preSearchRow int // for board view (row within column)
preSearchPane string // for board view (pane ID)
preSearchRow int // for board view (row within pane)
searchQuery string // current search term (for UI restoration)
}
@ -23,11 +23,11 @@ func (ss *SearchState) SavePreSearchState(index int) {
ss.preSearchIndex = index
}
// SavePreSearchColumnState saves the current column and row for board view
func (ss *SearchState) SavePreSearchColumnState(colID string, row int) {
// SavePreSearchPaneState saves the current pane and row for board view
func (ss *SearchState) SavePreSearchPaneState(paneID string, row int) {
ss.mu.Lock()
defer ss.mu.Unlock()
ss.preSearchCol = colID
ss.preSearchPane = paneID
ss.preSearchRow = row
}
@ -40,7 +40,7 @@ func (ss *SearchState) SetSearchResults(results []task.SearchResult, query strin
}
// ClearSearchResults clears search and returns the pre-search state
// Returns: (preSearchIndex, preSearchCol, preSearchRow)
// Returns: (preSearchIndex, preSearchPane, preSearchRow)
func (ss *SearchState) ClearSearchResults() (int, string, int) {
ss.mu.Lock()
defer ss.mu.Unlock()
@ -48,7 +48,7 @@ func (ss *SearchState) ClearSearchResults() (int, string, int) {
ss.searchResults = nil
ss.searchQuery = ""
return ss.preSearchIndex, ss.preSearchCol, ss.preSearchRow
return ss.preSearchIndex, ss.preSearchPane, ss.preSearchRow
}
// IsSearchActive returns true if search is currently active

View file

@ -41,12 +41,12 @@ func TestSearchState_GridBasedFlow(t *testing.T) {
}
// Clear search and verify restoration
preIndex, preCol, preRow := ss.ClearSearchResults()
preIndex, prePane, preRow := ss.ClearSearchResults()
if preIndex != 5 {
t.Errorf("ClearSearchResults() preIndex = %d, want 5", preIndex)
}
if preCol != "" {
t.Errorf("ClearSearchResults() preCol = %q, want empty", preCol)
if prePane != "" {
t.Errorf("ClearSearchResults() prePane = %q, want empty", prePane)
}
if preRow != 0 {
t.Errorf("ClearSearchResults() preRow = %d, want 0", preRow)
@ -68,11 +68,11 @@ func TestSearchState_GridBasedFlow(t *testing.T) {
}
}
func TestSearchState_ColumnBasedFlow(t *testing.T) {
func TestSearchState_PaneBasedFlow(t *testing.T) {
ss := &SearchState{}
// Save column-based pre-search state
ss.SavePreSearchColumnState("in_progress", 3)
// Save pane-based pre-search state
ss.SavePreSearchPaneState("in_progress", 3)
// Set search results
results := []task.SearchResult{
@ -86,12 +86,12 @@ func TestSearchState_ColumnBasedFlow(t *testing.T) {
}
// Clear and verify column state restored
preIndex, preCol, preRow := ss.ClearSearchResults()
preIndex, prePane, preRow := ss.ClearSearchResults()
if preIndex != 0 {
t.Errorf("ClearSearchResults() preIndex = %d, want 0", preIndex)
}
if preCol != "in_progress" {
t.Errorf("ClearSearchResults() preCol = %q, want %q", preCol, "in_progress")
if prePane != "in_progress" {
t.Errorf("ClearSearchResults() prePane = %q, want %q", prePane, "in_progress")
}
if preRow != 3 {
t.Errorf("ClearSearchResults() preRow = %d, want 3", preRow)
@ -189,16 +189,16 @@ func TestSearchState_StateOverwriting(t *testing.T) {
// Save grid state
ss.SavePreSearchState(5)
// Overwrite with column state
ss.SavePreSearchColumnState("todo", 2)
// Overwrite with pane state
ss.SavePreSearchPaneState("todo", 2)
// Clear - should have both states available but prefer column
preIndex, preCol, preRow := ss.ClearSearchResults()
preIndex, prePane, preRow := ss.ClearSearchResults()
if preIndex != 5 {
t.Errorf("preIndex = %d, want 5 (grid state preserved)", preIndex)
}
if preCol != "todo" {
t.Errorf("preCol = %q, want %q", preCol, "todo")
if prePane != "todo" {
t.Errorf("prePane = %q, want %q", prePane, "todo")
}
if preRow != 2 {
t.Errorf("preRow = %d, want 2", preRow)
@ -284,8 +284,8 @@ func TestSearchState_ZeroValueState(t *testing.T) {
}
// Clear on zero value should not panic and return zero values
preIndex, preCol, preRow := ss.ClearSearchResults()
if preIndex != 0 || preCol != "" || preRow != 0 {
preIndex, prePane, preRow := ss.ClearSearchResults()
if preIndex != 0 || prePane != "" || preRow != 0 {
t.Error("ClearSearchResults() on zero value should return zero values")
}
}

376
plugin/action.go Normal file
View file

@ -0,0 +1,376 @@
package plugin
import (
"fmt"
"strconv"
"strings"
"github.com/boolean-maybe/tiki/task"
)
// PaneAction represents parsed pane actions.
type PaneAction struct {
Ops []PaneActionOp
}
// PaneActionOp represents a single action operation.
type PaneActionOp struct {
Field ActionField
Operator ActionOperator
StrValue string
IntValue int
Tags []string
}
// ActionField identifies a supported action field.
type ActionField string
const (
ActionFieldStatus ActionField = "status"
ActionFieldType ActionField = "type"
ActionFieldPriority ActionField = "priority"
ActionFieldAssignee ActionField = "assignee"
ActionFieldPoints ActionField = "points"
ActionFieldTags ActionField = "tags"
)
// ActionOperator identifies a supported action operator.
type ActionOperator string
const (
ActionOperatorAssign ActionOperator = "="
ActionOperatorAdd ActionOperator = "+="
ActionOperatorRemove ActionOperator = "-="
)
// ParsePaneAction parses a pane action string into operations.
func ParsePaneAction(input string) (PaneAction, error) {
input = strings.TrimSpace(input)
if input == "" {
return PaneAction{}, nil
}
parts, err := splitTopLevelCommas(input)
if err != nil {
return PaneAction{}, err
}
ops := make([]PaneActionOp, 0, len(parts))
for _, part := range parts {
if part == "" {
return PaneAction{}, fmt.Errorf("empty action segment")
}
field, op, value, err := parseActionSegment(part)
if err != nil {
return PaneAction{}, err
}
switch field {
case ActionFieldTags:
if op == ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("tags action only supports += or -=")
}
tags, err := parseTagsValue(value)
if err != nil {
return PaneAction{}, err
}
ops = append(ops, PaneActionOp{
Field: field,
Operator: op,
Tags: tags,
})
case ActionFieldPriority, ActionFieldPoints:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
}
intValue, err := parseIntValue(value)
if err != nil {
return PaneAction{}, err
}
if field == ActionFieldPriority && !task.IsValidPriority(intValue) {
return PaneAction{}, fmt.Errorf("priority value out of range: %d", intValue)
}
if field == ActionFieldPoints && !task.IsValidPoints(intValue) {
return PaneAction{}, fmt.Errorf("points value out of range: %d", intValue)
}
ops = append(ops, PaneActionOp{
Field: field,
Operator: op,
IntValue: intValue,
})
case ActionFieldStatus:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return PaneAction{}, err
}
if _, ok := task.ParseStatus(strValue); !ok {
return PaneAction{}, fmt.Errorf("invalid status value %q", strValue)
}
ops = append(ops, PaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
case ActionFieldType:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return PaneAction{}, err
}
if _, ok := task.ParseType(strValue); !ok {
return PaneAction{}, fmt.Errorf("invalid type value %q", strValue)
}
ops = append(ops, PaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
default:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return PaneAction{}, err
}
ops = append(ops, PaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
}
}
return PaneAction{Ops: ops}, nil
}
// ApplyPaneAction applies a parsed action to a task clone.
func ApplyPaneAction(src *task.Task, action PaneAction, currentUser string) (*task.Task, error) {
if src == nil {
return nil, fmt.Errorf("task is nil")
}
if len(action.Ops) == 0 {
return src.Clone(), nil
}
clone := src.Clone()
for _, op := range action.Ops {
switch op.Field {
case ActionFieldStatus:
clone.Status = task.MapStatus(op.StrValue)
case ActionFieldType:
clone.Type = task.NormalizeType(op.StrValue)
case ActionFieldPriority:
clone.Priority = op.IntValue
case ActionFieldAssignee:
assignee := op.StrValue
if isCurrentUserToken(assignee) {
if strings.TrimSpace(currentUser) == "" {
return nil, fmt.Errorf("current user is not available for assignee")
}
assignee = currentUser
}
clone.Assignee = assignee
case ActionFieldPoints:
clone.Points = op.IntValue
case ActionFieldTags:
clone.Tags = applyTagOperation(clone.Tags, op.Operator, op.Tags)
default:
return nil, fmt.Errorf("unsupported action field %q", op.Field)
}
}
if validation := task.QuickValidate(clone); validation.HasErrors() {
return nil, fmt.Errorf("action resulted in invalid task: %w", validation)
}
return clone, nil
}
func isCurrentUserToken(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "CURRENT_USER")
}
func parseActionSegment(segment string) (ActionField, ActionOperator, string, error) {
opIdx, op := findOperator(segment)
if opIdx == -1 {
return "", "", "", fmt.Errorf("action segment missing operator: %q", segment)
}
field := strings.TrimSpace(segment[:opIdx])
value := strings.TrimSpace(segment[opIdx+len(op):])
if field == "" || value == "" {
return "", "", "", fmt.Errorf("invalid action segment: %q", segment)
}
switch strings.ToLower(field) {
case "status":
return ActionFieldStatus, op, value, nil
case "type":
return ActionFieldType, op, value, nil
case "priority":
return ActionFieldPriority, op, value, nil
case "assignee":
return ActionFieldAssignee, op, value, nil
case "points":
return ActionFieldPoints, op, value, nil
case "tags":
return ActionFieldTags, op, value, nil
default:
return "", "", "", fmt.Errorf("unknown action field %q", field)
}
}
func findOperator(segment string) (int, ActionOperator) {
if idx := strings.Index(segment, "+="); idx != -1 {
return idx, ActionOperatorAdd
}
if idx := strings.Index(segment, "-="); idx != -1 {
return idx, ActionOperatorRemove
}
if idx := strings.Index(segment, "="); idx != -1 {
return idx, ActionOperatorAssign
}
return -1, ""
}
func parseStringValue(raw string) (string, error) {
value := strings.TrimSpace(raw)
if len(value) >= 2 {
if (value[0] == '\'' && value[len(value)-1] == '\'') ||
(value[0] == '"' && value[len(value)-1] == '"') {
value = value[1 : len(value)-1]
}
}
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("string value is empty")
}
return value, nil
}
func parseIntValue(raw string) (int, error) {
value := strings.TrimSpace(raw)
intValue, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid integer value %q", value)
}
return intValue, nil
}
func parseTagsValue(raw string) ([]string, error) {
value := strings.TrimSpace(raw)
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return nil, fmt.Errorf("tags value must be in brackets, got %q", value)
}
inner := strings.TrimSpace(value[1 : len(value)-1])
if inner == "" {
return nil, fmt.Errorf("tags list is empty")
}
parts, err := splitTopLevelCommas(inner)
if err != nil {
return nil, err
}
tags := make([]string, 0, len(parts))
for _, part := range parts {
tag, err := parseStringValue(part)
if err != nil {
return nil, err
}
tags = append(tags, tag)
}
return tags, nil
}
func splitTopLevelCommas(input string) ([]string, error) {
var parts []string
start := 0
inSingle := false
inDouble := false
bracketDepth := 0
for i, r := range input {
switch r {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '[':
if !inSingle && !inDouble {
bracketDepth++
}
case ']':
if !inSingle && !inDouble {
if bracketDepth == 0 {
return nil, fmt.Errorf("unexpected ']' in %q", input)
}
bracketDepth--
}
case ',':
if !inSingle && !inDouble && bracketDepth == 0 {
part := strings.TrimSpace(input[start:i])
parts = append(parts, part)
start = i + 1
}
}
}
if inSingle || inDouble || bracketDepth != 0 {
return nil, fmt.Errorf("unterminated quotes or brackets in %q", input)
}
part := strings.TrimSpace(input[start:])
parts = append(parts, part)
return parts, nil
}
func applyTagOperation(current []string, op ActionOperator, tags []string) []string {
switch op {
case ActionOperatorAdd:
return addTags(current, tags)
case ActionOperatorRemove:
return removeTags(current, tags)
default:
return current
}
}
func addTags(current []string, tags []string) []string {
existing := make(map[string]bool, len(current))
for _, tag := range current {
existing[tag] = true
}
for _, tag := range tags {
if !existing[tag] {
current = append(current, tag)
existing[tag] = true
}
}
return current
}
func removeTags(current []string, tags []string) []string {
toRemove := make(map[string]bool, len(tags))
for _, tag := range tags {
toRemove[tag] = true
}
filtered := current[:0]
for _, tag := range current {
if !toRemove[tag] {
filtered = append(filtered, tag)
}
}
return filtered
}

318
plugin/action_test.go Normal file
View file

@ -0,0 +1,318 @@
package plugin
import (
"reflect"
"strings"
"testing"
"github.com/boolean-maybe/tiki/task"
)
func TestSplitTopLevelCommas(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr string
}{
{
name: "simple split",
input: "status=todo, type=bug",
want: []string{"status=todo", "type=bug"},
},
{
name: "comma in quotes",
input: "assignee='O,Brien', status=done",
want: []string{"assignee='O,Brien'", "status=done"},
},
{
name: "comma in brackets",
input: "tags+=[one,two], status=done",
want: []string{"tags+=[one,two]", "status=done"},
},
{
name: "mixed quotes and brackets",
input: `tags+=[one,"two,three"], status=done`,
want: []string{`tags+=[one,"two,three"]`, "status=done"},
},
{
name: "unterminated quote",
input: "status='todo, type=bug",
wantErr: "unterminated quotes or brackets",
},
{
name: "unterminated brackets",
input: "tags+=[one,two, status=done",
wantErr: "unterminated quotes or brackets",
},
{
name: "unexpected closing bracket",
input: "status=todo], type=bug",
wantErr: "unexpected ']'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := splitTopLevelCommas(tc.input)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("expected %v, got %v", tc.want, got)
}
})
}
}
func TestParsePaneAction(t *testing.T) {
action, err := ParsePaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review']")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(action.Ops) != 6 {
t.Fatalf("expected 6 ops, got %d", len(action.Ops))
}
gotFields := []ActionField{
action.Ops[0].Field,
action.Ops[1].Field,
action.Ops[2].Field,
action.Ops[3].Field,
action.Ops[4].Field,
action.Ops[5].Field,
}
wantFields := []ActionField{
ActionFieldStatus,
ActionFieldType,
ActionFieldPriority,
ActionFieldPoints,
ActionFieldAssignee,
ActionFieldTags,
}
if !reflect.DeepEqual(gotFields, wantFields) {
t.Fatalf("expected fields %v, got %v", wantFields, gotFields)
}
if action.Ops[0].StrValue != "done" {
t.Fatalf("expected status value 'done', got %q", action.Ops[0].StrValue)
}
if action.Ops[1].StrValue != "bug" {
t.Fatalf("expected type value 'bug', got %q", action.Ops[1].StrValue)
}
if action.Ops[2].IntValue != 2 {
t.Fatalf("expected priority 2, got %d", action.Ops[2].IntValue)
}
if action.Ops[3].IntValue != 3 {
t.Fatalf("expected points 3, got %d", action.Ops[3].IntValue)
}
if action.Ops[4].StrValue != "Alice" {
t.Fatalf("expected assignee Alice, got %q", action.Ops[4].StrValue)
}
if !reflect.DeepEqual(action.Ops[5].Tags, []string{"frontend", "needs review"}) {
t.Fatalf("expected tags [frontend needs review], got %v", action.Ops[5].Tags)
}
}
func TestParsePaneAction_Errors(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "empty segment",
input: "status=done,,type=bug",
wantErr: "empty action segment",
},
{
name: "missing operator",
input: "statusdone",
wantErr: "missing operator",
},
{
name: "tags assign not allowed",
input: "tags=[one]",
wantErr: "tags action only supports",
},
{
name: "status add not allowed",
input: "status+=done",
wantErr: "status action only supports",
},
{
name: "unknown field",
input: "owner=me",
wantErr: "unknown action field",
},
{
name: "invalid status",
input: "status=unknown",
wantErr: "invalid status value",
},
{
name: "invalid type",
input: "type=unknown",
wantErr: "invalid type value",
},
{
name: "priority out of range",
input: "priority=10",
wantErr: "priority value out of range",
},
{
name: "points out of range",
input: "points=-1",
wantErr: "points value out of range",
},
{
name: "tags missing brackets",
input: "tags+={one}",
wantErr: "tags value must be in brackets",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParsePaneAction(tc.input)
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func TestApplyPaneAction(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
Tags: []string{"existing"},
Assignee: "Bob",
}
action, err := ParsePaneAction("status=done, type=bug, priority=2, points=3, assignee=Alice, tags+=[moved]")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
updated, err := ApplyPaneAction(base, action, "")
if err != nil {
t.Fatalf("unexpected apply error: %v", err)
}
if updated.Status != task.StatusDone {
t.Fatalf("expected status done, got %v", updated.Status)
}
if updated.Type != task.TypeBug {
t.Fatalf("expected type bug, got %v", updated.Type)
}
if updated.Priority != 2 {
t.Fatalf("expected priority 2, got %d", updated.Priority)
}
if updated.Points != 3 {
t.Fatalf("expected points 3, got %d", updated.Points)
}
if updated.Assignee != "Alice" {
t.Fatalf("expected assignee Alice, got %q", updated.Assignee)
}
if !reflect.DeepEqual(updated.Tags, []string{"existing", "moved"}) {
t.Fatalf("expected tags [existing moved], got %v", updated.Tags)
}
if base.Status != task.StatusBacklog {
t.Fatalf("expected base task unchanged, got %v", base.Status)
}
if !reflect.DeepEqual(base.Tags, []string{"existing"}) {
t.Fatalf("expected base tags unchanged, got %v", base.Tags)
}
}
func TestApplyPaneAction_InvalidResult(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
}
action := PaneAction{
Ops: []PaneActionOp{
{
Field: ActionFieldPriority,
Operator: ActionOperatorAssign,
IntValue: 99,
},
},
}
_, err := ApplyPaneAction(base, action, "")
if err == nil {
t.Fatalf("expected validation error")
}
}
func TestApplyPaneAction_AssigneeCurrentUser(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
Assignee: "Bob",
}
action, err := ParsePaneAction("assignee=CURRENT_USER")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
updated, err := ApplyPaneAction(base, action, "Alex")
if err != nil {
t.Fatalf("unexpected apply error: %v", err)
}
if updated.Assignee != "Alex" {
t.Fatalf("expected assignee Alex, got %q", updated.Assignee)
}
}
func TestApplyPaneAction_AssigneeCurrentUserMissing(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
Status: task.StatusBacklog,
Type: task.TypeStory,
Priority: task.PriorityMedium,
Points: 1,
}
action, err := ParsePaneAction("assignee=CURRENT_USER")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
_, err = ApplyPaneAction(base, action, "")
if err == nil {
t.Fatalf("expected error for missing current user")
}
if !strings.Contains(err.Error(), "current user") {
t.Fatalf("expected current user error, got %v", err)
}
}

View file

@ -61,9 +61,9 @@ func (p *BasePlugin) GetType() string {
// TikiPlugin is a task-based plugin (like default Kanban board)
type TikiPlugin struct {
BasePlugin
Filter filter.FilterExpr // parsed filter expression AST (nil = no filtering)
Sort []SortRule // parsed sort rules (nil = default sort)
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
Panes []TikiPane // pane definitions for this plugin
Sort []SortRule // parsed sort rules (nil = default sort)
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
}
// DokiPlugin is a documentation-based plugin
@ -74,21 +74,38 @@ type DokiPlugin struct {
URL string // resource URL (for file)
}
// PluginPaneConfig represents a pane in YAML or config definitions.
type PluginPaneConfig struct {
Name string `yaml:"name" mapstructure:"name"`
Columns int `yaml:"columns" mapstructure:"columns"`
Filter string `yaml:"filter" mapstructure:"filter"`
Action string `yaml:"action" mapstructure:"action"`
}
// TikiPane represents a parsed pane definition.
type TikiPane struct {
Name string
Columns int
Filter filter.FilterExpr
Action PaneAction
}
// PluginRef is the entry in config.yaml that references a plugin file or defines it inline
type PluginRef struct {
// File reference (for file-based and hybrid modes)
File string `mapstructure:"file"`
// Inline definition fields (for inline and hybrid modes)
Name string `mapstructure:"name"`
Foreground string `mapstructure:"foreground"`
Background string `mapstructure:"background"`
Key string `mapstructure:"key"`
Filter string `mapstructure:"filter"`
Sort string `mapstructure:"sort"`
View string `mapstructure:"view"`
Type string `mapstructure:"type"`
Fetcher string `mapstructure:"fetcher"`
Text string `mapstructure:"text"`
URL string `mapstructure:"url"`
Name string `mapstructure:"name"`
Foreground string `mapstructure:"foreground"`
Background string `mapstructure:"background"`
Key string `mapstructure:"key"`
Filter string `mapstructure:"filter"`
Sort string `mapstructure:"sort"`
View string `mapstructure:"view"`
Type string `mapstructure:"type"`
Fetcher string `mapstructure:"fetcher"`
Text string `mapstructure:"text"`
URL string `mapstructure:"url"`
Panes []PluginPaneConfig `mapstructure:"panes"`
}

View file

@ -2,5 +2,8 @@ name: Backlog
foreground: "#5fff87"
background: "#005f00"
key: "F3"
filter: status = 'backlog'
panes:
- name: Backlog
columns: 4
filter: status = 'backlog'
sort: Priority, ID

View file

@ -2,5 +2,8 @@ name: Recent
foreground: "##ffff99"
background: "#996600"
key: Ctrl-R
filter: NOW - UpdatedAt < 2hours
panes:
- name: Recent
columns: 4
filter: NOW - UpdatedAt < 2hours
sort: UpdatedAt DESC

View file

@ -2,6 +2,9 @@ name: Roadmap
foreground: "#e1bee7"
background: "#4a148c"
key: "F2"
filter: type = 'epic'
panes:
- name: Roadmap
columns: 4
filter: type = 'epic'
sort: Priority, Points DESC
view: expanded

View file

@ -14,7 +14,9 @@ name: UI Tasks
foreground: "#ffffff"
background: "#0000ff"
key: U
filter: tags IN ['ui', 'ux', 'design']
panes:
- name: UI
filter: tags IN ['ui', 'ux', 'design']
`
def, err := parsePluginYAML([]byte(pluginYAML), "test")
@ -31,8 +33,8 @@ filter: tags IN ['ui', 'ux', 'design']
t.Fatalf("Expected TikiPlugin, got %T", def)
}
if tp.Filter == nil {
t.Fatal("Expected filter to be parsed")
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Fatal("Expected pane filter to be parsed")
}
// Test filter evaluation with matching tasks
@ -43,7 +45,7 @@ filter: tags IN ['ui', 'ux', 'design']
Status: task.StatusTodo,
}
if !tp.Filter.Evaluate(matchingTask, time.Now(), "testuser") {
if !tp.Panes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
t.Error("Expected filter to match task with 'ui' and 'design' tags")
}
@ -55,7 +57,7 @@ filter: tags IN ['ui', 'ux', 'design']
Status: task.StatusTodo,
}
if tp.Filter.Evaluate(nonMatchingTask, time.Now(), "testuser") {
if tp.Panes[0].Filter.Evaluate(nonMatchingTask, time.Now(), "testuser") {
t.Error("Expected filter to NOT match task with 'backend' and 'api' tags")
}
@ -67,7 +69,7 @@ filter: tags IN ['ui', 'ux', 'design']
Status: task.StatusTodo,
}
if !tp.Filter.Evaluate(partialMatchTask, time.Now(), "testuser") {
if !tp.Panes[0].Filter.Evaluate(partialMatchTask, time.Now(), "testuser") {
t.Error("Expected filter to match task with 'ux' tag")
}
}
@ -77,7 +79,9 @@ func TestPluginWithComplexInFilter(t *testing.T) {
pluginYAML := `
name: Active Work
key: A
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'cancelled']
panes:
- name: Active
filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'cancelled']
`
def, err := parsePluginYAML([]byte(pluginYAML), "test")
@ -97,7 +101,7 @@ filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'cancelled']
Status: task.StatusTodo,
}
if !tp.Filter.Evaluate(matchingTask, time.Now(), "testuser") {
if !tp.Panes[0].Filter.Evaluate(matchingTask, time.Now(), "testuser") {
t.Error("Expected filter to match active UI task")
}
@ -108,7 +112,7 @@ filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'cancelled']
Status: task.StatusDone,
}
if tp.Filter.Evaluate(doneTask, time.Now(), "testuser") {
if tp.Panes[0].Filter.Evaluate(doneTask, time.Now(), "testuser") {
t.Error("Expected filter to NOT match done UI task")
}
@ -119,7 +123,7 @@ filter: tags IN ['ui', 'backend'] AND status NOT IN ['done', 'cancelled']
Status: task.StatusInProgress,
}
if tp.Filter.Evaluate(noTagsTask, time.Now(), "testuser") {
if tp.Panes[0].Filter.Evaluate(noTagsTask, time.Now(), "testuser") {
t.Error("Expected filter to NOT match task without matching tags")
}
}
@ -129,7 +133,9 @@ func TestPluginWithStatusInFilter(t *testing.T) {
pluginYAML := `
name: In Progress Work
key: W
filter: status IN ['todo', 'in_progress', 'blocked']
panes:
- name: Active
filter: status IN ['todo', 'in_progress', 'blocked']
`
def, err := parsePluginYAML([]byte(pluginYAML), "test")
@ -161,7 +167,7 @@ filter: status IN ['todo', 'in_progress', 'blocked']
Status: tc.status,
}
result := tp.Filter.Evaluate(task, time.Now(), "testuser")
result := tp.Panes[0].Filter.Evaluate(task, time.Now(), "testuser")
if result != tc.expect {
t.Errorf("Expected %v for status %s, got %v", tc.expect, tc.status, result)
}

View file

@ -148,6 +148,7 @@ func loadPluginFromRef(ref PluginRef, baseDir string) (Plugin, error) {
Fetcher: ref.Fetcher,
Text: ref.Text,
URL: ref.URL,
Panes: ref.Panes,
}
source = "inline:" + ref.Name
}

View file

@ -15,9 +15,11 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
Foreground: "#ffffff",
Background: "#000000",
Key: "I",
Filter: "status = 'todo'",
Sort: "Priority DESC",
View: "expanded",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status = 'todo'"},
},
Sort: "Priority DESC",
View: "expanded",
}
def, err := loadPluginFromRef(ref, "")
@ -42,8 +44,8 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
t.Errorf("Expected view mode 'expanded', got '%s'", tp.ViewMode)
}
if tp.Filter == nil {
t.Error("Expected filter to be parsed")
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Fatal("Expected pane filter to be parsed")
}
if len(tp.Sort) != 1 || tp.Sort[0].Field != "priority" || !tp.Sort[0].Descending {
@ -56,15 +58,17 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
Status: taskpkg.StatusTodo,
}
if !tp.Filter.Evaluate(task, time.Now(), "testuser") {
if !tp.Panes[0].Filter.Evaluate(task, time.Now(), "testuser") {
t.Error("Expected filter to match todo task")
}
}
func TestLoadPluginFromRef_InlineMinimal(t *testing.T) {
ref := PluginRef{
Name: "Minimal",
Filter: "type = 'bug'",
Name: "Minimal",
Panes: []PluginPaneConfig{
{Name: "Bugs", Filter: "type = 'bug'"},
},
}
def, err := loadPluginFromRef(ref, "")
@ -81,8 +85,8 @@ func TestLoadPluginFromRef_InlineMinimal(t *testing.T) {
t.Errorf("Expected name 'Minimal', got '%s'", tp.Name)
}
if tp.Filter == nil {
t.Error("Expected filter to be parsed")
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Error("Expected pane filter to be parsed")
}
}
@ -94,7 +98,9 @@ func TestLoadPluginFromRef_FileBased(t *testing.T) {
foreground: "#ff0000"
background: "#0000ff"
key: T
filter: status = 'in_progress'
panes:
- name: In Progress
filter: status = 'in_progress'
sort: Priority, UpdatedAt DESC
view: compact
`
@ -137,7 +143,9 @@ func TestLoadPluginFromRef_Hybrid(t *testing.T) {
foreground: "#ff0000"
background: "#0000ff"
key: L
filter: status = 'todo'
panes:
- name: Todo
filter: status = 'todo'
sort: Priority
view: compact
`
@ -167,8 +175,8 @@ view: compact
t.Errorf("Expected name 'Base Plugin', got '%s'", tp.Name)
}
if tp.Filter == nil {
t.Error("Expected filter from file")
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Error("Expected pane filter from file")
}
// Overridden fields should be from inline
@ -189,7 +197,9 @@ func TestLoadPluginFromRef_HybridMultipleOverrides(t *testing.T) {
foreground: "#ffffff"
background: "#000000"
key: M
filter: status = 'todo'
panes:
- name: Todo
filter: status = 'todo'
sort: Priority
view: compact
`
@ -199,9 +209,11 @@ view: compact
// Override multiple fields
ref := PluginRef{
File: "multi-plugin.yaml",
Key: "X",
Filter: "status = 'in_progress'",
File: "multi-plugin.yaml",
Key: "X",
Panes: []PluginPaneConfig{
{Name: "In Progress", Filter: "status = 'in_progress'"},
},
Sort: "UpdatedAt DESC",
View: "expanded",
Foreground: "#00ff00",
@ -231,7 +243,10 @@ view: compact
ID: "TIKI-1",
Status: taskpkg.StatusInProgress,
}
if !tp.Filter.Evaluate(task, time.Now(), "testuser") {
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Fatal("Expected overridden pane filter")
}
if !tp.Panes[0].Filter.Evaluate(task, time.Now(), "testuser") {
t.Error("Expected overridden filter to match in_progress task")
}
@ -239,7 +254,7 @@ view: compact
ID: "TIKI-2",
Status: taskpkg.StatusTodo,
}
if tp.Filter.Evaluate(todoTask, time.Now(), "testuser") {
if tp.Panes[0].Filter.Evaluate(todoTask, time.Now(), "testuser") {
t.Error("Expected overridden filter to NOT match todo task")
}
}
@ -262,7 +277,9 @@ func TestLoadPluginFromRef_MissingFile(t *testing.T) {
func TestLoadPluginFromRef_NoName(t *testing.T) {
// Inline plugin without name
ref := PluginRef{
Filter: "status = 'todo'",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status = 'todo'"},
},
}
_, err := loadPluginFromRef(ref, "")

View file

@ -8,17 +8,18 @@ import (
// pluginFileConfig represents the YAML structure of a plugin file
type pluginFileConfig struct {
Name string `yaml:"name"`
Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color
Background string `yaml:"background"`
Key string `yaml:"key"` // single character
Filter string `yaml:"filter"`
Sort string `yaml:"sort"`
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
Fetcher string `yaml:"fetcher"`
Text string `yaml:"text"`
URL string `yaml:"url"`
Name string `yaml:"name"`
Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color
Background string `yaml:"background"`
Key string `yaml:"key"` // single character
Filter string `yaml:"filter"`
Sort string `yaml:"sort"`
View string `yaml:"view"` // "compact" or "expanded" (default: compact)
Type string `yaml:"type"` // "tiki" or "doki" (default: tiki)
Fetcher string `yaml:"fetcher"`
Text string `yaml:"text"`
URL string `yaml:"url"`
Panes []PluginPaneConfig `yaml:"panes"`
}
// mergePluginConfigs merges file-based config (base) with inline overrides
@ -59,6 +60,9 @@ func mergePluginConfigs(base pluginFileConfig, overrides PluginRef) pluginFileCo
if overrides.URL != "" {
result.URL = overrides.URL
}
if len(overrides.Panes) > 0 {
result.Panes = overrides.Panes
}
return result
}
@ -83,7 +87,7 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
ConfigIndex: overrideTiki.ConfigIndex, // Use override's config index
Type: baseTiki.Type,
},
Filter: baseTiki.Filter,
Panes: baseTiki.Panes,
Sort: baseTiki.Sort,
ViewMode: baseTiki.ViewMode,
}
@ -100,8 +104,8 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
if overrideTiki.Background != tcell.ColorDefault {
result.Background = overrideTiki.Background
}
if overrideTiki.Filter != nil {
result.Filter = overrideTiki.Filter
if len(overrideTiki.Panes) > 0 {
result.Panes = overrideTiki.Panes
}
if overrideTiki.Sort != nil {
result.Sort = overrideTiki.Sort
@ -135,7 +139,8 @@ func validatePluginRef(ref PluginRef) error {
hasContent := ref.Key != "" || ref.Filter != "" ||
ref.Sort != "" || ref.Foreground != "" ||
ref.Background != "" || ref.View != "" || ref.Type != "" ||
ref.Fetcher != "" || ref.Text != "" || ref.URL != ""
ref.Fetcher != "" || ref.Text != "" || ref.URL != "" ||
len(ref.Panes) > 0
if !hasContent {
return fmt.Errorf("inline plugin '%s' has no configuration fields", ref.Name)

View file

@ -14,9 +14,11 @@ func TestMergePluginConfigs(t *testing.T) {
Foreground: "#ff0000",
Background: "#0000ff",
Key: "L",
Filter: "status = 'todo'",
Sort: "Priority",
View: "compact",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status = 'todo'"},
},
Sort: "Priority",
View: "compact",
}
overrides := PluginRef{
@ -30,8 +32,8 @@ func TestMergePluginConfigs(t *testing.T) {
if result.Name != "Base" {
t.Errorf("Expected name 'Base', got '%s'", result.Name)
}
if result.Filter != "status = 'todo'" {
t.Errorf("Expected filter from base, got '%s'", result.Filter)
if len(result.Panes) != 1 || result.Panes[0].Filter != "status = 'todo'" {
t.Errorf("Expected panes from base, got %+v", result.Panes)
}
if result.Foreground != "#ff0000" {
t.Errorf("Expected foreground from base, got '%s'", result.Foreground)
@ -52,9 +54,11 @@ func TestMergePluginConfigs_AllOverrides(t *testing.T) {
Foreground: "#ff0000",
Background: "#0000ff",
Key: "L",
Filter: "status = 'todo'",
Sort: "Priority",
View: "compact",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status = 'todo'"},
},
Sort: "Priority",
View: "compact",
}
overrides := PluginRef{
@ -62,9 +66,11 @@ func TestMergePluginConfigs_AllOverrides(t *testing.T) {
Foreground: "#00ff00",
Background: "#000000",
Key: "O",
Filter: "status = 'done'",
Sort: "UpdatedAt DESC",
View: "expanded",
Panes: []PluginPaneConfig{
{Name: "Done", Filter: "status = 'done'"},
},
Sort: "UpdatedAt DESC",
View: "expanded",
}
result := mergePluginConfigs(base, overrides)
@ -82,8 +88,8 @@ func TestMergePluginConfigs_AllOverrides(t *testing.T) {
if result.Key != "O" {
t.Errorf("Expected key 'O', got '%s'", result.Key)
}
if result.Filter != "status = 'done'" {
t.Errorf("Expected filter 'status = 'done'', got '%s'", result.Filter)
if len(result.Panes) != 1 || result.Panes[0].Filter != "status = 'done'" {
t.Errorf("Expected pane filter 'status = 'done'', got %+v", result.Panes)
}
if result.Sort != "UpdatedAt DESC" {
t.Errorf("Expected sort 'UpdatedAt DESC', got '%s'", result.Sort)
@ -118,8 +124,10 @@ func TestValidatePluginRef_Hybrid(t *testing.T) {
func TestValidatePluginRef_InlineValid(t *testing.T) {
ref := PluginRef{
Name: "Test",
Filter: "status = 'todo'",
Name: "Test",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status = 'todo'"},
},
}
err := validatePluginRef(ref)
@ -130,7 +138,9 @@ func TestValidatePluginRef_InlineValid(t *testing.T) {
func TestValidatePluginRef_InlineNoName(t *testing.T) {
ref := PluginRef{
Filter: "status = 'todo'",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status = 'todo'"},
},
}
err := validatePluginRef(ref)
@ -173,7 +183,9 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
Background: tcell.ColorBlue,
Type: "tiki",
},
Filter: baseFilter,
Panes: []TikiPane{
{Name: "Todo", Columns: 1, Filter: baseFilter},
},
Sort: baseSort,
ViewMode: "compact",
}
@ -191,7 +203,9 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
ConfigIndex: 1,
Type: "tiki",
},
Filter: overrideFilter,
Panes: []TikiPane{
{Name: "Bugs", Columns: 1, Filter: overrideFilter},
},
Sort: nil,
ViewMode: "expanded",
}
@ -215,8 +229,8 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
if resultTiki.ViewMode != "expanded" {
t.Errorf("Expected expanded view, got %q", resultTiki.ViewMode)
}
if resultTiki.Filter == nil {
t.Error("Expected filter to be overridden")
if len(resultTiki.Panes) != 1 || resultTiki.Panes[0].Filter == nil {
t.Error("Expected pane filter to be overridden")
}
// Check that base sort is kept when override has nil
@ -239,7 +253,9 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
Background: tcell.ColorDefault,
Type: "tiki",
},
Filter: baseFilter,
Panes: []TikiPane{
{Name: "Todo", Columns: 1, Filter: baseFilter},
},
}
// Override with no modifier change (Modifier: 0)

View file

@ -49,6 +49,9 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
if cfg.View != "" {
return nil, fmt.Errorf("doki plugin cannot have 'view'")
}
if len(cfg.Panes) > 0 {
return nil, fmt.Errorf("doki plugin cannot have 'panes'")
}
if cfg.Fetcher != "file" && cfg.Fetcher != "internal" {
return nil, fmt.Errorf("doki plugin fetcher must be 'file' or 'internal', got '%s'", cfg.Fetcher)
@ -78,11 +81,42 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
if cfg.URL != "" {
return nil, fmt.Errorf("tiki plugin cannot have 'url'")
}
if cfg.Filter != "" {
return nil, fmt.Errorf("tiki plugin cannot have 'filter'")
}
if len(cfg.Panes) == 0 {
return nil, fmt.Errorf("tiki plugin requires 'panes'")
}
if len(cfg.Panes) > 10 {
return nil, fmt.Errorf("tiki plugin has too many panes (%d), max is 10", len(cfg.Panes))
}
// Parse filter expression
filterExpr, err := filter.ParseFilter(cfg.Filter)
if err != nil {
return nil, fmt.Errorf("parsing filter: %w", err)
panes := make([]TikiPane, 0, len(cfg.Panes))
for i, pane := range cfg.Panes {
if pane.Name == "" {
return nil, fmt.Errorf("pane %d missing name", i)
}
columns := pane.Columns
if columns == 0 {
columns = 1
}
if columns < 0 {
return nil, fmt.Errorf("pane %q has invalid columns %d", pane.Name, columns)
}
filterExpr, err := filter.ParseFilter(pane.Filter)
if err != nil {
return nil, fmt.Errorf("parsing filter for pane %q: %w", pane.Name, err)
}
action, err := ParsePaneAction(pane.Action)
if err != nil {
return nil, fmt.Errorf("parsing action for pane %q: %w", pane.Name, err)
}
panes = append(panes, TikiPane{
Name: pane.Name,
Columns: columns,
Filter: filterExpr,
Action: action,
})
}
// Parse sort rules
@ -93,7 +127,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
return &TikiPlugin{
BasePlugin: base,
Filter: filterExpr,
Panes: panes,
Sort: sortRules,
ViewMode: cfg.View,
}, nil

View file

@ -162,9 +162,11 @@ func TestParsePluginConfig_InvalidKey(t *testing.T) {
func TestParsePluginConfig_DefaultTikiType(t *testing.T) {
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Filter: "status='todo'",
Name: "Test",
Key: "T",
Panes: []PluginPaneConfig{
{Name: "Todo", Filter: "status='todo'"},
},
// Type not specified, should default to "tiki"
}
@ -206,11 +208,11 @@ func TestParsePluginConfig_TikiWithInvalidFilter(t *testing.T) {
_, err := parsePluginConfig(cfg, "test.yaml")
if err == nil {
t.Fatal("Expected error for invalid filter")
t.Fatal("Expected error for invalid top-level filter")
}
if !strings.Contains(err.Error(), "parsing filter") {
t.Errorf("Expected 'parsing filter' error, got: %v", err)
if !strings.Contains(err.Error(), "tiki plugin cannot have 'filter'") {
t.Errorf("Expected 'cannot have filter' error, got: %v", err)
}
}
@ -275,7 +277,10 @@ func TestParsePluginYAML_ValidTiki(t *testing.T) {
name: Test Plugin
key: T
type: tiki
filter: status = 'todo'
panes:
- name: Todo
columns: 4
filter: status = 'todo'
sort: Priority
view: expanded
foreground: "#ff0000"
@ -299,6 +304,14 @@ background: "#0000ff"
if tikiPlugin.ViewMode != "expanded" {
t.Errorf("Expected view mode 'expanded', got %q", tikiPlugin.ViewMode)
}
if len(tikiPlugin.Panes) != 1 {
t.Fatalf("Expected 1 pane, got %d", len(tikiPlugin.Panes))
}
if tikiPlugin.Panes[0].Columns != 4 {
t.Errorf("Expected pane columns 4, got %d", tikiPlugin.Panes[0].Columns)
}
}
func TestParsePluginYAML_ValidDoki(t *testing.T) {

View file

@ -270,8 +270,8 @@ func parseStatusFromContent(content string, fallbackID string) (task.Status, str
}
func isActiveStatus(status task.Status) bool {
column := task.StatusColumn(status)
return column == task.StatusTodo || column == task.StatusInProgress || column == task.StatusReview
pane := task.StatusPane(status)
return pane == task.StatusTodo || pane == task.StatusInProgress || pane == task.StatusReview
}
func deriveTaskID(fileName string) string {

View file

@ -153,11 +153,11 @@ func (s *InMemoryStore) GetTasksByStatus(status task.Status) []*task.Task {
s.mu.RLock()
defer s.mu.RUnlock()
targetColumn := task.StatusColumn(status)
targetPane := task.StatusPane(status)
var tasks []*task.Task
for _, t := range s.tasks {
if task.StatusColumn(t.Status) == targetColumn {
if task.StatusPane(t.Status) == targetPane {
tasks = append(tasks, t)
}
}
@ -171,7 +171,7 @@ func (s *InMemoryStore) GetBacklogTasks() []*task.Task {
var tasks []*task.Task
for _, t := range s.tasks {
if task.StatusColumn(t.Status) == task.StatusBacklog {
if task.StatusPane(t.Status) == task.StatusBacklog {
tasks = append(tasks, t)
}
}
@ -188,7 +188,7 @@ func (s *InMemoryStore) SearchBacklog(query string) []task.SearchResult {
var results []task.SearchResult
for _, t := range s.tasks {
if task.StatusColumn(t.Status) == task.StatusBacklog {
if task.StatusPane(t.Status) == task.StatusBacklog {
if query == "" || strings.Contains(strings.ToLower(t.Title), strings.ToLower(query)) {
results = append(results, task.SearchResult{Task: t, Score: 1.0})
}

View file

@ -27,11 +27,11 @@ func (s *TikiStore) GetTasksByStatus(status taskpkg.Status) []*taskpkg.Task {
s.mu.RLock()
defer s.mu.RUnlock()
targetColumn := taskpkg.StatusColumn(status)
targetPane := taskpkg.StatusPane(status)
var tasks []*taskpkg.Task
for _, t := range s.tasks {
if taskpkg.StatusColumn(t.Status) == targetColumn {
if taskpkg.StatusPane(t.Status) == targetPane {
tasks = append(tasks, t)
}
}
@ -47,7 +47,7 @@ func (s *TikiStore) GetBacklogTasks() []*taskpkg.Task {
var tasks []*taskpkg.Task
for _, t := range s.tasks {
if taskpkg.StatusColumn(t.Status) == taskpkg.StatusBacklog {
if taskpkg.StatusPane(t.Status) == taskpkg.StatusBacklog {
tasks = append(tasks, t)
}
}
@ -66,7 +66,7 @@ func (s *TikiStore) SearchBacklog(query string) []taskpkg.SearchResult {
// Return all backlog tasks
var tasks []*taskpkg.Task
for _, t := range s.tasks {
if taskpkg.StatusColumn(t.Status) == taskpkg.StatusBacklog {
if taskpkg.StatusPane(t.Status) == taskpkg.StatusBacklog {
tasks = append(tasks, t)
}
}
@ -81,7 +81,7 @@ func (s *TikiStore) SearchBacklog(query string) []taskpkg.SearchResult {
queryLower := strings.ToLower(query)
var tasks []*taskpkg.Task
for _, t := range s.tasks {
if taskpkg.StatusColumn(t.Status) == taskpkg.StatusBacklog {
if taskpkg.StatusPane(t.Status) == taskpkg.StatusBacklog {
if strings.Contains(strings.ToLower(t.Title), queryLower) {
tasks = append(tasks, t)
}

View file

@ -18,50 +18,59 @@ const (
)
type statusInfo struct {
label string
emoji string
column Status
label string
emoji string
pane Status
}
var statuses = map[Status]statusInfo{
StatusBacklog: {label: "Backlog", emoji: "📥", column: StatusBacklog},
StatusTodo: {label: "To Do", emoji: "📋", column: StatusTodo},
StatusReady: {label: "Ready", emoji: "📋", column: StatusTodo},
StatusInProgress: {label: "In Progress", emoji: "⚙️", column: StatusInProgress},
StatusWaiting: {label: "Waiting", emoji: "⏳", column: StatusReview},
StatusBlocked: {label: "Blocked", emoji: "⛔", column: StatusInProgress},
StatusReview: {label: "Review", emoji: "👀", column: StatusReview},
StatusDone: {label: "Done", emoji: "✅", column: StatusDone},
StatusBacklog: {label: "Backlog", emoji: "📥", pane: StatusBacklog},
StatusTodo: {label: "To Do", emoji: "📋", pane: StatusTodo},
StatusReady: {label: "Ready", emoji: "📋", pane: StatusTodo},
StatusInProgress: {label: "In Progress", emoji: "⚙️", pane: StatusInProgress},
StatusWaiting: {label: "Waiting", emoji: "⏳", pane: StatusReview},
StatusBlocked: {label: "Blocked", emoji: "⛔", pane: StatusInProgress},
StatusReview: {label: "Review", emoji: "👀", pane: StatusReview},
StatusDone: {label: "Done", emoji: "✅", pane: StatusDone},
}
func normalizeStatusKey(status string) string {
normalized := strings.ToLower(strings.TrimSpace(status))
normalized = strings.ReplaceAll(normalized, "-", "_")
normalized = strings.ReplaceAll(normalized, " ", "_")
return normalized
}
func ParseStatus(status string) (Status, bool) {
normalized := normalizeStatusKey(status)
switch normalized {
case "", "backlog":
return StatusBacklog, true
case "todo", "to_do":
return StatusTodo, true
case "ready":
return StatusReady, true
case "open":
return StatusTodo, true
case "in_progress", "inprocess", "in_process", "inprogress":
return StatusInProgress, true
case "waiting", "on_hold", "hold":
return StatusWaiting, true
case "blocked", "blocker":
return StatusBlocked, true
case "review", "in_review", "inreview":
return StatusReview, true
case "done", "closed", "completed":
return StatusDone, true
default:
return StatusBacklog, false
}
}
// NormalizeStatus standardizes a raw status string into a Status.
func NormalizeStatus(status string) Status {
normalized := strings.ToLower(strings.TrimSpace(status))
normalized = strings.ReplaceAll(normalized, "-", "_")
normalized = strings.ReplaceAll(normalized, " ", "_")
switch normalized {
case "", "backlog":
return StatusBacklog
case "todo", "to_do":
return StatusTodo
case "ready":
return StatusReady
case "open":
return StatusTodo
case "in_progress", "inprocess", "in_process", "inprogress":
return StatusInProgress
case "waiting", "on_hold", "hold":
return StatusWaiting
case "blocked", "blocker":
return StatusBlocked
case "review", "in_review", "inreview":
return StatusReview
case "done", "closed", "completed":
return StatusDone
default:
return StatusBacklog
}
normalized, _ := ParseStatus(status)
return normalized
}
// MapStatus maps a raw status string to a Status constant.
@ -77,9 +86,9 @@ func StatusToString(status Status) string {
return string(StatusBacklog)
}
func StatusColumn(status Status) Status {
if info, ok := statuses[status]; ok && info.column != "" {
return info.column
func StatusPane(status Status) Status {
if info, ok := statuses[status]; ok && info.pane != "" {
return info.pane
}
return StatusBacklog
}

View file

@ -37,24 +37,28 @@ func normalizeType(s string) string {
return s
}
// NormalizeType standardizes a raw type string into a Type.
func NormalizeType(t string) Type {
func ParseType(t string) (Type, bool) {
normalized := normalizeType(t)
switch normalized {
case "bug":
return TypeBug
return TypeBug, true
case "spike":
return TypeSpike
return TypeSpike, true
case "epic":
return TypeEpic
return TypeEpic, true
case "story", "feature", "task":
return TypeStory
return TypeStory, true
default:
return TypeStory
return TypeStory, false
}
}
// NormalizeType standardizes a raw type string into a Type.
func NormalizeType(t string) Type {
normalized, _ := ParseType(t)
return normalized
}
// TypeLabel returns a human-readable label for a task type.
func TypeLabel(taskType Type) string {
// Direct lookup using Type constant

View file

@ -94,6 +94,20 @@ const (
DefaultPriority = 3 // Medium
)
func IsValidPriority(priority int) bool {
return priority >= MinPriority && priority <= MaxPriority
}
func IsValidPoints(points int) bool {
if points == 0 {
return true
}
if points < 0 {
return false
}
return points <= config.GetMaxPoints()
}
// PriorityValidator validates priority range (1-5)
type PriorityValidator struct{}

View file

@ -11,7 +11,7 @@ import (
// CreateTestTask creates a markdown task file with YAML frontmatter
func CreateTestTask(dir, id, title string, status task.Status, taskType task.Type) error {
// Task files are lowercase (e.g., test-1.md)
// Task files are lowercase (e.g., tiki-1.md)
filename := strings.ToLower(id) + ".md"
filename = strings.ReplaceAll(filename, "-", "-") // already hyphenated
filepath := filepath.Join(dir, filename)
@ -31,7 +31,7 @@ points: 1
return os.WriteFile(filepath, []byte(content), 0644)
}
// CreateBoardTasks creates sample tasks across all board columns
// CreateBoardTasks creates sample tasks across all board panes
func CreateBoardTasks(dir string) error {
tasks := []struct {
id string
@ -39,11 +39,11 @@ func CreateBoardTasks(dir string) error {
status task.Status
taskType task.Type
}{
{"TEST-1", "Todo Task", task.StatusTodo, task.TypeStory},
{"TEST-2", "In Progress Task", task.StatusInProgress, task.TypeStory},
{"TEST-3", "Review Task", task.StatusReview, task.TypeStory},
{"TEST-4", "Done Task", task.StatusDone, task.TypeStory},
{"TEST-5", "Another Todo", task.StatusTodo, task.TypeBug},
{"TIKI-1", "Todo Task", task.StatusTodo, task.TypeStory},
{"TIKI-2", "In Progress Task", task.StatusInProgress, task.TypeStory},
{"TIKI-3", "Review Task", task.StatusReview, task.TypeStory},
{"TIKI-4", "Done Task", task.StatusDone, task.TypeStory},
{"TIKI-5", "Another Todo", task.StatusTodo, task.TypeBug},
}
for _, task := range tasks {

View file

@ -296,6 +296,11 @@ func (ta *TestApp) LoadPlugins() error {
// Create appropriate controller based on plugin type
if tp, ok := p.(*plugin.TikiPlugin); ok {
columns := make([]int, len(tp.Panes))
for i, pane := range tp.Panes {
columns[i] = pane.Columns
}
pc.SetPaneLayout(columns)
pluginControllers[p.GetName()] = controller.NewPluginController(
ta.TaskStore, pc, tp, ta.NavController,
)

View file

@ -12,15 +12,14 @@ import (
"github.com/rivo/tview"
)
// BoardView renders the kanban board: columns arranged horizontally, each containing task boxes.
// BoardView renders the kanban board with columns
// BoardView renders the kanban board: panes arranged horizontally, each containing task boxes.
// BoardView renders the kanban board with panes
type BoardView struct {
root *tview.Flex
searchHelper *SearchHelper
columnTitles tview.Primitive // column title row
columns *tview.Flex
columnBoxes []*ScrollableList // each column's task container
paneTitles tview.Primitive // pane title row
panes *tview.Flex
paneBoxes []*ScrollableList // each pane's task container
taskStore store.Store
boardConfig *model.BoardConfig
registry *controller.ActionRegistry
@ -77,23 +76,23 @@ func filterTasksBySearch(tasks []*task.Task, searchMap map[string]bool) []*task.
func (bv *BoardView) build() {
colors := config.GetColors()
// Collect column names for gradient caption row
columns := bv.boardConfig.GetColumns()
columnNames := make([]string, len(columns))
for i, col := range columns {
columnNames[i] = col.Name
// Collect pane names for gradient caption row
panes := bv.boardConfig.GetPanes()
paneNames := make([]string, len(panes))
for i, pane := range panes {
paneNames[i] = pane.Name
}
// Create single gradient caption row for all columns
bv.columnTitles = NewGradientCaptionRow(
columnNames,
colors.BoardColumnTitleGradient,
colors.BoardColumnTitleText,
// Create single gradient caption row for all panes
bv.paneTitles = NewGradientCaptionRow(
paneNames,
colors.BoardPaneTitleGradient,
colors.BoardPaneTitleText,
)
// columns container (just task lists, no titles)
bv.columns = tview.NewFlex().SetDirection(tview.FlexColumn)
bv.columnBoxes = make([]*ScrollableList, 0)
// panes container (just task lists, no titles)
bv.panes = tview.NewFlex().SetDirection(tview.FlexColumn)
bv.paneBoxes = make([]*ScrollableList, 0)
// determine item height based on view mode
itemHeight := config.TaskBoxHeight
@ -101,18 +100,18 @@ func (bv *BoardView) build() {
itemHeight = config.TaskBoxHeightExpanded
}
for _, col := range columns {
// task container for this column
for _, pane := range panes {
// task container for this pane
taskContainer := NewScrollableList().SetItemHeight(itemHeight)
bv.columnBoxes = append(bv.columnBoxes, taskContainer)
bv.paneBoxes = append(bv.paneBoxes, taskContainer)
// selected column gets focus
isSelected := col.ID == bv.boardConfig.GetSelectedColumnID()
bv.columns.AddItem(taskContainer, 0, 1, isSelected)
// selected pane gets focus
isSelected := pane.ID == bv.boardConfig.GetSelectedPaneID()
bv.panes.AddItem(taskContainer, 0, 1, isSelected)
}
// search helper - focus returns to columns container
bv.searchHelper = NewSearchHelper(bv.columns)
// search helper - focus returns to panes container
bv.searchHelper = NewSearchHelper(bv.panes)
bv.searchHelper.SetCancelHandler(func() {
bv.HideSearch()
})
@ -127,22 +126,22 @@ func (bv *BoardView) build() {
// rebuildLayout rebuilds the root layout based on current state (search visibility)
func (bv *BoardView) rebuildLayout() {
bv.root.Clear()
bv.root.AddItem(bv.columnTitles, 1, 0, false)
bv.root.AddItem(bv.paneTitles, 1, 0, false)
// Restore search box if search is active (e.g., returning from task details)
if bv.boardConfig.IsSearchActive() {
query := bv.boardConfig.GetSearchQuery()
bv.searchHelper.ShowSearch(query)
bv.root.AddItem(bv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, false)
bv.root.AddItem(bv.columns, 0, 1, false)
bv.root.AddItem(bv.panes, 0, 1, false)
} else {
bv.root.AddItem(bv.columns, 0, 1, true)
bv.root.AddItem(bv.panes, 0, 1, true)
}
}
func (bv *BoardView) refresh() {
columns := bv.boardConfig.GetColumns()
selectedColID := bv.boardConfig.GetSelectedColumnID()
panes := bv.boardConfig.GetPanes()
selectedPaneID := bv.boardConfig.GetSelectedPaneID()
selectedRow := bv.boardConfig.GetSelectedRow()
viewMode := bv.boardConfig.GetViewMode()
@ -156,16 +155,16 @@ func (bv *BoardView) refresh() {
searchResults := bv.boardConfig.GetSearchResults()
searchTaskMap := buildSearchMap(searchResults)
for i, col := range columns {
if i >= len(bv.columnBoxes) {
for i, pane := range panes {
if i >= len(bv.paneBoxes) {
break
}
container := bv.columnBoxes[i]
container := bv.paneBoxes[i]
container.SetItemHeight(itemHeight)
container.Clear()
allTasks := bv.taskStore.GetTasksByStatus(task.Status(col.Status))
allTasks := bv.taskStore.GetTasksByStatus(task.Status(pane.Status))
// Filter tasks by search results if search is active
tasks := filterTasksBySearch(allTasks, searchTaskMap)
@ -174,9 +173,9 @@ func (bv *BoardView) refresh() {
continue
}
// clamp selectedRow to valid bounds for this column
// clamp selectedRow to valid bounds for this pane
effectiveRow := selectedRow
if col.ID == selectedColID {
if pane.ID == selectedPaneID {
if effectiveRow >= len(tasks) {
effectiveRow = len(tasks) - 1
if effectiveRow < 0 {
@ -191,7 +190,7 @@ func (bv *BoardView) refresh() {
}
for j, task := range tasks {
isSelected := col.ID == selectedColID && j == effectiveRow
isSelected := pane.ID == selectedPaneID && j == effectiveRow
var taskFrame *tview.Frame
colors := config.GetColors()
if viewMode == model.ViewModeCompact {
@ -203,18 +202,18 @@ func (bv *BoardView) refresh() {
}
}
// Smart column selection: if current column is empty, find nearest non-empty column
selectedStatus := bv.boardConfig.GetStatusForColumn(selectedColID)
// Smart pane selection: if current pane is empty, find nearest non-empty pane
selectedStatus := bv.boardConfig.GetStatusForPane(selectedPaneID)
allSelectedTasks := bv.taskStore.GetTasksByStatus(selectedStatus)
// Filter by search if active
selectedTasks := filterTasksBySearch(allSelectedTasks, searchTaskMap)
if len(selectedTasks) == 0 {
// Current column is empty - find fallback column
// Current pane is empty - find fallback pane
currentIdx := -1
for i, col := range columns {
if col.ID == selectedColID {
for i, pane := range panes {
if pane.ID == selectedPaneID {
currentIdx = i
break
}
@ -223,34 +222,34 @@ func (bv *BoardView) refresh() {
if currentIdx >= 0 {
// Search LEFT first (preferred direction)
for i := currentIdx - 1; i >= 0; i-- {
status := bv.boardConfig.GetStatusForColumn(columns[i].ID)
status := bv.boardConfig.GetStatusForPane(panes[i].ID)
candidateTasks := bv.taskStore.GetTasksByStatus(status)
// Filter by search if active
filteredCandidates := filterTasksBySearch(candidateTasks, searchTaskMap)
if len(filteredCandidates) > 0 {
bv.boardConfig.SetSelection(columns[i].ID, 0)
bv.boardConfig.SetSelection(panes[i].ID, 0)
return
}
}
// Search RIGHT if no non-empty column found to the left
for i := currentIdx + 1; i < len(columns); i++ {
status := bv.boardConfig.GetStatusForColumn(columns[i].ID)
for i := currentIdx + 1; i < len(panes); i++ {
status := bv.boardConfig.GetStatusForPane(panes[i].ID)
candidateTasks := bv.taskStore.GetTasksByStatus(status)
// Filter by search if active
filteredCandidates := filterTasksBySearch(candidateTasks, searchTaskMap)
if len(filteredCandidates) > 0 {
bv.boardConfig.SetSelection(columns[i].ID, 0)
bv.boardConfig.SetSelection(panes[i].ID, 0)
return
}
}
}
// All columns empty - selection remains but nothing renders
// All panes empty - selection remains but nothing renders
// This is acceptable behavior per requirements
}
}
@ -281,11 +280,11 @@ func (bv *BoardView) OnFocus() {
}
// ensureValidSelection ensures selection is on a valid task.
// selects first task in leftmost non-empty column, or clears selection if all empty.
// selects first task in leftmost non-empty pane, or clears selection if all empty.
func (bv *BoardView) ensureValidSelection() {
// check if current selection is valid
currentColID := bv.boardConfig.GetSelectedColumnID()
currentStatus := bv.boardConfig.GetStatusForColumn(currentColID)
currentPaneID := bv.boardConfig.GetSelectedPaneID()
currentStatus := bv.boardConfig.GetStatusForPane(currentPaneID)
currentTasks := bv.taskStore.GetTasksByStatus(currentStatus)
currentRow := bv.boardConfig.GetSelectedRow()
@ -293,20 +292,20 @@ func (bv *BoardView) ensureValidSelection() {
return // current selection is valid
}
// find first non-empty column from left
for _, col := range bv.boardConfig.GetColumns() {
status := bv.boardConfig.GetStatusForColumn(col.ID)
// find first non-empty pane from left
for _, pane := range bv.boardConfig.GetPanes() {
status := bv.boardConfig.GetStatusForPane(pane.ID)
tasks := bv.taskStore.GetTasksByStatus(status)
if len(tasks) > 0 {
bv.boardConfig.SetSelection(col.ID, 0)
bv.boardConfig.SetSelection(pane.ID, 0)
return
}
}
// all columns empty - reset to first column, row 0 (nothing will be highlighted)
columns := bv.boardConfig.GetColumns()
if len(columns) > 0 {
bv.boardConfig.SetSelection(columns[0].ID, 0)
// all panes empty - reset to first pane, row 0 (nothing will be highlighted)
panes := bv.boardConfig.GetPanes()
if len(panes) > 0 {
bv.boardConfig.SetSelection(panes[0].ID, 0)
}
}
@ -319,8 +318,8 @@ func (bv *BoardView) OnBlur() {
// GetSelectedID returns the selected task ID
func (bv *BoardView) GetSelectedID() string {
colID := bv.boardConfig.GetSelectedColumnID()
status := bv.boardConfig.GetStatusForColumn(colID)
paneID := bv.boardConfig.GetSelectedPaneID()
status := bv.boardConfig.GetStatusForPane(paneID)
tasks := bv.taskStore.GetTasksByStatus(status)
row := bv.boardConfig.GetSelectedRow()
@ -338,12 +337,12 @@ func (bv *BoardView) SetSelectedID(id string) {
return
}
col := bv.boardConfig.GetColumnByStatus(task.Status)
if col == nil {
pane := bv.boardConfig.GetPaneByStatus(task.Status)
if pane == nil {
return
}
bv.boardConfig.SetSelectedColumn(col.ID)
bv.boardConfig.SetSelectedPane(pane.ID)
// find row index
tasks := bv.taskStore.GetTasksByStatus(task.Status)
@ -368,9 +367,9 @@ func (bv *BoardView) ShowSearch() tview.Primitive {
// Rebuild layout with search box
bv.root.Clear()
bv.root.AddItem(bv.columnTitles, 1, 0, false)
bv.root.AddItem(bv.paneTitles, 1, 0, false)
bv.root.AddItem(bv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, true)
bv.root.AddItem(bv.columns, 0, 1, false)
bv.root.AddItem(bv.panes, 0, 1, false)
return searchBox
}
@ -388,8 +387,8 @@ func (bv *BoardView) HideSearch() {
// Rebuild layout without search box
bv.root.Clear()
bv.root.AddItem(bv.columnTitles, 1, 0, false)
bv.root.AddItem(bv.columns, 0, 1, true)
bv.root.AddItem(bv.paneTitles, 1, 0, false)
bv.root.AddItem(bv.panes, 0, 1, true)
}
// IsSearchVisible returns whether the search box is currently visible
@ -414,10 +413,10 @@ func (bv *BoardView) SetFocusSetter(setter func(p tview.Primitive)) {
// GetStats returns stats for the header (Total count of board tasks)
func (bv *BoardView) GetStats() []store.Stat {
// Count tasks in all board columns (non-backlog statuses)
// Count tasks in all board panes (non-backlog statuses)
total := 0
for _, col := range bv.boardConfig.GetColumns() {
tasks := bv.taskStore.GetTasksByStatus(task.Status(col.Status))
for _, pane := range bv.boardConfig.GetPanes() {
tasks := bv.taskStore.GetTasksByStatus(task.Status(pane.Status))
total += len(tasks)
}

View file

@ -59,7 +59,7 @@ func (dv *DokiView) build() {
if dv.pluginDef.Foreground != tcell.ColorDefault {
textColor = dv.pluginDef.Foreground
}
titleGradient := gradientFromPrimaryColor(dv.pluginDef.Background, config.GetColors().BoardColumnTitleGradient)
titleGradient := gradientFromPrimaryColor(dv.pluginDef.Background, config.GetColors().BoardPaneTitleGradient)
dv.titleBar = NewGradientCaptionRow([]string{dv.pluginDef.Name}, titleGradient, textColor)
// content view (Navigable Markdown)

View file

@ -90,7 +90,7 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
f.taskStore,
pluginConfig,
tikiPlugin,
tikiController.GetFilteredTasks,
tikiController.GetFilteredTasksForPane,
)
} else {
// Fallback if controller type doesn't match

View file

@ -7,45 +7,45 @@ import (
"github.com/rivo/tview"
)
// GradientCaptionRow is a tview primitive that renders multiple column captions
// GradientCaptionRow is a tview primitive that renders multiple pane captions
// with a continuous horizontal background gradient spanning the entire screen width
type GradientCaptionRow struct {
*tview.Box
columnNames []string
gradient config.Gradient
textColor tcell.Color
paneNames []string
gradient config.Gradient
textColor tcell.Color
}
// NewGradientCaptionRow creates a new gradient caption row widget
func NewGradientCaptionRow(columnNames []string, gradient config.Gradient, textColor tcell.Color) *GradientCaptionRow {
func NewGradientCaptionRow(paneNames []string, gradient config.Gradient, textColor tcell.Color) *GradientCaptionRow {
return &GradientCaptionRow{
Box: tview.NewBox(),
columnNames: columnNames,
gradient: gradient,
textColor: textColor,
Box: tview.NewBox(),
paneNames: paneNames,
gradient: gradient,
textColor: textColor,
}
}
// Draw renders all column captions with a screen-wide gradient background
// Draw renders all pane captions with a screen-wide gradient background
func (gcr *GradientCaptionRow) Draw(screen tcell.Screen) {
gcr.DrawForSubclass(screen, gcr)
x, y, width, height := gcr.GetInnerRect()
if width <= 0 || height <= 0 || len(gcr.columnNames) == 0 {
if width <= 0 || height <= 0 || len(gcr.paneNames) == 0 {
return
}
// Calculate column width (equal distribution)
numColumns := len(gcr.columnNames)
columnWidth := width / numColumns
// Calculate pane width (equal distribution)
numPanes := len(gcr.paneNames)
paneWidth := width / numPanes
// Convert all column names to runes for Unicode handling
columnRunes := make([][]rune, numColumns)
for i, name := range gcr.columnNames {
columnRunes[i] = []rune(name)
// Convert all pane names to runes for Unicode handling
paneRunes := make([][]rune, numPanes)
for i, name := range gcr.paneNames {
paneRunes[i] = []rune(name)
}
// Render each column position across the screen
// Render each pane position across the screen
for col := 0; col < width; col++ {
// Calculate gradient color based on screen position (edges to center gradient)
// Distance from center: 0.0 at center, 1.0 at edges
@ -59,34 +59,34 @@ func (gcr *GradientCaptionRow) Draw(screen tcell.Screen) {
}
bgColor := interpolateColor(gcr.gradient, distanceFromCenter)
// Determine which column this position belongs to
columnIndex := col / columnWidth
if columnIndex >= numColumns {
columnIndex = numColumns - 1
// Determine which pane this position belongs to
paneIndex := col / paneWidth
if paneIndex >= numPanes {
paneIndex = numPanes - 1
}
// Calculate position within this column
columnStartX := columnIndex * columnWidth
columnEndX := columnStartX + columnWidth
if columnIndex == numColumns-1 {
columnEndX = width // Last column extends to screen edge
// Calculate position within this pane
paneStartX := paneIndex * paneWidth
paneEndX := paneStartX + paneWidth
if paneIndex == numPanes-1 {
paneEndX = width // Last pane extends to screen edge
}
currentColumnWidth := columnEndX - columnStartX
posInColumn := col - columnStartX
currentPaneWidth := paneEndX - paneStartX
posInPane := col - paneStartX
// Get the text for this column
textRunes := columnRunes[columnIndex]
// Get the text for this pane
textRunes := paneRunes[paneIndex]
textWidth := len(textRunes)
// Calculate centered text position within column
// Calculate centered text position within pane
textStartPos := 0
if textWidth < currentColumnWidth {
textStartPos = (currentColumnWidth - textWidth) / 2
if textWidth < currentPaneWidth {
textStartPos = (currentPaneWidth - textWidth) / 2
}
// Determine if we should render a character at this position
char := ' '
textIndex := posInColumn - textStartPos
textIndex := posInPane - textStartPos
if textIndex >= 0 && textIndex < textWidth {
char = textRunes[textIndex]
}

View file

@ -5,14 +5,16 @@ how Backlog is defined:
```text
name: Backlog
type: tiki
filter: status = 'backlog'
sort: Priority, ID
foreground: "#5fff87"
background: "#005f00"
key: "F3"
panes:
- name: Backlog
columns: 4
filter: status = 'backlog'
sort: Priority, ID
```
that translates to - show all tikis of in the status `backlog`, sort by priority and then by ID
that translates to - show all tikis of in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single pane
You define the name, caption colors, hotkey, tiki filter and sorting. Save this into a yaml file and add this line:
```text
@ -37,9 +39,72 @@ Likewise the documentation is just a plugin:
that translates to - show `index.md` file located under `.doc/doki`
installed in the same way
## Multi-pane plugin
Backlog is a pretty simple plugin in that it displays all tikis in a single pane. Multi-pane tiki plugins offer functionality
similar to that of the board. You can define multiple panes per view and move tikis around with Shift-Left/Shift-Right
much like in the board. You can create a multi-pane plugin by defining multiple panes in its definition and assigning
actions to each pane. An action defines what happens when you move a tiki into the pane. Here is a multi-pane plugin
definition that roughly mimics the board:
```yaml
name: Custom
foreground: "#5fff87"
background: "#005f00"
key: "F4"
sort: Priority, Title
panes:
- name: To Do
columns: 1
filter: status = 'todo'
action: status = 'todo'
- name: In Progress
columns: 1
filter: status = 'in_progress'
action: status = 'in_progress'
- name: Review
columns: 1
filter: status = 'review'
action: status = 'review'
- name: Done
columns: 1
filter: status = 'done'
action: status = 'done'
```
## Action expression
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the pane. Here `=`
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
### Supported Fields
- `status` - set workflow status (case-insensitive)
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
- `priority` - set numeric priority (1-5)
- `points` - set numeric points (0 or positive, up to max points)
- `assignee` - set assignee string
- `tags` - add/remove tags (list)
### Operators
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`
- `+=` adds tags, `-=` removes tags
- multiple operations are separated by commas: `status=done, tags+=[moved]`
### Literals
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
- use quotes when the value has spaces
- integers are used for `priority` and `points`
- tag lists use brackets: `tags += [ui, frontend]`
- `CURRENT_USER` assigns the current git user to `assignee`
- example: `assignee = CURRENT_USER`
## Filter expression
The `status = 'backlog'` statement in the backlog plugin is a filter expression that determines which tikis appear in the view.
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
### Supported Fields

View file

@ -16,19 +16,20 @@ import (
// Note: tcell import is still used for pv.pluginDef.Background/Foreground checks
// PluginView renders a filtered/sorted list of tasks in a 4-column grid
// PluginView renders a filtered/sorted list of tasks across panes
type PluginView struct {
root *tview.Flex
titleBar tview.Primitive
searchHelper *SearchHelper
grid *ScrollableList // rows container
panes *tview.Flex
paneBoxes []*ScrollableList
taskStore store.Store
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
registry *controller.ActionRegistry
storeListenerID int
selectionListenerID int
getFilteredTasks func() []*task.Task // injected from controller
getPaneTasks func(pane int) []*task.Task // injected from controller
}
// NewPluginView creates a plugin view
@ -36,14 +37,14 @@ func NewPluginView(
taskStore store.Store,
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
getFilteredTasks func() []*task.Task,
getPaneTasks func(pane int) []*task.Task,
) *PluginView {
pv := &PluginView{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
registry: controller.PluginViewActions(),
getFilteredTasks: getFilteredTasks,
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
registry: controller.PluginViewActions(),
getPaneTasks: getPaneTasks,
}
pv.build()
@ -57,20 +58,19 @@ func (pv *PluginView) build() {
if pv.pluginDef.Foreground != tcell.ColorDefault {
textColor = pv.pluginDef.Foreground
}
titleGradient := gradientFromPrimaryColor(pv.pluginDef.Background, config.GetColors().BoardColumnTitleGradient)
pv.titleBar = NewGradientCaptionRow([]string{pv.pluginDef.Name}, titleGradient, textColor)
// determine item height based on view mode
itemHeight := config.TaskBoxHeight
if pv.pluginConfig.GetViewMode() == model.ViewModeExpanded {
itemHeight = config.TaskBoxHeightExpanded
titleGradient := gradientFromPrimaryColor(pv.pluginDef.Background, config.GetColors().BoardPaneTitleGradient)
paneNames := make([]string, len(pv.pluginDef.Panes))
for i, pane := range pv.pluginDef.Panes {
paneNames[i] = pane.Name
}
pv.titleBar = NewGradientCaptionRow(paneNames, titleGradient, textColor)
// grid container (rows)
pv.grid = NewScrollableList().SetItemHeight(itemHeight)
// panes container (rows)
pv.panes = tview.NewFlex().SetDirection(tview.FlexColumn)
pv.paneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Panes))
// search helper - focus returns to grid
pv.searchHelper = NewSearchHelper(pv.grid)
// search helper - focus returns to panes container
pv.searchHelper = NewSearchHelper(pv.panes)
pv.searchHelper.SetCancelHandler(func() {
pv.HideSearch()
})
@ -92,9 +92,9 @@ func (pv *PluginView) rebuildLayout() {
query := pv.pluginConfig.GetSearchQuery()
pv.searchHelper.ShowSearch(query)
pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, false)
pv.root.AddItem(pv.grid, 0, 1, false)
pv.root.AddItem(pv.panes, 0, 1, false)
} else {
pv.root.AddItem(pv.grid, 0, 1, true)
pv.root.AddItem(pv.panes, 0, 1, true)
}
}
@ -106,53 +106,60 @@ func (pv *PluginView) refresh() {
if viewMode == model.ViewModeExpanded {
itemHeight = config.TaskBoxHeightExpanded
}
pv.grid.SetItemHeight(itemHeight)
pv.grid.Clear()
pv.panes.Clear()
pv.paneBoxes = pv.paneBoxes[:0]
// Get filtered and sorted tasks from controller
tasks := pv.getFilteredTasks()
columns := pv.pluginConfig.GetColumns()
selectedPane := pv.pluginConfig.GetSelectedPane()
if len(tasks) == 0 {
// Show nothing when there are no tasks
return
}
for paneIdx := range pv.pluginDef.Panes {
// task container for this pane
paneContainer := NewScrollableList().SetItemHeight(itemHeight)
pv.paneBoxes = append(pv.paneBoxes, paneContainer)
// clamp selection
pv.pluginConfig.ClampSelection(len(tasks))
selectedIndex := pv.pluginConfig.GetSelectedIndex()
isSelectedPane := paneIdx == selectedPane
pv.panes.AddItem(paneContainer, 0, 1, isSelectedPane)
// set selection on grid (by row)
selectedRow := selectedIndex / columns
pv.grid.SetSelection(selectedRow)
// build grid row by row
numRows := (len(tasks) + columns - 1) / columns
for row := 0; row < numRows; row++ {
rowFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
for col := 0; col < columns; col++ {
idx := row*columns + col
if idx < len(tasks) {
task := tasks[idx]
isSelected := idx == selectedIndex
var taskBox *tview.Frame
if viewMode == model.ViewModeCompact {
taskBox = CreateCompactTaskBox(task, isSelected, config.GetColors())
} else {
taskBox = CreateExpandedTaskBox(task, isSelected, config.GetColors())
}
rowFlex.AddItem(taskBox, 0, 1, false)
} else {
// empty placeholder for incomplete row
spacer := tview.NewBox()
rowFlex.AddItem(spacer, 0, 1, false)
}
tasks := pv.getPaneTasks(paneIdx)
if isSelectedPane {
pv.pluginConfig.ClampSelection(len(tasks))
}
if len(tasks) == 0 {
paneContainer.SetSelection(-1)
continue
}
pv.grid.AddItem(rowFlex)
columns := pv.pluginConfig.GetColumnsForPane(paneIdx)
selectedIndex := pv.pluginConfig.GetSelectedIndexForPane(paneIdx)
selectedRow := selectedIndex / columns
if isSelectedPane {
paneContainer.SetSelection(selectedRow)
} else {
paneContainer.SetSelection(-1)
}
numRows := (len(tasks) + columns - 1) / columns
for row := 0; row < numRows; row++ {
rowFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
for col := 0; col < columns; col++ {
idx := row*columns + col
if idx < len(tasks) {
task := tasks[idx]
isSelected := isSelectedPane && idx == selectedIndex
var taskBox *tview.Frame
if viewMode == model.ViewModeCompact {
taskBox = CreateCompactTaskBox(task, isSelected, config.GetColors())
} else {
taskBox = CreateExpandedTaskBox(task, isSelected, config.GetColors())
}
rowFlex.AddItem(taskBox, 0, 1, false)
} else {
spacer := tview.NewBox()
rowFlex.AddItem(spacer, 0, 1, false)
}
}
paneContainer.AddItem(rowFlex)
}
}
}
@ -186,8 +193,9 @@ func (pv *PluginView) OnBlur() {
// GetSelectedID returns the selected task ID
func (pv *PluginView) GetSelectedID() string {
tasks := pv.getFilteredTasks()
idx := pv.pluginConfig.GetSelectedIndex()
pane := pv.pluginConfig.GetSelectedPane()
tasks := pv.getPaneTasks(pane)
idx := pv.pluginConfig.GetSelectedIndexForPane(pane)
if idx >= 0 && idx < len(tasks) {
return tasks[idx].ID
}
@ -196,11 +204,14 @@ func (pv *PluginView) GetSelectedID() string {
// SetSelectedID sets the selection to a task
func (pv *PluginView) SetSelectedID(id string) {
tasks := pv.getFilteredTasks()
for i, t := range tasks {
if t.ID == id {
pv.pluginConfig.SetSelectedIndex(i)
break
for pane := range pv.pluginDef.Panes {
tasks := pv.getPaneTasks(pane)
for i, t := range tasks {
if t.ID == id {
pv.pluginConfig.SetSelectedPane(pane)
pv.pluginConfig.SetSelectedIndexForPane(pane, i)
return
}
}
}
}
@ -218,7 +229,7 @@ func (pv *PluginView) ShowSearch() tview.Primitive {
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
pv.root.AddItem(pv.searchHelper.GetSearchBox(), config.SearchBoxHeight, 0, true)
pv.root.AddItem(pv.grid, 0, 1, false)
pv.root.AddItem(pv.panes, 0, 1, false)
return searchBox
}
@ -237,7 +248,7 @@ func (pv *PluginView) HideSearch() {
// Rebuild layout without search box
pv.root.Clear()
pv.root.AddItem(pv.titleBar, 1, 0, false)
pv.root.AddItem(pv.grid, 0, 1, true)
pv.root.AddItem(pv.panes, 0, 1, true)
}
// IsSearchVisible returns whether the search box is currently visible
@ -262,8 +273,12 @@ func (pv *PluginView) SetFocusSetter(setter func(p tview.Primitive)) {
// GetStats returns stats for the header (Total count of filtered tasks)
func (pv *PluginView) GetStats() []store.Stat {
tasks := pv.getFilteredTasks()
total := 0
for pane := range pv.pluginDef.Panes {
tasks := pv.getPaneTasks(pane)
total += len(tasks)
}
return []store.Stat{
{Name: "Total", Value: fmt.Sprintf("%d", len(tasks)), Order: 5},
{Name: "Total", Value: fmt.Sprintf("%d", total), Order: 5},
}
}