diff --git a/config/colors.go b/config/colors.go index bc7057b..613cc9b 100644 --- a/config/colors.go +++ b/config/colors.go @@ -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) diff --git a/controller/actions.go b/controller/actions.go index e35d2be..0bdc794 100644 --- a/controller/actions.go +++ b/controller/actions.go @@ -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}) diff --git a/controller/board.go b/controller/board.go index 99c53ea..3f79a75 100644 --- a/controller/board.go +++ b/controller/board.go @@ -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) } } } diff --git a/controller/plugin.go b/controller/plugin.go index fe677f5..b35b08b 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -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 +} diff --git a/controller/testing.go b/controller/testing.go index a439b4a..d9b4807 100644 --- a/controller/testing.go +++ b/controller/testing.go @@ -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, diff --git a/controller/util.go b/controller/util.go index 6f2d48e..cb33e10 100644 --- a/controller/util.go +++ b/controller/util.go @@ -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 +} diff --git a/integration/board_search_test.go b/integration/board_search_test.go index adf1a14..fa91dd9 100644 --- a/integration/board_search_test.go +++ b/integration/board_search_test.go @@ -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) diff --git a/integration/board_view_test.go b/integration/board_view_test.go index 3dbb8b9..7620172 100644 --- a/integration/board_view_test.go +++ b/integration/board_view_test.go @@ -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) diff --git a/integration/pane_action_test.go b/integration/pane_action_test.go new file mode 100644 index 0000000..49f854d --- /dev/null +++ b/integration/pane_action_test.go @@ -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 +} diff --git a/integration/plugin_navigation_test.go b/integration/plugin_navigation_test.go index 7e59414..88d92fe 100644 --- a/integration/plugin_navigation_test.go +++ b/integration/plugin_navigation_test.go @@ -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() diff --git a/integration/plugin_view_test.go b/integration/plugin_view_test.go index eeeab71..ea5fc76 100644 --- a/integration/plugin_view_test.go +++ b/integration/plugin_view_test.go @@ -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)") diff --git a/integration/refresh_test.go b/integration/refresh_test.go index 5409a1a..add1a6d 100644 --- a/integration/refresh_test.go +++ b/integration/refresh_test.go @@ -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") diff --git a/integration/task_deletion_test.go b/integration/task_deletion_test.go index 95e23ac..d58563d 100644 --- a/integration/task_deletion_test.go +++ b/integration/task_deletion_test.go @@ -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") } } diff --git a/integration/task_detail_view_test.go b/integration/task_detail_view_test.go index f1ce671..0c9e18f 100644 --- a/integration/task_detail_view_test.go +++ b/integration/task_detail_view_test.go @@ -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) } diff --git a/integration/task_edit_advanced_test.go b/integration/task_edit_advanced_test.go index 2789e3b..3138816 100644 --- a/integration/task_edit_advanced_test.go +++ b/integration/task_edit_advanced_test.go @@ -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) } diff --git a/integration/task_edit_test.go b/integration/task_edit_test.go index cd8cd3e..40b0c06 100644 --- a/integration/task_edit_test.go +++ b/integration/task_edit_test.go @@ -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) } diff --git a/internal/bootstrap/plugins.go b/internal/bootstrap/plugins.go index 4b3db3b..ba951c6 100644 --- a/internal/bootstrap/plugins.go +++ b/internal/bootstrap/plugins.go @@ -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 diff --git a/model/board_config.go b/model/board_config.go index 9180b64..9f1e623 100644 --- a/model/board_config.go +++ b/model/board_config.go @@ -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() } diff --git a/model/board_config_test.go b/model/board_config_test.go index 0424df8..0822bbd 100644 --- a/model/board_config_test.go +++ b/model/board_config_test.go @@ -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) } diff --git a/model/entities.go b/model/entities.go index bc699ec..c8f80bb 100644 --- a/model/entities.go +++ b/model/entities.go @@ -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) } diff --git a/model/plugin_config.go b/model/plugin_config.go index a900334..4688b86 100644 --- a/model/plugin_config.go +++ b/model/plugin_config.go @@ -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 +} diff --git a/model/plugin_config_test.go b/model/plugin_config_test.go index f23ed0b..3d57695 100644 --- a/model/plugin_config_test.go +++ b/model/plugin_config_test.go @@ -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 { diff --git a/model/search_state.go b/model/search_state.go index de096ba..1e236bf 100644 --- a/model/search_state.go +++ b/model/search_state.go @@ -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 diff --git a/model/search_state_test.go b/model/search_state_test.go index a27b065..6965f00 100644 --- a/model/search_state_test.go +++ b/model/search_state_test.go @@ -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") } } diff --git a/plugin/action.go b/plugin/action.go new file mode 100644 index 0000000..9bfa4e7 --- /dev/null +++ b/plugin/action.go @@ -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 +} diff --git a/plugin/action_test.go b/plugin/action_test.go new file mode 100644 index 0000000..ab4fb1e --- /dev/null +++ b/plugin/action_test.go @@ -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) + } +} diff --git a/plugin/definition.go b/plugin/definition.go index 4223b72..adfff50 100644 --- a/plugin/definition.go +++ b/plugin/definition.go @@ -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"` } diff --git a/plugin/embed/backlog.yaml b/plugin/embed/backlog.yaml index acf9cb3..a1bd7d4 100644 --- a/plugin/embed/backlog.yaml +++ b/plugin/embed/backlog.yaml @@ -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 \ No newline at end of file diff --git a/plugin/embed/recent.yaml b/plugin/embed/recent.yaml index a287cf7..3ea210b 100644 --- a/plugin/embed/recent.yaml +++ b/plugin/embed/recent.yaml @@ -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 \ No newline at end of file diff --git a/plugin/embed/roadmap.yaml b/plugin/embed/roadmap.yaml index 474e0ca..ede2239 100644 --- a/plugin/embed/roadmap.yaml +++ b/plugin/embed/roadmap.yaml @@ -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 \ No newline at end of file diff --git a/plugin/integration_test.go b/plugin/integration_test.go index b3a9848..1c3a93e 100644 --- a/plugin/integration_test.go +++ b/plugin/integration_test.go @@ -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) } diff --git a/plugin/loader.go b/plugin/loader.go index 811c811..1bd7c87 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -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 } diff --git a/plugin/loader_test.go b/plugin/loader_test.go index 9f916df..c463b6e 100644 --- a/plugin/loader_test.go +++ b/plugin/loader_test.go @@ -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, "") diff --git a/plugin/merger.go b/plugin/merger.go index 5aadb3c..55cf1df 100644 --- a/plugin/merger.go +++ b/plugin/merger.go @@ -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) diff --git a/plugin/merger_test.go b/plugin/merger_test.go index 3e81164..1c5d3c2 100644 --- a/plugin/merger_test.go +++ b/plugin/merger_test.go @@ -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) diff --git a/plugin/parser.go b/plugin/parser.go index e0b0c02..bfcc775 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -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 diff --git a/plugin/parser_test.go b/plugin/parser_test.go index 7977ea1..a8ab2cd 100644 --- a/plugin/parser_test.go +++ b/plugin/parser_test.go @@ -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) { diff --git a/store/history.go b/store/history.go index e83abd7..bc9fe85 100644 --- a/store/history.go +++ b/store/history.go @@ -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 { diff --git a/store/memory_store.go b/store/memory_store.go index dad341f..3d3d1d1 100644 --- a/store/memory_store.go +++ b/store/memory_store.go @@ -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}) } diff --git a/store/tikistore/query.go b/store/tikistore/query.go index cf811cc..c195243 100644 --- a/store/tikistore/query.go +++ b/store/tikistore/query.go @@ -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) } diff --git a/task/status.go b/task/status.go index 2ffa8f0..eb51184 100644 --- a/task/status.go +++ b/task/status.go @@ -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 } diff --git a/task/type.go b/task/type.go index ce73ce9..c23a457 100644 --- a/task/type.go +++ b/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 diff --git a/task/validation_rules.go b/task/validation_rules.go index f817cc1..d26391f 100644 --- a/task/validation_rules.go +++ b/task/validation_rules.go @@ -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{} diff --git a/testutil/fixtures.go b/testutil/fixtures.go index 926c5a2..c96ab7b 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -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 { diff --git a/testutil/integration_helpers.go b/testutil/integration_helpers.go index 6c620f7..c10cb42 100644 --- a/testutil/integration_helpers.go +++ b/testutil/integration_helpers.go @@ -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, ) diff --git a/view/board.go b/view/board.go index 609e920..36580ae 100644 --- a/view/board.go +++ b/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) } diff --git a/view/doki_plugin_view.go b/view/doki_plugin_view.go index 4cf3c12..7afabb3 100644 --- a/view/doki_plugin_view.go +++ b/view/doki_plugin_view.go @@ -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) diff --git a/view/factory.go b/view/factory.go index 01b0c78..93c5a9a 100644 --- a/view/factory.go +++ b/view/factory.go @@ -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 diff --git a/view/gradient_caption_row.go b/view/gradient_caption_row.go index 3d759ba..98de0e4 100644 --- a/view/gradient_caption_row.go +++ b/view/gradient_caption_row.go @@ -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] } diff --git a/view/help/custom.md b/view/help/custom.md index 4c39982..9848861 100644 --- a/view/help/custom.md +++ b/view/help/custom.md @@ -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 diff --git a/view/tiki_plugin_view.go b/view/tiki_plugin_view.go index 2a6366e..de57121 100644 --- a/view/tiki_plugin_view.go +++ b/view/tiki_plugin_view.go @@ -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}, } }