mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
multi-pane plugins
This commit is contained in:
parent
d8faf72a6d
commit
264d2ef65f
51 changed files with 2356 additions and 1026 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
100
integration/pane_action_test.go
Normal file
100
integration/pane_action_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
376
plugin/action.go
Normal 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
318
plugin/action_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
20
task/type.go
20
task/type.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
145
view/board.go
145
view/board.go
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue