From 2a6761d04c2873838a1cc9eeee9f08fc1a5a57f2 Mon Sep 17 00:00:00 2001 From: booleanmaybe Date: Thu, 22 Jan 2026 19:24:53 -0500 Subject: [PATCH] fix selection on empty panes --- .gitignore | 3 +- controller/plugin.go | 21 ++++- controller/plugin_selection_test.go | 137 ++++++++++++++++++++++++++++ model/plugin_config.go | 20 ++++ plugin/embed/roadmap.yaml | 15 ++- view/factory.go | 1 + view/tiki_plugin_view.go | 16 +++- view/tiki_plugin_view_test.go | 69 ++++++++++++++ 8 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 controller/plugin_selection_test.go create mode 100644 view/tiki_plugin_view_test.go diff --git a/.gitignore b/.gitignore index ca559c9..3e98019 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/controller/plugin.go b/controller/plugin.go index b35b08b..ea9419d 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -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 diff --git a/controller/plugin_selection_test.go b/controller/plugin_selection_test.go new file mode 100644 index 0000000..c282876 --- /dev/null +++ b/controller/plugin_selection_test.go @@ -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)) + } +} diff --git a/model/plugin_config.go b/model/plugin_config.go index 4688b86..32bdcbe 100644 --- a/model/plugin_config.go +++ b/model/plugin_config.go @@ -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() diff --git a/plugin/embed/roadmap.yaml b/plugin/embed/roadmap.yaml index ede2239..3332304 100644 --- a/plugin/embed/roadmap.yaml +++ b/plugin/embed/roadmap.yaml @@ -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 \ No newline at end of file diff --git a/view/factory.go b/view/factory.go index 93c5a9a..48e634d 100644 --- a/view/factory.go +++ b/view/factory.go @@ -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 diff --git a/view/tiki_plugin_view.go b/view/tiki_plugin_view.go index d163da1..877f479 100644 --- a/view/tiki_plugin_view.go +++ b/view/tiki_plugin_view.go @@ -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,13 +39,15 @@ func NewPluginView( pluginConfig *model.PluginConfig, pluginDef *plugin.TikiPlugin, getPaneTasks func(pane int) []*task.Task, + ensureSelection func() bool, ) *PluginView { pv := &PluginView{ - taskStore: taskStore, - pluginConfig: pluginConfig, - pluginDef: pluginDef, - registry: controller.PluginViewActions(), - getPaneTasks: getPaneTasks, + taskStore: taskStore, + pluginConfig: pluginConfig, + 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 diff --git a/view/tiki_plugin_view_test.go b/view/tiki_plugin_view_test.go new file mode 100644 index 0000000..07729c0 --- /dev/null +++ b/view/tiki_plugin_view_test.go @@ -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) + } +}