mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
fix selection on empty panes
This commit is contained in:
parent
c732bf751f
commit
2a6761d04c
8 changed files with 269 additions and 13 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
.gocache
|
||||
.gomodcache
|
||||
go-build-cache
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea/
|
||||
|
|
@ -12,7 +13,7 @@ out/
|
|||
# AI
|
||||
.cursor
|
||||
.cursorindexingignore
|
||||
.specstory/
|
||||
.specstory
|
||||
.claude
|
||||
|
||||
# Tiki binary
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ func (pc *PluginController) HandleSearch(query string) {
|
|||
}
|
||||
|
||||
pc.pluginConfig.SetSearchResults(results, query)
|
||||
if pc.selectFirstSearchPane() {
|
||||
if pc.selectFirstNonEmptyPane() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -297,18 +297,31 @@ func (pc *PluginController) selectTaskInPane(pane int, taskID string) {
|
|||
pc.pluginConfig.SetSelectedIndexForPane(pane, targetIndex)
|
||||
}
|
||||
|
||||
func (pc *PluginController) selectFirstSearchPane() bool {
|
||||
func (pc *PluginController) selectFirstNonEmptyPane() bool {
|
||||
for pane := range pc.pluginDef.Panes {
|
||||
tasks := pc.GetFilteredTasksForPane(pane)
|
||||
if len(tasks) > 0 {
|
||||
pc.pluginConfig.SetSelectedPane(pane)
|
||||
pc.pluginConfig.SetSelectedIndexForPane(pane, 0)
|
||||
pc.pluginConfig.SetSelectedPaneAndIndex(pane, 0)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pc *PluginController) EnsureFirstNonEmptyPaneSelection() bool {
|
||||
if pc.pluginDef == nil {
|
||||
return false
|
||||
}
|
||||
currentPane := pc.pluginConfig.GetSelectedPane()
|
||||
if currentPane >= 0 && currentPane < len(pc.pluginDef.Panes) {
|
||||
tasks := pc.GetFilteredTasksForPane(currentPane)
|
||||
if len(tasks) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return pc.selectFirstNonEmptyPane()
|
||||
}
|
||||
|
||||
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
|
||||
if updated == nil {
|
||||
return
|
||||
|
|
|
|||
137
controller/plugin_selection_test.go
Normal file
137
controller/plugin_selection_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestEnsureFirstNonEmptyPaneSelectionSelectsFirstTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1",
|
||||
Title: "Task 1",
|
||||
Status: task.StatusTodo,
|
||||
Type: task.TypeStory,
|
||||
}); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: "T-2",
|
||||
Title: "Task 2",
|
||||
Status: task.StatusTodo,
|
||||
Type: task.TypeStory,
|
||||
}); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
emptyFilter, err := filter.ParseFilter("status = 'done'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
todoFilter, err := filter.ParseFilter("status = 'todo'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
Name: "TestPlugin",
|
||||
},
|
||||
Panes: []plugin.TikiPane{
|
||||
{Name: "Empty", Columns: 1, Filter: emptyFilter},
|
||||
{Name: "Todo", Columns: 1, Filter: todoFilter},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetPaneLayout([]int{1, 1})
|
||||
pluginConfig.SetSelectedPane(0)
|
||||
pluginConfig.SetSelectedIndexForPane(0, 1)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
|
||||
pc.EnsureFirstNonEmptyPaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedPane() != 1 {
|
||||
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
|
||||
}
|
||||
if pluginConfig.GetSelectedIndexForPane(1) != 0 {
|
||||
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForPane(1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFirstNonEmptyPaneSelectionKeepsCurrentPane(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
if err := taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1",
|
||||
Title: "Task 1",
|
||||
Status: task.StatusTodo,
|
||||
Type: task.TypeStory,
|
||||
}); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
todoFilter, err := filter.ParseFilter("status = 'todo'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
Name: "TestPlugin",
|
||||
},
|
||||
Panes: []plugin.TikiPane{
|
||||
{Name: "First", Columns: 1, Filter: todoFilter},
|
||||
{Name: "Second", Columns: 1, Filter: todoFilter},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetPaneLayout([]int{1, 1})
|
||||
pluginConfig.SetSelectedPane(1)
|
||||
pluginConfig.SetSelectedIndexForPane(1, 0)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
|
||||
pc.EnsureFirstNonEmptyPaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedPane() != 1 {
|
||||
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
|
||||
}
|
||||
if pluginConfig.GetSelectedIndexForPane(1) != 0 {
|
||||
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForPane(1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureFirstNonEmptyPaneSelectionNoTasks(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, err := filter.ParseFilter("status = 'done'")
|
||||
if err != nil {
|
||||
t.Fatalf("parse filter: %v", err)
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
Name: "TestPlugin",
|
||||
},
|
||||
Panes: []plugin.TikiPane{
|
||||
{Name: "Empty", Columns: 1, Filter: emptyFilter},
|
||||
{Name: "StillEmpty", Columns: 1, Filter: emptyFilter},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetPaneLayout([]int{1, 1})
|
||||
pluginConfig.SetSelectedPane(1)
|
||||
pluginConfig.SetSelectedIndexForPane(1, 2)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
|
||||
pc.EnsureFirstNonEmptyPaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedPane() != 1 {
|
||||
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
|
||||
}
|
||||
if pluginConfig.GetSelectedIndexForPane(1) != 2 {
|
||||
t.Fatalf("expected selected index 2, got %d", pluginConfig.GetSelectedIndexForPane(1))
|
||||
}
|
||||
}
|
||||
|
|
@ -125,6 +125,26 @@ func (pc *PluginConfig) SetSelectedIndexForPane(pane int, idx int) {
|
|||
pc.notifyListeners()
|
||||
}
|
||||
|
||||
func (pc *PluginConfig) SetSelectedPaneAndIndex(pane int, idx int) {
|
||||
pc.mu.Lock()
|
||||
if pane < 0 || pane >= len(pc.selectedIndices) {
|
||||
pc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(pc.selectedIndices) == 0 {
|
||||
pc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
changed := pc.selectedPane != pane || pc.selectedIndices[pane] != idx
|
||||
pc.selectedPane = pane
|
||||
pc.selectedIndices[pane] = idx
|
||||
pc.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
pc.notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// GetColumnsForPane returns the number of grid columns for a pane.
|
||||
func (pc *PluginConfig) GetColumnsForPane(pane int) int {
|
||||
pc.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -3,8 +3,17 @@ foreground: "#e1bee7"
|
|||
background: "#4a148c"
|
||||
key: "F2"
|
||||
panes:
|
||||
- name: Roadmap
|
||||
columns: 4
|
||||
filter: type = 'epic'
|
||||
- name: Now
|
||||
columns: 2
|
||||
filter: type = 'epic' AND status = 'todo'
|
||||
action: status = 'todo'
|
||||
- name: Next
|
||||
columns: 1
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority = 1
|
||||
action: status = 'backlog', priority = 1
|
||||
- name: Later
|
||||
columns: 1
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority > 1
|
||||
action: status = 'backlog', priority = 2
|
||||
sort: Priority, Points DESC
|
||||
view: expanded
|
||||
|
|
@ -91,6 +91,7 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
|
|||
pluginConfig,
|
||||
tikiPlugin,
|
||||
tikiController.GetFilteredTasksForPane,
|
||||
tikiController.EnsureFirstNonEmptyPaneSelection,
|
||||
)
|
||||
} else {
|
||||
// Fallback if controller type doesn't match
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type PluginView struct {
|
|||
storeListenerID int
|
||||
selectionListenerID int
|
||||
getPaneTasks func(pane int) []*task.Task // injected from controller
|
||||
ensureSelection func() bool // injected from controller
|
||||
}
|
||||
|
||||
// NewPluginView creates a plugin view
|
||||
|
|
@ -38,6 +39,7 @@ func NewPluginView(
|
|||
pluginConfig *model.PluginConfig,
|
||||
pluginDef *plugin.TikiPlugin,
|
||||
getPaneTasks func(pane int) []*task.Task,
|
||||
ensureSelection func() bool,
|
||||
) *PluginView {
|
||||
pv := &PluginView{
|
||||
taskStore: taskStore,
|
||||
|
|
@ -45,6 +47,7 @@ func NewPluginView(
|
|||
pluginDef: pluginDef,
|
||||
registry: controller.PluginViewActions(),
|
||||
getPaneTasks: getPaneTasks,
|
||||
ensureSelection: ensureSelection,
|
||||
}
|
||||
|
||||
pv.build()
|
||||
|
|
@ -100,6 +103,9 @@ func (pv *PluginView) rebuildLayout() {
|
|||
|
||||
func (pv *PluginView) refresh() {
|
||||
viewMode := pv.pluginConfig.GetViewMode()
|
||||
if pv.ensureSelection != nil {
|
||||
pv.ensureSelection()
|
||||
}
|
||||
|
||||
// update item height based on view mode
|
||||
itemHeight := config.TaskBoxHeight
|
||||
|
|
|
|||
69
view/tiki_plugin_view_test.go
Normal file
69
view/tiki_plugin_view_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetPaneLayout([]int{1})
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{
|
||||
Name: "TestPlugin",
|
||||
},
|
||||
Panes: []plugin.TikiPane{
|
||||
{Name: "Pane", Columns: 1},
|
||||
},
|
||||
}
|
||||
|
||||
tasks := make([]*task.Task, 10)
|
||||
for i := range tasks {
|
||||
tasks[i] = &task.Task{
|
||||
ID: fmt.Sprintf("T-%d", i),
|
||||
Title: fmt.Sprintf("Task %d", i),
|
||||
Status: task.StatusTodo,
|
||||
Type: task.TypeStory,
|
||||
}
|
||||
}
|
||||
|
||||
pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(pane int) []*task.Task {
|
||||
return tasks
|
||||
}, nil)
|
||||
|
||||
if len(pv.paneBoxes) != 1 {
|
||||
t.Fatalf("expected 1 pane box, got %d", len(pv.paneBoxes))
|
||||
}
|
||||
|
||||
pane := pv.paneBoxes[0]
|
||||
itemHeight := config.TaskBoxHeight
|
||||
pane.SetRect(0, 0, 80, itemHeight*5)
|
||||
|
||||
pluginConfig.SetSelectedIndexForPane(0, len(tasks)-1)
|
||||
pv.refresh()
|
||||
|
||||
expectedScrollOffset := len(tasks) - 5
|
||||
if pane.scrollOffset != expectedScrollOffset {
|
||||
t.Fatalf("expected scrollOffset %d, got %d", expectedScrollOffset, pane.scrollOffset)
|
||||
}
|
||||
|
||||
paneBefore := pane
|
||||
pluginConfig.SetSelectedIndexForPane(0, len(tasks)-2)
|
||||
pv.refresh()
|
||||
|
||||
if pv.paneBoxes[0] != paneBefore {
|
||||
t.Fatalf("expected pane list to be reused across refresh")
|
||||
}
|
||||
|
||||
if pane.scrollOffset != expectedScrollOffset {
|
||||
t.Fatalf("expected scrollOffset to remain %d, got %d", expectedScrollOffset, pane.scrollOffset)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue