rename pane to lane

This commit is contained in:
booleanmaybe 2026-02-10 16:18:05 -05:00
parent fe0af13cb3
commit aa94c897d6
29 changed files with 524 additions and 524 deletions

View file

@ -8,7 +8,7 @@ how Backlog is defined:
foreground: "#5fff87"
background: "#005f00"
key: "F3"
panes:
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog'
@ -18,7 +18,7 @@ how Backlog is defined:
action: status = 'ready'
sort: Priority, ID
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single pane.
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
You define the name, caption colors, hotkey, tiki filter and sorting. Save this into a yaml file and add this line:
@ -44,12 +44,12 @@ 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
## Multi-lane 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
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
much like in the board. You can create a multi-lane plugin by defining multiple lanes in its definition and assigning
actions to each lane. An action defines what happens when you move a tiki into the lane. Here is a multi-lane plugin
definition that roughly mimics the board:
```yaml
@ -58,7 +58,7 @@ foreground: "#5fff87"
background: "#005f00"
key: "F4"
sort: Priority, Title
panes:
lanes:
- name: Ready
columns: 1
filter: status = 'ready'
@ -79,7 +79,7 @@ panes:
## Plugin actions
In addition to pane actions that trigger when moving tikis between panes, you can define plugin-level actions
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
```yaml
@ -95,14 +95,14 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - an action expression (same syntax as pane actions, see below)
- `action` - an action expression (same syntax as lane actions, see below)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
## 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 `=`
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. 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

View file

@ -133,13 +133,13 @@ func (pc *PluginController) HandleAction(actionID ActionID) bool {
}
func (pc *PluginController) handleNav(direction string) bool {
pane := pc.pluginConfig.GetSelectedPane()
tasks := pc.GetFilteredTasksForPane(pane)
lane := pc.pluginConfig.GetSelectedLane()
tasks := pc.GetFilteredTasksForLane(lane)
if direction == "left" || direction == "right" {
if pc.pluginConfig.MoveSelection(direction, len(tasks)) {
return true
}
return pc.handlePaneSwitch(direction)
return pc.handleLaneSwitch(direction)
}
return pc.pluginConfig.MoveSelection(direction, len(tasks))
}
@ -156,38 +156,38 @@ func (pc *PluginController) handleOpenTask() bool {
return true
}
func (pc *PluginController) handlePaneSwitch(direction string) bool {
currentPane := pc.pluginConfig.GetSelectedPane()
nextPane := currentPane
func (pc *PluginController) handleLaneSwitch(direction string) bool {
currentLane := pc.pluginConfig.GetSelectedLane()
nextLane := currentLane
switch direction {
case "left":
nextPane--
nextLane--
case "right":
nextPane++
nextLane++
default:
return false
}
for nextPane >= 0 && nextPane < len(pc.pluginDef.Panes) {
tasks := pc.GetFilteredTasksForPane(nextPane)
for nextLane >= 0 && nextLane < len(pc.pluginDef.Lanes) {
tasks := pc.GetFilteredTasksForLane(nextLane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedPane(nextPane)
pc.pluginConfig.SetSelectedLane(nextLane)
// Select the task at top of viewport (scroll offset) rather than keeping stale index
scrollOffset := pc.pluginConfig.GetScrollOffsetForPane(nextPane)
scrollOffset := pc.pluginConfig.GetScrollOffsetForLane(nextLane)
if scrollOffset >= len(tasks) {
scrollOffset = len(tasks) - 1
}
if scrollOffset < 0 {
scrollOffset = 0
}
pc.pluginConfig.SetSelectedIndexForPane(nextPane, scrollOffset)
pc.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
return true
}
switch direction {
case "left":
nextPane--
nextLane--
case "right":
nextPane++
nextLane++
}
}
return false
@ -249,7 +249,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
}
currentUser := getCurrentUserName(pc.taskStore)
updated, err := plugin.ApplyPaneAction(taskItem, pa.Action, currentUser)
updated, err := plugin.ApplyLaneAction(taskItem, pa.Action, currentUser)
if err != nil {
slog.Error("failed to apply plugin action", "task_id", taskID, "key", string(r), "error", err)
return false
@ -271,13 +271,13 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
return false
}
if pc.pluginDef == nil || len(pc.pluginDef.Panes) == 0 {
if pc.pluginDef == nil || len(pc.pluginDef.Lanes) == 0 {
return false
}
currentPane := pc.pluginConfig.GetSelectedPane()
targetPane := currentPane + offset
if targetPane < 0 || targetPane >= len(pc.pluginDef.Panes) {
currentLane := pc.pluginConfig.GetSelectedLane()
targetLane := currentLane + offset
if targetLane < 0 || targetLane >= len(pc.pluginDef.Lanes) {
return false
}
@ -287,19 +287,19 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
}
currentUser := getCurrentUserName(pc.taskStore)
updated, err := plugin.ApplyPaneAction(taskItem, pc.pluginDef.Panes[targetPane].Action, currentUser)
updated, err := plugin.ApplyLaneAction(taskItem, pc.pluginDef.Lanes[targetLane].Action, currentUser)
if err != nil {
slog.Error("failed to apply pane action", "task_id", taskID, "error", err)
slog.Error("failed to apply lane 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)
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
return false
}
pc.ensureSearchResultIncludesTask(updated)
pc.selectTaskInPane(targetPane, taskID)
pc.selectTaskInLane(targetLane, taskID)
return true
}
@ -313,7 +313,7 @@ func (pc *PluginController) HandleSearch(query string) {
// Save current position
pc.pluginConfig.SavePreSearchState()
// Search across all tasks; pane membership is decided per pane
// Search across all tasks; lane membership is decided per lane
results := pc.taskStore.Search(query, nil)
if len(results) == 0 {
pc.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
@ -321,28 +321,28 @@ func (pc *PluginController) HandleSearch(query string) {
}
pc.pluginConfig.SetSearchResults(results, query)
if pc.selectFirstNonEmptyPane() {
if pc.selectFirstNonEmptyLane() {
return
}
}
// getSelectedTaskID returns the ID of the currently selected task
func (pc *PluginController) getSelectedTaskID() string {
pane := pc.pluginConfig.GetSelectedPane()
tasks := pc.GetFilteredTasksForPane(pane)
idx := pc.pluginConfig.GetSelectedIndexForPane(pane)
lane := pc.pluginConfig.GetSelectedLane()
tasks := pc.GetFilteredTasksForLane(lane)
idx := pc.pluginConfig.GetSelectedIndexForLane(lane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
// GetFilteredTasksForPane returns tasks filtered and sorted for a specific pane.
func (pc *PluginController) GetFilteredTasksForPane(pane int) []*task.Task {
// GetFilteredTasksForLane returns tasks filtered and sorted for a specific lane.
func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
if pc.pluginDef == nil {
return nil
}
if pane < 0 || pane >= len(pc.pluginDef.Panes) {
if lane < 0 || lane >= len(pc.pluginDef.Lanes) {
return nil
}
@ -359,8 +359,8 @@ func (pc *PluginController) GetFilteredTasksForPane(pane int) []*task.Task {
// Apply filter
var filtered []*task.Task
for _, task := range allTasks {
paneFilter := pc.pluginDef.Panes[pane].Filter
if paneFilter == nil || paneFilter.Evaluate(task, now, currentUser) {
laneFilter := pc.pluginDef.Lanes[lane].Filter
if laneFilter == nil || laneFilter.Evaluate(task, now, currentUser) {
filtered = append(filtered, task)
}
}
@ -381,12 +381,12 @@ func (pc *PluginController) GetFilteredTasksForPane(pane int) []*task.Task {
return filtered
}
func (pc *PluginController) selectTaskInPane(pane int, taskID string) {
if pane < 0 || pane >= len(pc.pluginDef.Panes) {
func (pc *PluginController) selectTaskInLane(lane int, taskID string) {
if lane < 0 || lane >= len(pc.pluginDef.Lanes) {
return
}
tasks := pc.GetFilteredTasksForPane(pane)
tasks := pc.GetFilteredTasksForLane(lane)
targetIndex := 0
for i, task := range tasks {
if task.ID == taskID {
@ -395,33 +395,33 @@ func (pc *PluginController) selectTaskInPane(pane int, taskID string) {
}
}
pc.pluginConfig.SetSelectedPane(pane)
pc.pluginConfig.SetSelectedIndexForPane(pane, targetIndex)
pc.pluginConfig.SetSelectedLane(lane)
pc.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
}
func (pc *PluginController) selectFirstNonEmptyPane() bool {
for pane := range pc.pluginDef.Panes {
tasks := pc.GetFilteredTasksForPane(pane)
func (pc *PluginController) selectFirstNonEmptyLane() bool {
for lane := range pc.pluginDef.Lanes {
tasks := pc.GetFilteredTasksForLane(lane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedPaneAndIndex(pane, 0)
pc.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
return true
}
}
return false
}
func (pc *PluginController) EnsureFirstNonEmptyPaneSelection() bool {
func (pc *PluginController) EnsureFirstNonEmptyLaneSelection() bool {
if pc.pluginDef == nil {
return false
}
currentPane := pc.pluginConfig.GetSelectedPane()
if currentPane >= 0 && currentPane < len(pc.pluginDef.Panes) {
tasks := pc.GetFilteredTasksForPane(currentPane)
currentLane := pc.pluginConfig.GetSelectedLane()
if currentLane >= 0 && currentLane < len(pc.pluginDef.Lanes) {
tasks := pc.GetFilteredTasksForLane(currentLane)
if len(tasks) > 0 {
return false
}
}
return pc.selectFirstNonEmptyPane()
return pc.selectFirstNonEmptyLane()
}
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {

View file

@ -11,7 +11,7 @@ import (
"github.com/boolean-maybe/tiki/task"
)
func TestEnsureFirstNonEmptyPaneSelectionSelectsFirstTask(t *testing.T) {
func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
taskStore := store.NewInMemoryStore()
if err := taskStore.CreateTask(&task.Task{
ID: "T-1",
@ -43,28 +43,28 @@ func TestEnsureFirstNonEmptyPaneSelectionSelectsFirstTask(t *testing.T) {
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
Lanes: []plugin.TikiLane{
{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)
pluginConfig.SetLaneLayout([]int{1, 1})
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 1)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc.EnsureFirstNonEmptyPaneSelection()
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
if pluginConfig.GetSelectedLane() != 1 {
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
}
if pluginConfig.GetSelectedIndexForPane(1) != 0 {
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForPane(1))
if pluginConfig.GetSelectedIndexForLane(1) != 0 {
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForLane(1))
}
}
func TestEnsureFirstNonEmptyPaneSelectionKeepsCurrentPane(t *testing.T) {
func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
taskStore := store.NewInMemoryStore()
if err := taskStore.CreateTask(&task.Task{
ID: "T-1",
@ -84,28 +84,28 @@ func TestEnsureFirstNonEmptyPaneSelectionKeepsCurrentPane(t *testing.T) {
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
Lanes: []plugin.TikiLane{
{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)
pluginConfig.SetLaneLayout([]int{1, 1})
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 0)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc.EnsureFirstNonEmptyPaneSelection()
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
if pluginConfig.GetSelectedLane() != 1 {
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
}
if pluginConfig.GetSelectedIndexForPane(1) != 0 {
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForPane(1))
if pluginConfig.GetSelectedIndexForLane(1) != 0 {
t.Fatalf("expected selected index 0, got %d", pluginConfig.GetSelectedIndexForLane(1))
}
}
func TestEnsureFirstNonEmptyPaneSelectionNoTasks(t *testing.T) {
func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
taskStore := store.NewInMemoryStore()
emptyFilter, err := filter.ParseFilter("status = 'done'")
if err != nil {
@ -116,30 +116,30 @@ func TestEnsureFirstNonEmptyPaneSelectionNoTasks(t *testing.T) {
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
Lanes: []plugin.TikiLane{
{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)
pluginConfig.SetLaneLayout([]int{1, 1})
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 2)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
pc.EnsureFirstNonEmptyPaneSelection()
pc.EnsureFirstNonEmptyLaneSelection()
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
if pluginConfig.GetSelectedLane() != 1 {
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
}
if pluginConfig.GetSelectedIndexForPane(1) != 2 {
t.Fatalf("expected selected index 2, got %d", pluginConfig.GetSelectedIndexForPane(1))
if pluginConfig.GetSelectedIndexForLane(1) != 2 {
t.Fatalf("expected selected index 2, got %d", pluginConfig.GetSelectedIndexForLane(1))
}
}
func TestPaneSwitchSelectsTopOfViewport(t *testing.T) {
func TestLaneSwitchSelectsTopOfViewport(t *testing.T) {
taskStore := store.NewInMemoryStore()
// Create tasks for two panes
// Create tasks for two lanes
for i := 1; i <= 10; i++ {
status := task.StatusReady
if i > 5 {
@ -168,40 +168,40 @@ func TestPaneSwitchSelectsTopOfViewport(t *testing.T) {
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
Lanes: []plugin.TikiLane{
{Name: "Ready", Columns: 1, Filter: readyFilter},
{Name: "InProgress", Columns: 1, Filter: inProgressFilter},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1, 1})
pluginConfig.SetLaneLayout([]int{1, 1})
// Start in pane 0 (Ready), with selection at index 2
pluginConfig.SetSelectedPane(0)
pluginConfig.SetSelectedIndexForPane(0, 2)
// Start in lane 0 (Ready), with selection at index 2
pluginConfig.SetSelectedLane(0)
pluginConfig.SetSelectedIndexForLane(0, 2)
// Simulate that pane 1 has been scrolled to offset 3
pluginConfig.SetScrollOffsetForPane(1, 3)
// Simulate that lane 1 has been scrolled to offset 3
pluginConfig.SetScrollOffsetForLane(1, 3)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
// Navigate right to pane 1
// Navigate right to lane 1
pc.HandleAction(ActionNavRight)
// Should be in pane 1
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
// Should be in lane 1
if pluginConfig.GetSelectedLane() != 1 {
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
}
// Selection should be at scroll offset (top of viewport), not stale index
if pluginConfig.GetSelectedIndexForPane(1) != 3 {
t.Errorf("expected selection at scroll offset 3, got %d", pluginConfig.GetSelectedIndexForPane(1))
if pluginConfig.GetSelectedIndexForLane(1) != 3 {
t.Errorf("expected selection at scroll offset 3, got %d", pluginConfig.GetSelectedIndexForLane(1))
}
}
func TestPaneSwitchClampsScrollOffsetToTaskCount(t *testing.T) {
func TestLaneSwitchClampsScrollOffsetToTaskCount(t *testing.T) {
taskStore := store.NewInMemoryStore()
// Create 3 tasks in pane 1 only
// Create 3 tasks in lane 1 only
for i := 1; i <= 3; i++ {
if err := taskStore.CreateTask(&task.Task{
ID: fmt.Sprintf("T-%d", i),
@ -213,7 +213,7 @@ func TestPaneSwitchClampsScrollOffsetToTaskCount(t *testing.T) {
}
}
// Pane 0 is empty, pane 1 has 3 tasks
// Lane 0 is empty, lane 1 has 3 tasks
emptyFilter, err := filter.ParseFilter("status = 'ready'")
if err != nil {
t.Fatalf("parse filter: %v", err)
@ -227,37 +227,37 @@ func TestPaneSwitchClampsScrollOffsetToTaskCount(t *testing.T) {
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
Lanes: []plugin.TikiLane{
{Name: "Empty", Columns: 1, Filter: emptyFilter},
{Name: "InProgress", Columns: 1, Filter: inProgressFilter},
},
}
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1, 1})
pluginConfig.SetLaneLayout([]int{1, 1})
// Start in pane 1
pluginConfig.SetSelectedPane(1)
pluginConfig.SetSelectedIndexForPane(1, 0)
// Start in lane 1
pluginConfig.SetSelectedLane(1)
pluginConfig.SetSelectedIndexForLane(1, 0)
// Set a stale scroll offset that exceeds the task count
pluginConfig.SetScrollOffsetForPane(1, 10)
pluginConfig.SetScrollOffsetForLane(1, 10)
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil)
// Navigate left (to empty pane, will skip to... well, nowhere)
// Navigate left (to empty lane, will skip to... well, nowhere)
// Then try to go right from a fresh setup
pluginConfig.SetSelectedPane(0)
pluginConfig.SetScrollOffsetForPane(1, 10) // stale offset > task count
pluginConfig.SetSelectedLane(0)
pluginConfig.SetScrollOffsetForLane(1, 10) // stale offset > task count
pc.HandleAction(ActionNavRight)
// Should be in pane 1
if pluginConfig.GetSelectedPane() != 1 {
t.Fatalf("expected selected pane 1, got %d", pluginConfig.GetSelectedPane())
// Should be in lane 1
if pluginConfig.GetSelectedLane() != 1 {
t.Fatalf("expected selected lane 1, got %d", pluginConfig.GetSelectedLane())
}
// Selection should be clamped to last valid index (2, since 3 tasks)
selectedIdx := pluginConfig.GetSelectedIndexForPane(1)
selectedIdx := pluginConfig.GetSelectedIndexForLane(1)
if selectedIdx < 0 || selectedIdx >= 3 {
t.Errorf("expected selection clamped to valid range [0,2], got %d", selectedIdx)
}

View file

@ -12,13 +12,13 @@ import (
"github.com/spf13/viper"
)
func TestPluginView_MoveTaskAppliesPaneAction(t *testing.T) {
func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) {
originalPlugins := viper.Get("plugins")
viper.Set("plugins", []plugin.PluginRef{
{
Name: "ActionTest",
Key: "F4",
Panes: []plugin.PluginPaneConfig{
Lanes: []plugin.PluginLaneConfig{
{
Name: "Backlog",
Columns: 1,

View file

@ -126,8 +126,8 @@ func TestTaskDeletion_SelectionMoves(t *testing.T) {
}
}
// TestTaskDeletion_LastTaskInPane verifies deleting last task resets selection
func TestTaskDeletion_LastTaskInPane(t *testing.T) {
// TestTaskDeletion_LastTaskInLane verifies deleting last task resets selection
func TestTaskDeletion_LastTaskInLane(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
@ -136,7 +136,7 @@ func TestTaskDeletion_LastTaskInPane(t *testing.T) {
t.Fatalf("failed to load plugins: %v", err)
}
// Create only one task in todo pane
// Create only one task in todo lane
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Only Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -175,7 +175,7 @@ func TestTaskDeletion_LastTaskInPane(t *testing.T) {
t.Errorf("selection should reset to 0 after deleting last task, got %d", kanbanConfig.GetSelectedIndex())
}
// Verify no crash occurred (pane is empty)
// Verify no crash occurred (lane is empty)
// This is implicit - if we got here without panic, test passes
}
@ -232,8 +232,8 @@ func TestTaskDeletion_MultipleSequential(t *testing.T) {
}
}
// TestTaskDeletion_FromDifferentPane verifies deleting from non-todo pane
func TestTaskDeletion_FromDifferentPane(t *testing.T) {
// TestTaskDeletion_FromDifferentLane verifies deleting from non-todo lane
func TestTaskDeletion_FromDifferentLane(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
@ -242,7 +242,7 @@ func TestTaskDeletion_FromDifferentPane(t *testing.T) {
t.Fatalf("failed to load plugins: %v", err)
}
// Create task in in_progress pane
// Create task in in_progress lane
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)
}
@ -254,14 +254,14 @@ func TestTaskDeletion_FromDifferentPane(t *testing.T) {
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
// Move to in_progress pane (Right arrow)
// Move to in_progress lane (Right arrow)
ta.SendKey(tcell.KeyRight, 0, tcell.ModNone)
// Verify TIKI-1 visible
found, _, _ := ta.FindText("TIKI-1")
if !found {
ta.DumpScreen()
t.Fatalf("TIKI-1 should be visible in in_progress pane")
t.Fatalf("TIKI-1 should be visible in in_progress lane")
}
// Delete task
@ -326,8 +326,8 @@ func TestTaskDeletion_CannotDeleteFromTaskDetail(t *testing.T) {
}
}
// TestTaskDeletion_WithMultiplePanes verifies deletion doesn't affect other panes
func TestTaskDeletion_WithMultiplePanes(t *testing.T) {
// TestTaskDeletion_WithMultipleLanes verifies deletion doesn't affect other lanes
func TestTaskDeletion_WithMultipleLanes(t *testing.T) {
ta := testutil.NewTestApp(t)
defer ta.Cleanup()
@ -336,7 +336,7 @@ func TestTaskDeletion_WithMultiplePanes(t *testing.T) {
t.Fatalf("failed to load plugins: %v", err)
}
// Create tasks in different panes
// Create tasks in different lanes
if err := testutil.CreateTestTask(ta.TaskDir, "TIKI-1", "Todo Task", taskpkg.StatusReady, taskpkg.TypeStory); err != nil {
t.Fatalf("failed to create task: %v", err)
}
@ -354,7 +354,7 @@ func TestTaskDeletion_WithMultiplePanes(t *testing.T) {
ta.NavController.PushView(model.MakePluginViewID("Kanban"), nil)
ta.Draw()
// Delete TIKI-1 from todo pane
// Delete TIKI-1 from todo lane
ta.SendKey(tcell.KeyRune, 'd', tcell.ModNone)
// Reload
@ -367,11 +367,11 @@ func TestTaskDeletion_WithMultiplePanes(t *testing.T) {
t.Errorf("TIKI-1 should be deleted")
}
// Verify TIKI-2 and TIKI-3 still exist (in other panes)
// Verify TIKI-2 and TIKI-3 still exist (in other lanes)
if ta.TaskStore.GetTask("TIKI-2") == nil {
t.Errorf("TIKI-2 (in different pane) should still exist")
t.Errorf("TIKI-2 (in different lane) should still exist")
}
if ta.TaskStore.GetTask("TIKI-3") == nil {
t.Errorf("TIKI-3 (in different pane) should still exist")
t.Errorf("TIKI-3 (in different lane) should still exist")
}
}

View file

@ -382,11 +382,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 panes)
// Find the task on board (may need to navigate between lanes)
taskID := fmt.Sprintf("TIKI-%d", i+1)
// Navigate to correct pane based on status
// For simplicity, we'll just open first task in todo pane for this test
// Navigate to correct lane based on status
// For simplicity, we'll just open first task in todo lane for this test
if status == taskpkg.StatusReady {
ta.SendKey(tcell.KeyEnter, 0, tcell.ModNone)

View file

@ -50,11 +50,11 @@ func BuildPluginConfigsAndDefs(plugins []plugin.Plugin) (map[string]*model.Plugi
if tp.ViewMode == "expanded" {
pc.SetViewMode("expanded")
}
columns := make([]int, len(tp.Panes))
for i, pane := range tp.Panes {
columns[i] = pane.Columns
columns := make([]int, len(tp.Lanes))
for i, lane := range tp.Lanes {
columns[i] = lane.Columns
}
pc.SetPaneLayout(columns)
pc.SetLaneLayout(columns)
}
pluginConfigs[p.GetName()] = pc

View file

@ -23,11 +23,11 @@ type PluginSelectionListener func()
type PluginConfig struct {
mu sync.RWMutex
pluginName string
selectedPane int
selectedLane int
selectedIndices []int
paneColumns []int
scrollOffsets []int // per-pane viewport position (top visible row)
preSearchPane int
laneColumns []int
scrollOffsets []int // per-lane viewport position (top visible row)
preSearchLane int
preSearchIndices []int
viewMode ViewMode // compact or expanded display
configIndex int // index in config.yaml plugins array (-1 if embedded/not in config)
@ -45,7 +45,7 @@ func NewPluginConfig(name string) *PluginConfig {
listeners: make(map[int]PluginSelectionListener),
nextListenerID: 1, // Start at 1 to avoid conflict with zero-value sentinel
}
pc.SetPaneLayout([]int{4})
pc.SetLaneLayout([]int{4})
return pc
}
@ -61,103 +61,103 @@ func (pc *PluginConfig) GetPluginName() string {
return pc.pluginName
}
// SetPaneLayout configures pane columns and resets selection state as needed.
func (pc *PluginConfig) SetPaneLayout(columns []int) {
// SetLaneLayout configures lane columns and resets selection state as needed.
func (pc *PluginConfig) SetLaneLayout(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))
pc.scrollOffsets = ensureSelectionLength(pc.scrollOffsets, len(pc.paneColumns))
pc.laneColumns = normalizeLaneColumns(columns)
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.laneColumns))
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.laneColumns))
pc.scrollOffsets = ensureSelectionLength(pc.scrollOffsets, len(pc.laneColumns))
if pc.selectedPane < 0 || pc.selectedPane >= len(pc.paneColumns) {
pc.selectedPane = 0
if pc.selectedLane < 0 || pc.selectedLane >= len(pc.laneColumns) {
pc.selectedLane = 0
}
}
// GetPaneCount returns the number of panes.
func (pc *PluginConfig) GetPaneCount() int {
// GetLaneCount returns the number of lanes.
func (pc *PluginConfig) GetLaneCount() int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return len(pc.paneColumns)
return len(pc.laneColumns)
}
// GetSelectedPane returns the selected pane index.
func (pc *PluginConfig) GetSelectedPane() int {
// GetSelectedLane returns the selected lane index.
func (pc *PluginConfig) GetSelectedLane() int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.selectedPane
return pc.selectedLane
}
// SetSelectedPane sets the selected pane index.
func (pc *PluginConfig) SetSelectedPane(pane int) {
// SetSelectedLane sets the selected lane index.
func (pc *PluginConfig) SetSelectedLane(lane int) {
pc.mu.Lock()
if pane < 0 || pane >= len(pc.paneColumns) {
if lane < 0 || lane >= len(pc.laneColumns) {
pc.mu.Unlock()
return
}
changed := pc.selectedPane != pane
pc.selectedPane = pane
changed := pc.selectedLane != lane
pc.selectedLane = lane
pc.mu.Unlock()
if changed {
pc.notifyListeners()
}
}
// GetSelectedIndex returns the selected task index for the current pane.
// GetSelectedIndex returns the selected task index for the current lane.
func (pc *PluginConfig) GetSelectedIndex() int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.indexForPane(pc.selectedPane)
return pc.indexForLane(pc.selectedLane)
}
// GetSelectedIndexForPane returns the selected index for a pane.
func (pc *PluginConfig) GetSelectedIndexForPane(pane int) int {
// GetSelectedIndexForLane returns the selected index for a lane.
func (pc *PluginConfig) GetSelectedIndexForLane(lane int) int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.indexForPane(pane)
return pc.indexForLane(lane)
}
// SetSelectedIndex sets the selected task index for the current pane.
// SetSelectedIndex sets the selected task index for the current lane.
func (pc *PluginConfig) SetSelectedIndex(idx int) {
pc.mu.Lock()
pc.setIndexForPane(pc.selectedPane, idx)
pc.setIndexForLane(pc.selectedLane, idx)
pc.mu.Unlock()
pc.notifyListeners()
}
// SetSelectedIndexForPane sets the selected index for a specific pane.
func (pc *PluginConfig) SetSelectedIndexForPane(pane int, idx int) {
// SetSelectedIndexForLane sets the selected index for a specific lane.
func (pc *PluginConfig) SetSelectedIndexForLane(lane int, idx int) {
pc.mu.Lock()
pc.setIndexForPane(pane, idx)
pc.setIndexForLane(lane, idx)
pc.mu.Unlock()
pc.notifyListeners()
}
// GetScrollOffsetForPane returns the scroll offset (top visible row) for a pane.
func (pc *PluginConfig) GetScrollOffsetForPane(pane int) int {
// GetScrollOffsetForLane returns the scroll offset (top visible row) for a lane.
func (pc *PluginConfig) GetScrollOffsetForLane(lane int) int {
pc.mu.RLock()
defer pc.mu.RUnlock()
if pane < 0 || pane >= len(pc.scrollOffsets) {
if lane < 0 || lane >= len(pc.scrollOffsets) {
return 0
}
return pc.scrollOffsets[pane]
return pc.scrollOffsets[lane]
}
// SetScrollOffsetForPane sets the scroll offset for a specific pane.
func (pc *PluginConfig) SetScrollOffsetForPane(pane int, offset int) {
// SetScrollOffsetForLane sets the scroll offset for a specific lane.
func (pc *PluginConfig) SetScrollOffsetForLane(lane int, offset int) {
pc.mu.Lock()
defer pc.mu.Unlock()
if pane < 0 || pane >= len(pc.scrollOffsets) {
if lane < 0 || lane >= len(pc.scrollOffsets) {
return
}
pc.scrollOffsets[pane] = offset
pc.scrollOffsets[lane] = offset
}
func (pc *PluginConfig) SetSelectedPaneAndIndex(pane int, idx int) {
func (pc *PluginConfig) SetSelectedLaneAndIndex(lane int, idx int) {
pc.mu.Lock()
if pane < 0 || pane >= len(pc.selectedIndices) {
if lane < 0 || lane >= len(pc.selectedIndices) {
pc.mu.Unlock()
return
}
@ -165,9 +165,9 @@ func (pc *PluginConfig) SetSelectedPaneAndIndex(pane int, idx int) {
pc.mu.Unlock()
return
}
changed := pc.selectedPane != pane || pc.selectedIndices[pane] != idx
pc.selectedPane = pane
pc.selectedIndices[pane] = idx
changed := pc.selectedLane != lane || pc.selectedIndices[lane] != idx
pc.selectedLane = lane
pc.selectedIndices[lane] = idx
pc.mu.Unlock()
if changed {
@ -175,11 +175,11 @@ func (pc *PluginConfig) SetSelectedPaneAndIndex(pane int, idx int) {
}
}
// GetColumnsForPane returns the number of grid columns for a pane.
func (pc *PluginConfig) GetColumnsForPane(pane int) int {
// GetColumnsForLane returns the number of grid columns for a lane.
func (pc *PluginConfig) GetColumnsForLane(lane int) int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.columnsForPane(pane)
return pc.columnsForLane(lane)
}
// AddSelectionListener registers a callback for selection changes
@ -212,16 +212,16 @@ func (pc *PluginConfig) notifyListeners() {
}
}
// MoveSelection moves selection in a direction within the current pane.
// MoveSelection moves selection in a direction within the current lane.
func (pc *PluginConfig) MoveSelection(direction string, taskCount int) bool {
if taskCount == 0 {
return false
}
pc.mu.Lock()
pane := pc.selectedPane
columns := pc.columnsForPane(pane)
oldIndex := pc.indexForPane(pane)
lane := pc.selectedLane
columns := pc.columnsForLane(lane)
oldIndex := pc.indexForLane(lane)
row := oldIndex / columns
col := oldIndex % columns
numRows := (taskCount + columns - 1) / columns
@ -229,24 +229,24 @@ func (pc *PluginConfig) MoveSelection(direction string, taskCount int) bool {
switch direction {
case "up":
if row > 0 {
pc.setIndexForPane(pane, oldIndex-columns)
pc.setIndexForLane(lane, oldIndex-columns)
}
case "down":
newIdx := oldIndex + columns
if row < numRows-1 && newIdx < taskCount {
pc.setIndexForPane(pane, newIdx)
pc.setIndexForLane(lane, newIdx)
}
case "left":
if col > 0 {
pc.setIndexForPane(pane, oldIndex-1)
pc.setIndexForLane(lane, oldIndex-1)
}
case "right":
if col < columns-1 && oldIndex+1 < taskCount {
pc.setIndexForPane(pane, oldIndex+1)
pc.setIndexForLane(lane, oldIndex+1)
}
}
moved := pc.indexForPane(pane) != oldIndex
moved := pc.indexForLane(lane) != oldIndex
pc.mu.Unlock()
if moved {
@ -255,16 +255,16 @@ func (pc *PluginConfig) MoveSelection(direction string, taskCount int) bool {
return moved
}
// ClampSelection ensures selection is within bounds for the current pane.
// ClampSelection ensures selection is within bounds for the current lane.
func (pc *PluginConfig) ClampSelection(taskCount int) {
pc.mu.Lock()
pane := pc.selectedPane
index := pc.indexForPane(pane)
lane := pc.selectedLane
index := pc.indexForLane(lane)
if index >= taskCount {
pc.setIndexForPane(pane, taskCount-1)
pc.setIndexForLane(lane, taskCount-1)
}
if pc.indexForPane(pane) < 0 {
pc.setIndexForPane(pane, 0)
if pc.indexForLane(lane) < 0 {
pc.setIndexForLane(lane, 0)
}
pc.mu.Unlock()
}
@ -312,10 +312,10 @@ func (pc *PluginConfig) SetViewMode(mode string) {
// SavePreSearchState saves current selection for later restoration
func (pc *PluginConfig) SavePreSearchState() {
pc.mu.Lock()
pc.preSearchPane = pc.selectedPane
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.paneColumns))
pc.preSearchLane = pc.selectedLane
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.laneColumns))
copy(pc.preSearchIndices, pc.selectedIndices)
selectedIndex := pc.indexForPane(pc.selectedPane)
selectedIndex := pc.indexForLane(pc.selectedLane)
pc.mu.Unlock()
pc.searchState.SavePreSearchState(selectedIndex)
}
@ -330,13 +330,13 @@ func (pc *PluginConfig) SetSearchResults(results []task.SearchResult, query stri
func (pc *PluginConfig) ClearSearchResults() {
pc.searchState.ClearSearchResults()
pc.mu.Lock()
if len(pc.preSearchIndices) == len(pc.paneColumns) {
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.paneColumns))
if len(pc.preSearchIndices) == len(pc.laneColumns) {
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.laneColumns))
copy(pc.selectedIndices, pc.preSearchIndices)
pc.selectedPane = pc.preSearchPane
pc.selectedLane = pc.preSearchLane
} else if len(pc.selectedIndices) > 0 {
pc.selectedPane = 0
pc.setIndexForPane(0, 0)
pc.selectedLane = 0
pc.setIndexForLane(0, 0)
}
pc.mu.Unlock()
pc.notifyListeners()
@ -357,40 +357,40 @@ func (pc *PluginConfig) GetSearchQuery() string {
return pc.searchState.GetSearchQuery()
}
func (pc *PluginConfig) indexForPane(pane int) int {
func (pc *PluginConfig) indexForLane(lane 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))
if lane < 0 || lane >= len(pc.selectedIndices) {
slog.Warn("lane index out of range", "lane", lane, "count", len(pc.selectedIndices))
return 0
}
return pc.selectedIndices[pane]
return pc.selectedIndices[lane]
}
func (pc *PluginConfig) setIndexForPane(pane int, idx int) {
func (pc *PluginConfig) setIndexForLane(lane 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))
if lane < 0 || lane >= len(pc.selectedIndices) {
slog.Warn("lane index out of range", "lane", lane, "count", len(pc.selectedIndices))
return
}
pc.selectedIndices[pane] = idx
pc.selectedIndices[lane] = idx
}
func (pc *PluginConfig) columnsForPane(pane int) int {
if len(pc.paneColumns) == 0 {
func (pc *PluginConfig) columnsForLane(lane int) int {
if len(pc.laneColumns) == 0 {
return 1
}
if pane < 0 || pane >= len(pc.paneColumns) {
slog.Warn("pane columns out of range", "pane", pane, "count", len(pc.paneColumns))
if lane < 0 || lane >= len(pc.laneColumns) {
slog.Warn("lane columns out of range", "lane", lane, "count", len(pc.laneColumns))
return 1
}
return pc.paneColumns[pane]
return pc.laneColumns[lane]
}
func normalizePaneColumns(columns []int) []int {
func normalizeLaneColumns(columns []int) []int {
if len(columns) == 0 {
return []int{1}
}

View file

@ -23,8 +23,8 @@ func TestNewPluginConfig(t *testing.T) {
t.Errorf("initial GetSelectedIndex() = %d, want 0", pc.GetSelectedIndex())
}
if pc.GetColumnsForPane(0) != 4 {
t.Errorf("GetColumnsForPane(0) = %d, want 4", pc.GetColumnsForPane(0))
if pc.GetColumnsForLane(0) != 4 {
t.Errorf("GetColumnsForLane(0) = %d, want 4", pc.GetColumnsForLane(0))
}
if pc.GetViewMode() != ViewModeCompact {
@ -628,72 +628,72 @@ func TestPluginConfig_GridNavigation_AllCorners(t *testing.T) {
func TestPluginConfig_ScrollOffset(t *testing.T) {
pc := NewPluginConfig("test")
pc.SetPaneLayout([]int{1, 1, 1}) // 3 panes
pc.SetLaneLayout([]int{1, 1, 1}) // 3 lanes
// Initial scroll offsets should be 0
for pane := 0; pane < 3; pane++ {
if offset := pc.GetScrollOffsetForPane(pane); offset != 0 {
t.Errorf("initial GetScrollOffsetForPane(%d) = %d, want 0", pane, offset)
for lane := 0; lane < 3; lane++ {
if offset := pc.GetScrollOffsetForLane(lane); offset != 0 {
t.Errorf("initial GetScrollOffsetForLane(%d) = %d, want 0", lane, offset)
}
}
// Set scroll offset for pane 1
pc.SetScrollOffsetForPane(1, 5)
if offset := pc.GetScrollOffsetForPane(1); offset != 5 {
t.Errorf("GetScrollOffsetForPane(1) = %d, want 5", offset)
pc.SetScrollOffsetForLane(1, 5)
if offset := pc.GetScrollOffsetForLane(1); offset != 5 {
t.Errorf("GetScrollOffsetForLane(1) = %d, want 5", offset)
}
// Other panes should be unaffected
if offset := pc.GetScrollOffsetForPane(0); offset != 0 {
t.Errorf("GetScrollOffsetForPane(0) = %d, want 0", offset)
if offset := pc.GetScrollOffsetForLane(0); offset != 0 {
t.Errorf("GetScrollOffsetForLane(0) = %d, want 0", offset)
}
if offset := pc.GetScrollOffsetForPane(2); offset != 0 {
t.Errorf("GetScrollOffsetForPane(2) = %d, want 0", offset)
if offset := pc.GetScrollOffsetForLane(2); offset != 0 {
t.Errorf("GetScrollOffsetForLane(2) = %d, want 0", offset)
}
// Set scroll offset for pane 2
pc.SetScrollOffsetForPane(2, 10)
if offset := pc.GetScrollOffsetForPane(2); offset != 10 {
t.Errorf("GetScrollOffsetForPane(2) = %d, want 10", offset)
pc.SetScrollOffsetForLane(2, 10)
if offset := pc.GetScrollOffsetForLane(2); offset != 10 {
t.Errorf("GetScrollOffsetForLane(2) = %d, want 10", offset)
}
}
func TestPluginConfig_ScrollOffset_OutOfBounds(t *testing.T) {
pc := NewPluginConfig("test")
pc.SetPaneLayout([]int{1, 1}) // 2 panes
pc.SetLaneLayout([]int{1, 1}) // 2 lanes
// Getting out of bounds should return 0
if offset := pc.GetScrollOffsetForPane(-1); offset != 0 {
t.Errorf("GetScrollOffsetForPane(-1) = %d, want 0", offset)
if offset := pc.GetScrollOffsetForLane(-1); offset != 0 {
t.Errorf("GetScrollOffsetForLane(-1) = %d, want 0", offset)
}
if offset := pc.GetScrollOffsetForPane(5); offset != 0 {
t.Errorf("GetScrollOffsetForPane(5) = %d, want 0", offset)
if offset := pc.GetScrollOffsetForLane(5); offset != 0 {
t.Errorf("GetScrollOffsetForLane(5) = %d, want 0", offset)
}
// Setting out of bounds should be a no-op (not panic)
pc.SetScrollOffsetForPane(-1, 10)
pc.SetScrollOffsetForPane(5, 10)
pc.SetScrollOffsetForLane(-1, 10)
pc.SetScrollOffsetForLane(5, 10)
// Valid panes should still be 0
if offset := pc.GetScrollOffsetForPane(0); offset != 0 {
t.Errorf("GetScrollOffsetForPane(0) = %d after out-of-bounds set, want 0", offset)
if offset := pc.GetScrollOffsetForLane(0); offset != 0 {
t.Errorf("GetScrollOffsetForLane(0) = %d after out-of-bounds set, want 0", offset)
}
}
func TestPluginConfig_ScrollOffset_PreservedOnLayoutChange(t *testing.T) {
pc := NewPluginConfig("test")
pc.SetPaneLayout([]int{1, 1, 1})
pc.SetLaneLayout([]int{1, 1, 1})
// Set scroll offsets
pc.SetScrollOffsetForPane(0, 3)
pc.SetScrollOffsetForPane(1, 7)
pc.SetScrollOffsetForLane(0, 3)
pc.SetScrollOffsetForLane(1, 7)
// Change layout to same size - should preserve offsets
pc.SetPaneLayout([]int{2, 2, 2})
if offset := pc.GetScrollOffsetForPane(0); offset != 3 {
t.Errorf("GetScrollOffsetForPane(0) after same-size layout change = %d, want 3", offset)
pc.SetLaneLayout([]int{2, 2, 2})
if offset := pc.GetScrollOffsetForLane(0); offset != 3 {
t.Errorf("GetScrollOffsetForLane(0) after same-size layout change = %d, want 3", offset)
}
if offset := pc.GetScrollOffsetForPane(1); offset != 7 {
t.Errorf("GetScrollOffsetForPane(1) after same-size layout change = %d, want 7", offset)
if offset := pc.GetScrollOffsetForLane(1); offset != 7 {
t.Errorf("GetScrollOffsetForLane(1) after same-size layout change = %d, want 7", offset)
}
}

View file

@ -8,13 +8,13 @@ import (
"github.com/boolean-maybe/tiki/task"
)
// PaneAction represents parsed pane actions.
type PaneAction struct {
Ops []PaneActionOp
// LaneAction represents parsed lane actions.
type LaneAction struct {
Ops []LaneActionOp
}
// PaneActionOp represents a single action operation.
type PaneActionOp struct {
// LaneActionOp represents a single action operation.
type LaneActionOp struct {
Field ActionField
Operator ActionOperator
StrValue string
@ -43,103 +43,103 @@ const (
ActionOperatorRemove ActionOperator = "-="
)
// ParsePaneAction parses a pane action string into operations.
func ParsePaneAction(input string) (PaneAction, error) {
// ParseLaneAction parses a lane action string into operations.
func ParseLaneAction(input string) (LaneAction, error) {
input = strings.TrimSpace(input)
if input == "" {
return PaneAction{}, nil
return LaneAction{}, nil
}
parts, err := splitTopLevelCommas(input)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
ops := make([]PaneActionOp, 0, len(parts))
ops := make([]LaneActionOp, 0, len(parts))
for _, part := range parts {
if part == "" {
return PaneAction{}, fmt.Errorf("empty action segment")
return LaneAction{}, fmt.Errorf("empty action segment")
}
field, op, value, err := parseActionSegment(part)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
switch field {
case ActionFieldTags:
if op == ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("tags action only supports += or -=")
return LaneAction{}, fmt.Errorf("tags action only supports += or -=")
}
tags, err := parseTagsValue(value)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
ops = append(ops, PaneActionOp{
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
Tags: tags,
})
case ActionFieldPriority, ActionFieldPoints:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
intValue, err := parseIntValue(value)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
if field == ActionFieldPriority && !task.IsValidPriority(intValue) {
return PaneAction{}, fmt.Errorf("priority value out of range: %d", intValue)
return LaneAction{}, 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)
return LaneAction{}, fmt.Errorf("points value out of range: %d", intValue)
}
ops = append(ops, PaneActionOp{
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
IntValue: intValue,
})
case ActionFieldStatus:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
if _, ok := task.ParseStatus(strValue); !ok {
return PaneAction{}, fmt.Errorf("invalid status value %q", strValue)
return LaneAction{}, fmt.Errorf("invalid status value %q", strValue)
}
ops = append(ops, PaneActionOp{
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
case ActionFieldType:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
if _, ok := task.ParseType(strValue); !ok {
return PaneAction{}, fmt.Errorf("invalid type value %q", strValue)
return LaneAction{}, fmt.Errorf("invalid type value %q", strValue)
}
ops = append(ops, PaneActionOp{
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
})
default:
if op != ActionOperatorAssign {
return PaneAction{}, fmt.Errorf("%s action only supports =", field)
return LaneAction{}, fmt.Errorf("%s action only supports =", field)
}
strValue, err := parseStringValue(value)
if err != nil {
return PaneAction{}, err
return LaneAction{}, err
}
ops = append(ops, PaneActionOp{
ops = append(ops, LaneActionOp{
Field: field,
Operator: op,
StrValue: strValue,
@ -147,11 +147,11 @@ func ParsePaneAction(input string) (PaneAction, error) {
}
}
return PaneAction{Ops: ops}, nil
return LaneAction{Ops: ops}, nil
}
// ApplyPaneAction applies a parsed action to a task clone.
func ApplyPaneAction(src *task.Task, action PaneAction, currentUser string) (*task.Task, error) {
// ApplyLaneAction applies a parsed action to a task clone.
func ApplyLaneAction(src *task.Task, action LaneAction, currentUser string) (*task.Task, error) {
if src == nil {
return nil, fmt.Errorf("task is nil")
}

View file

@ -74,8 +74,8 @@ func TestSplitTopLevelCommas(t *testing.T) {
}
}
func TestParsePaneAction(t *testing.T) {
action, err := ParsePaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review']")
func TestParseLaneAction(t *testing.T) {
action, err := ParseLaneAction("status=done, type=bug, priority=2, points=3, assignee='Alice', tags+=[frontend,'needs review']")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -123,7 +123,7 @@ func TestParsePaneAction(t *testing.T) {
}
}
func TestParsePaneAction_Errors(t *testing.T) {
func TestParseLaneAction_Errors(t *testing.T) {
tests := []struct {
name string
input string
@ -183,7 +183,7 @@ func TestParsePaneAction_Errors(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ParsePaneAction(tc.input)
_, err := ParseLaneAction(tc.input)
if err == nil {
t.Fatalf("expected error containing %q", tc.wantErr)
}
@ -194,7 +194,7 @@ func TestParsePaneAction_Errors(t *testing.T) {
}
}
func TestApplyPaneAction(t *testing.T) {
func TestApplyLaneAction(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
@ -206,12 +206,12 @@ func TestApplyPaneAction(t *testing.T) {
Assignee: "Bob",
}
action, err := ParsePaneAction("status=done, type=bug, priority=2, points=3, assignee=Alice, tags+=[moved]")
action, err := ParseLaneAction("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, "")
updated, err := ApplyLaneAction(base, action, "")
if err != nil {
t.Fatalf("unexpected apply error: %v", err)
}
@ -242,7 +242,7 @@ func TestApplyPaneAction(t *testing.T) {
}
}
func TestApplyPaneAction_InvalidResult(t *testing.T) {
func TestApplyLaneAction_InvalidResult(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
@ -252,8 +252,8 @@ func TestApplyPaneAction_InvalidResult(t *testing.T) {
Points: 1,
}
action := PaneAction{
Ops: []PaneActionOp{
action := LaneAction{
Ops: []LaneActionOp{
{
Field: ActionFieldPriority,
Operator: ActionOperatorAssign,
@ -262,13 +262,13 @@ func TestApplyPaneAction_InvalidResult(t *testing.T) {
},
}
_, err := ApplyPaneAction(base, action, "")
_, err := ApplyLaneAction(base, action, "")
if err == nil {
t.Fatalf("expected validation error")
}
}
func TestApplyPaneAction_AssigneeCurrentUser(t *testing.T) {
func TestApplyLaneAction_AssigneeCurrentUser(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
@ -279,12 +279,12 @@ func TestApplyPaneAction_AssigneeCurrentUser(t *testing.T) {
Assignee: "Bob",
}
action, err := ParsePaneAction("assignee=CURRENT_USER")
action, err := ParseLaneAction("assignee=CURRENT_USER")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
updated, err := ApplyPaneAction(base, action, "Alex")
updated, err := ApplyLaneAction(base, action, "Alex")
if err != nil {
t.Fatalf("unexpected apply error: %v", err)
}
@ -293,7 +293,7 @@ func TestApplyPaneAction_AssigneeCurrentUser(t *testing.T) {
}
}
func TestApplyPaneAction_AssigneeCurrentUserMissing(t *testing.T) {
func TestApplyLaneAction_AssigneeCurrentUserMissing(t *testing.T) {
base := &task.Task{
ID: "TASK-1",
Title: "Task",
@ -303,12 +303,12 @@ func TestApplyPaneAction_AssigneeCurrentUserMissing(t *testing.T) {
Points: 1,
}
action, err := ParsePaneAction("assignee=CURRENT_USER")
action, err := ParseLaneAction("assignee=CURRENT_USER")
if err != nil {
t.Fatalf("unexpected parse error: %v", err)
}
_, err = ApplyPaneAction(base, action, "")
_, err = ApplyLaneAction(base, action, "")
if err == nil {
t.Fatalf("expected error for missing current user")
}

View file

@ -61,7 +61,7 @@ func (p *BasePlugin) GetType() string {
// TikiPlugin is a task-based plugin (like default Kanban board)
type TikiPlugin struct {
BasePlugin
Panes []TikiPane // pane definitions for this plugin
Lanes []TikiLane // lane definitions for this plugin
Sort []SortRule // parsed sort rules (nil = default sort)
ViewMode string // default view mode: "compact" or "expanded" (empty = compact)
Actions []PluginAction // shortcut actions applied to the selected task
@ -86,23 +86,23 @@ type PluginActionConfig struct {
type PluginAction struct {
Rune rune
Label string
Action PaneAction
Action LaneAction
}
// PluginPaneConfig represents a pane in YAML or config definitions.
type PluginPaneConfig struct {
// PluginLaneConfig represents a lane in YAML or config definitions.
type PluginLaneConfig 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 {
// TikiLane represents a parsed lane definition.
type TikiLane struct {
Name string
Columns int
Filter filter.FilterExpr
Action PaneAction
Action LaneAction
}
// PluginRef is the entry in config.yaml that references a plugin file or defines it inline
@ -122,6 +122,6 @@ type PluginRef struct {
Fetcher string `mapstructure:"fetcher"`
Text string `mapstructure:"text"`
URL string `mapstructure:"url"`
Panes []PluginPaneConfig `mapstructure:"panes"`
Lanes []PluginLaneConfig `mapstructure:"lanes"`
Actions []PluginActionConfig `mapstructure:"actions"`
}

View file

@ -2,7 +2,7 @@ name: Backlog
foreground: "#5fff87"
background: "#0b3d2e"
key: "F3"
panes:
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog' and type != 'epic'

View file

@ -2,7 +2,7 @@ name: Kanban
foreground: "#87ceeb"
background: "#25496a"
key: "F1"
panes:
lanes:
- name: Ready
filter: status = 'ready' and type != 'epic'
action: status = 'ready'

View file

@ -2,7 +2,7 @@ name: Recent
foreground: "#f4d6a6"
background: "#5a3d1b"
key: Ctrl-R
panes:
lanes:
- name: Recent
columns: 4
filter: NOW - UpdatedAt < 24hours

View file

@ -2,7 +2,7 @@ name: Roadmap
foreground: "#e2e8f0"
background: "#2a5f5a"
key: "F4"
panes:
lanes:
- name: Now
columns: 1
filter: type = 'epic' AND status = 'ready'

View file

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

View file

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

View file

@ -15,7 +15,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
Foreground: "#ffffff",
Background: "#000000",
Key: "I",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
},
Sort: "Priority DESC",
@ -44,8 +44,8 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
t.Errorf("Expected view mode 'expanded', got '%s'", tp.ViewMode)
}
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Fatal("Expected pane filter to be parsed")
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
t.Fatal("Expected lane filter to be parsed")
}
if len(tp.Sort) != 1 || tp.Sort[0].Field != "priority" || !tp.Sort[0].Descending {
@ -58,7 +58,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
Status: taskpkg.StatusReady,
}
if !tp.Panes[0].Filter.Evaluate(task, time.Now(), "testuser") {
if !tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser") {
t.Error("Expected filter to match todo task")
}
}
@ -66,7 +66,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
func TestLoadPluginFromRef_InlineMinimal(t *testing.T) {
ref := PluginRef{
Name: "Minimal",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Bugs", Filter: "type = 'bug'"},
},
}
@ -85,8 +85,8 @@ func TestLoadPluginFromRef_InlineMinimal(t *testing.T) {
t.Errorf("Expected name 'Minimal', got '%s'", tp.Name)
}
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Error("Expected pane filter to be parsed")
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
t.Error("Expected lane filter to be parsed")
}
}
@ -98,7 +98,7 @@ func TestLoadPluginFromRef_FileBased(t *testing.T) {
foreground: "#ff0000"
background: "#0000ff"
key: T
panes:
lanes:
- name: In Progress
filter: status = 'in_progress'
sort: Priority, UpdatedAt DESC
@ -143,7 +143,7 @@ func TestLoadPluginFromRef_Hybrid(t *testing.T) {
foreground: "#ff0000"
background: "#0000ff"
key: L
panes:
lanes:
- name: Todo
filter: status = 'ready'
sort: Priority
@ -175,8 +175,8 @@ view: compact
t.Errorf("Expected name 'Base Plugin', got '%s'", tp.Name)
}
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Error("Expected pane filter from file")
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
t.Error("Expected lane filter from file")
}
// Overridden fields should be from inline
@ -197,7 +197,7 @@ func TestLoadPluginFromRef_HybridMultipleOverrides(t *testing.T) {
foreground: "#ffffff"
background: "#000000"
key: M
panes:
lanes:
- name: Todo
filter: status = 'ready'
sort: Priority
@ -211,7 +211,7 @@ view: compact
ref := PluginRef{
File: pluginFile, // Use absolute path
Key: "X",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "In Progress", Filter: "status = 'in_progress'"},
},
Sort: "UpdatedAt DESC",
@ -243,10 +243,10 @@ view: compact
ID: "TIKI-1",
Status: taskpkg.StatusInProgress,
}
if len(tp.Panes) != 1 || tp.Panes[0].Filter == nil {
t.Fatal("Expected overridden pane filter")
if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil {
t.Fatal("Expected overridden lane filter")
}
if !tp.Panes[0].Filter.Evaluate(task, time.Now(), "testuser") {
if !tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser") {
t.Error("Expected overridden filter to match in_progress task")
}
@ -254,7 +254,7 @@ view: compact
ID: "TIKI-2",
Status: taskpkg.StatusReady,
}
if tp.Panes[0].Filter.Evaluate(todoTask, time.Now(), "testuser") {
if tp.Lanes[0].Filter.Evaluate(todoTask, time.Now(), "testuser") {
t.Error("Expected overridden filter to NOT match todo task")
}
}
@ -277,7 +277,7 @@ func TestLoadPluginFromRef_MissingFile(t *testing.T) {
func TestLoadPluginFromRef_NoName(t *testing.T) {
// Inline plugin without name
ref := PluginRef{
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
},
}

View file

@ -19,7 +19,7 @@ type pluginFileConfig struct {
Fetcher string `yaml:"fetcher"`
Text string `yaml:"text"`
URL string `yaml:"url"`
Panes []PluginPaneConfig `yaml:"panes"`
Lanes []PluginLaneConfig `yaml:"lanes"`
Actions []PluginActionConfig `yaml:"actions"`
}
@ -61,8 +61,8 @@ func mergePluginConfigs(base pluginFileConfig, overrides PluginRef) pluginFileCo
if overrides.URL != "" {
result.URL = overrides.URL
}
if len(overrides.Panes) > 0 {
result.Panes = overrides.Panes
if len(overrides.Lanes) > 0 {
result.Lanes = overrides.Lanes
}
if len(overrides.Actions) > 0 {
result.Actions = overrides.Actions
@ -91,7 +91,7 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
ConfigIndex: overrideTiki.ConfigIndex, // Use override's config index
Type: baseTiki.Type,
},
Panes: baseTiki.Panes,
Lanes: baseTiki.Lanes,
Sort: baseTiki.Sort,
ViewMode: baseTiki.ViewMode,
Actions: baseTiki.Actions,
@ -109,8 +109,8 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin {
if overrideTiki.Background != tcell.ColorDefault {
result.Background = overrideTiki.Background
}
if len(overrideTiki.Panes) > 0 {
result.Panes = overrideTiki.Panes
if len(overrideTiki.Lanes) > 0 {
result.Lanes = overrideTiki.Lanes
}
if overrideTiki.Sort != nil {
result.Sort = overrideTiki.Sort
@ -148,7 +148,7 @@ func validatePluginRef(ref PluginRef) error {
ref.Sort != "" || ref.Foreground != "" ||
ref.Background != "" || ref.View != "" || ref.Type != "" ||
ref.Fetcher != "" || ref.Text != "" || ref.URL != "" ||
len(ref.Panes) > 0 || len(ref.Actions) > 0
len(ref.Lanes) > 0 || len(ref.Actions) > 0
if !hasContent {
return fmt.Errorf("inline plugin '%s' has no configuration fields", ref.Name)

View file

@ -14,7 +14,7 @@ func TestMergePluginConfigs(t *testing.T) {
Foreground: "#ff0000",
Background: "#0000ff",
Key: "L",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
},
Sort: "Priority",
@ -32,8 +32,8 @@ func TestMergePluginConfigs(t *testing.T) {
if result.Name != "Base" {
t.Errorf("Expected name 'Base', got '%s'", result.Name)
}
if len(result.Panes) != 1 || result.Panes[0].Filter != "status = 'ready'" {
t.Errorf("Expected panes from base, got %+v", result.Panes)
if len(result.Lanes) != 1 || result.Lanes[0].Filter != "status = 'ready'" {
t.Errorf("Expected lanes from base, got %+v", result.Lanes)
}
if result.Foreground != "#ff0000" {
t.Errorf("Expected foreground from base, got '%s'", result.Foreground)
@ -54,7 +54,7 @@ func TestMergePluginConfigs_AllOverrides(t *testing.T) {
Foreground: "#ff0000",
Background: "#0000ff",
Key: "L",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
},
Sort: "Priority",
@ -66,7 +66,7 @@ func TestMergePluginConfigs_AllOverrides(t *testing.T) {
Foreground: "#00ff00",
Background: "#000000",
Key: "O",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Done", Filter: "status = 'done'"},
},
Sort: "UpdatedAt DESC",
@ -88,8 +88,8 @@ func TestMergePluginConfigs_AllOverrides(t *testing.T) {
if result.Key != "O" {
t.Errorf("Expected key 'O', got '%s'", result.Key)
}
if len(result.Panes) != 1 || result.Panes[0].Filter != "status = 'done'" {
t.Errorf("Expected pane filter 'status = 'done'', got %+v", result.Panes)
if len(result.Lanes) != 1 || result.Lanes[0].Filter != "status = 'done'" {
t.Errorf("Expected lane filter 'status = 'done'', got %+v", result.Lanes)
}
if result.Sort != "UpdatedAt DESC" {
t.Errorf("Expected sort 'UpdatedAt DESC', got '%s'", result.Sort)
@ -125,7 +125,7 @@ func TestValidatePluginRef_Hybrid(t *testing.T) {
func TestValidatePluginRef_InlineValid(t *testing.T) {
ref := PluginRef{
Name: "Test",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
},
}
@ -138,7 +138,7 @@ func TestValidatePluginRef_InlineValid(t *testing.T) {
func TestValidatePluginRef_InlineNoName(t *testing.T) {
ref := PluginRef{
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status = 'ready'"},
},
}
@ -183,7 +183,7 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
Background: tcell.ColorBlue,
Type: "tiki",
},
Panes: []TikiPane{
Lanes: []TikiLane{
{Name: "Todo", Columns: 1, Filter: baseFilter},
},
Sort: baseSort,
@ -203,7 +203,7 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
ConfigIndex: 1,
Type: "tiki",
},
Panes: []TikiPane{
Lanes: []TikiLane{
{Name: "Bugs", Columns: 1, Filter: overrideFilter},
},
Sort: nil,
@ -229,8 +229,8 @@ func TestMergePluginDefinitions_TikiToTiki(t *testing.T) {
if resultTiki.ViewMode != "expanded" {
t.Errorf("Expected expanded view, got %q", resultTiki.ViewMode)
}
if len(resultTiki.Panes) != 1 || resultTiki.Panes[0].Filter == nil {
t.Error("Expected pane filter to be overridden")
if len(resultTiki.Lanes) != 1 || resultTiki.Lanes[0].Filter == nil {
t.Error("Expected lane filter to be overridden")
}
// Check that base sort is kept when override has nil
@ -253,7 +253,7 @@ func TestMergePluginDefinitions_PreservesModifier(t *testing.T) {
Background: tcell.ColorDefault,
Type: "tiki",
},
Panes: []TikiPane{
Lanes: []TikiLane{
{Name: "Todo", Columns: 1, Filter: baseFilter},
},
}

View file

@ -52,8 +52,8 @@ 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 len(cfg.Lanes) > 0 {
return nil, fmt.Errorf("doki plugin cannot have 'lanes'")
}
if len(cfg.Actions) > 0 {
return nil, fmt.Errorf("doki plugin cannot have 'actions'")
@ -90,35 +90,35 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
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.Lanes) == 0 {
return nil, fmt.Errorf("tiki plugin requires 'lanes'")
}
if len(cfg.Panes) > 10 {
return nil, fmt.Errorf("tiki plugin has too many panes (%d), max is 10", len(cfg.Panes))
if len(cfg.Lanes) > 10 {
return nil, fmt.Errorf("tiki plugin has too many lanes (%d), max is 10", len(cfg.Lanes))
}
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)
lanes := make([]TikiLane, 0, len(cfg.Lanes))
for i, lane := range cfg.Lanes {
if lane.Name == "" {
return nil, fmt.Errorf("lane %d missing name", i)
}
columns := pane.Columns
columns := lane.Columns
if columns == 0 {
columns = 1
}
if columns < 0 {
return nil, fmt.Errorf("pane %q has invalid columns %d", pane.Name, columns)
return nil, fmt.Errorf("lane %q has invalid columns %d", lane.Name, columns)
}
filterExpr, err := filter.ParseFilter(pane.Filter)
filterExpr, err := filter.ParseFilter(lane.Filter)
if err != nil {
return nil, fmt.Errorf("parsing filter for pane %q: %w", pane.Name, err)
return nil, fmt.Errorf("parsing filter for lane %q: %w", lane.Name, err)
}
action, err := ParsePaneAction(pane.Action)
action, err := ParseLaneAction(lane.Action)
if err != nil {
return nil, fmt.Errorf("parsing action for pane %q: %w", pane.Name, err)
return nil, fmt.Errorf("parsing action for lane %q: %w", lane.Name, err)
}
panes = append(panes, TikiPane{
Name: pane.Name,
lanes = append(lanes, TikiLane{
Name: lane.Name,
Columns: columns,
Filter: filterExpr,
Action: action,
@ -139,7 +139,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) {
return &TikiPlugin{
BasePlugin: base,
Panes: panes,
Lanes: lanes,
Sort: sortRules,
ViewMode: cfg.View,
Actions: actions,
@ -185,7 +185,7 @@ func parsePluginActions(configs []PluginActionConfig) ([]PluginAction, error) {
return nil, fmt.Errorf("action %d (key %q) missing 'action'", i, cfg.Key)
}
action, err := ParsePaneAction(cfg.Action)
action, err := ParseLaneAction(cfg.Action)
if err != nil {
return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err)
}

View file

@ -164,7 +164,7 @@ func TestParsePluginConfig_DefaultTikiType(t *testing.T) {
cfg := pluginFileConfig{
Name: "Test",
Key: "T",
Panes: []PluginPaneConfig{
Lanes: []PluginLaneConfig{
{Name: "Todo", Filter: "status='ready'"},
},
// Type not specified, should default to "tiki"
@ -277,7 +277,7 @@ func TestParsePluginYAML_ValidTiki(t *testing.T) {
name: Test Plugin
key: T
type: tiki
panes:
lanes:
- name: Todo
columns: 4
filter: status = 'ready'
@ -305,12 +305,12 @@ background: "#0000ff"
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 len(tikiPlugin.Lanes) != 1 {
t.Fatalf("Expected 1 lane, got %d", len(tikiPlugin.Lanes))
}
if tikiPlugin.Panes[0].Columns != 4 {
t.Errorf("Expected pane columns 4, got %d", tikiPlugin.Panes[0].Columns)
if tikiPlugin.Lanes[0].Columns != 4 {
t.Errorf("Expected lane columns 4, got %d", tikiPlugin.Lanes[0].Columns)
}
}
@ -429,7 +429,7 @@ func TestParsePluginYAML_TikiWithActions(t *testing.T) {
yamlData := []byte(`
name: Test
key: T
panes:
lanes:
- name: Backlog
filter: status = 'backlog'
actions:

View file

@ -303,11 +303,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
columns := make([]int, len(tp.Lanes))
for i, lane := range tp.Lanes {
columns[i] = lane.Columns
}
pc.SetPaneLayout(columns)
pc.SetLaneLayout(columns)
pluginControllers[p.GetName()] = controller.NewPluginController(
ta.TaskStore, pc, tp, ta.NavController,
)

View file

@ -87,8 +87,8 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
f.taskStore,
pluginConfig,
tikiPlugin,
tikiController.GetFilteredTasksForPane,
tikiController.EnsureFirstNonEmptyPaneSelection,
tikiController.GetFilteredTasksForLane,
tikiController.EnsureFirstNonEmptyLaneSelection,
tikiController.GetActionRegistry(),
)
} else {

View file

@ -8,47 +8,47 @@ import (
"github.com/rivo/tview"
)
// GradientCaptionRow is a tview primitive that renders multiple pane captions
// GradientCaptionRow is a tview primitive that renders multiple lane captions
// with a continuous horizontal background gradient spanning the entire screen width
type GradientCaptionRow struct {
*tview.Box
paneNames []string
laneNames []string
bgColor tcell.Color // original background color from plugin
gradient config.Gradient // computed gradient (for truecolor/256-color terminals)
textColor tcell.Color
}
// NewGradientCaptionRow creates a new gradient caption row widget
func NewGradientCaptionRow(paneNames []string, bgColor tcell.Color, textColor tcell.Color) *GradientCaptionRow {
func NewGradientCaptionRow(laneNames []string, bgColor tcell.Color, textColor tcell.Color) *GradientCaptionRow {
return &GradientCaptionRow{
Box: tview.NewBox(),
paneNames: paneNames,
laneNames: laneNames,
bgColor: bgColor,
gradient: computeCaptionGradient(bgColor),
textColor: textColor,
}
}
// Draw renders all pane captions with a screen-wide gradient background
// Draw renders all lane 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.paneNames) == 0 {
if width <= 0 || height <= 0 || len(gcr.laneNames) == 0 {
return
}
// Calculate pane width (equal distribution)
numPanes := len(gcr.paneNames)
paneWidth := width / numPanes
// Calculate lane width (equal distribution)
numLanes := len(gcr.laneNames)
laneWidth := width / numLanes
// Convert all pane names to runes for Unicode handling
paneRunes := make([][]rune, numPanes)
for i, name := range gcr.paneNames {
paneRunes[i] = []rune(name)
// Convert all lane names to runes for Unicode handling
laneRunes := make([][]rune, numLanes)
for i, name := range gcr.laneNames {
laneRunes[i] = []rune(name)
}
// Render each pane position across the screen
// Render each lane 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
@ -75,34 +75,34 @@ func (gcr *GradientCaptionRow) Draw(screen tcell.Screen) {
bgColor = gradient.InterpolateColor(gcr.gradient, 1.0)
}
// Determine which pane this position belongs to
paneIndex := col / paneWidth
if paneIndex >= numPanes {
paneIndex = numPanes - 1
// Determine which lane this position belongs to
laneIndex := col / laneWidth
if laneIndex >= numLanes {
laneIndex = numLanes - 1
}
// Calculate position within this pane
paneStartX := paneIndex * paneWidth
paneEndX := paneStartX + paneWidth
if paneIndex == numPanes-1 {
paneEndX = width // Last pane extends to screen edge
// Calculate position within this lane
laneStartX := laneIndex * laneWidth
laneEndX := laneStartX + laneWidth
if laneIndex == numLanes-1 {
laneEndX = width // Last lane extends to screen edge
}
currentPaneWidth := paneEndX - paneStartX
posInPane := col - paneStartX
currentLaneWidth := laneEndX - laneStartX
posInLane := col - laneStartX
// Get the text for this pane
textRunes := paneRunes[paneIndex]
// Get the text for this lane
textRunes := laneRunes[laneIndex]
textWidth := len(textRunes)
// Calculate centered text position within pane
// Calculate centered text position within lane
textStartPos := 0
if textWidth < currentPaneWidth {
textStartPos = (currentPaneWidth - textWidth) / 2
if textWidth < currentLaneWidth {
textStartPos = (currentLaneWidth - textWidth) / 2
}
// Determine if we should render a character at this position
char := ' '
textIndex := posInPane - textStartPos
textIndex := posInLane - textStartPos
if textIndex >= 0 && textIndex < textWidth {
char = textRunes[textIndex]
}

View file

@ -11,7 +11,7 @@ how Backlog is defined:
foreground: "#5fff87"
background: "#005f00"
key: "F3"
panes:
lanes:
- name: Backlog
columns: 4
filter: status = 'backlog'
@ -21,7 +21,7 @@ how Backlog is defined:
action: status = 'ready'
sort: Priority, ID
```
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single pane.
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
You define the name, caption colors, hotkey, tiki filter and sorting. Save this into a yaml file and add this line:
@ -47,12 +47,12 @@ 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
## Multi-lane 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
Backlog is a pretty simple plugin in that it displays all tikis in a single lane. Multi-lane tiki plugins offer functionality
similar to that of the board. You can define multiple lanes per view and move tikis around with Shift-Left/Shift-Right
much like in the board. You can create a multi-lane plugin by defining multiple lanes in its definition and assigning
actions to each lane. An action defines what happens when you move a tiki into the lane. Here is a multi-lane plugin
definition that roughly mimics the board:
```yaml
@ -61,7 +61,7 @@ foreground: "#5fff87"
background: "#005f00"
key: "F4"
sort: Priority, Title
panes:
lanes:
- name: Ready
columns: 1
filter: status = 'ready'
@ -82,7 +82,7 @@ panes:
## Plugin actions
In addition to pane actions that trigger when moving tikis between panes, you can define plugin-level actions
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
```yaml
@ -98,7 +98,7 @@ actions:
Each action has:
- `key` - a single printable character used as the keyboard shortcut
- `label` - description shown in the header
- `action` - an action expression (same syntax as pane actions, see below)
- `action` - an action expression (same syntax as lane actions, see below)
When the shortcut key is pressed, the action is applied to the currently selected tiki.
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`,
@ -106,7 +106,7 @@ effectively moving it to the board.
## 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 `=`
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. 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

View file

@ -16,20 +16,20 @@ import (
// Note: tcell import is still used for pv.pluginDef.Background/Foreground checks
// PluginView renders a filtered/sorted list of tasks across panes
// PluginView renders a filtered/sorted list of tasks across lanes
type PluginView struct {
root *tview.Flex
titleBar tview.Primitive
searchHelper *SearchHelper
panes *tview.Flex
paneBoxes []*ScrollableList
lanes *tview.Flex
laneBoxes []*ScrollableList
taskStore store.Store
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
registry *controller.ActionRegistry
storeListenerID int
selectionListenerID int
getPaneTasks func(pane int) []*task.Task // injected from controller
getLaneTasks func(lane int) []*task.Task // injected from controller
ensureSelection func() bool // injected from controller
}
@ -38,7 +38,7 @@ func NewPluginView(
taskStore store.Store,
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
getPaneTasks func(pane int) []*task.Task,
getLaneTasks func(lane int) []*task.Task,
ensureSelection func() bool,
registry *controller.ActionRegistry,
) *PluginView {
@ -47,7 +47,7 @@ func NewPluginView(
pluginConfig: pluginConfig,
pluginDef: pluginDef,
registry: registry,
getPaneTasks: getPaneTasks,
getLaneTasks: getLaneTasks,
ensureSelection: ensureSelection,
}
@ -62,18 +62,18 @@ func (pv *PluginView) build() {
if pv.pluginDef.Foreground != tcell.ColorDefault {
textColor = pv.pluginDef.Foreground
}
paneNames := make([]string, len(pv.pluginDef.Panes))
for i, pane := range pv.pluginDef.Panes {
paneNames[i] = pane.Name
laneNames := make([]string, len(pv.pluginDef.Lanes))
for i, lane := range pv.pluginDef.Lanes {
laneNames[i] = lane.Name
}
pv.titleBar = NewGradientCaptionRow(paneNames, pv.pluginDef.Background, textColor)
pv.titleBar = NewGradientCaptionRow(laneNames, pv.pluginDef.Background, textColor)
// panes container (rows)
pv.panes = tview.NewFlex().SetDirection(tview.FlexColumn)
pv.paneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Panes))
// lanes container (rows)
pv.lanes = tview.NewFlex().SetDirection(tview.FlexColumn)
pv.laneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Lanes))
// search helper - focus returns to panes container
pv.searchHelper = NewSearchHelper(pv.panes)
// search helper - focus returns to lanes container
pv.searchHelper = NewSearchHelper(pv.lanes)
pv.searchHelper.SetCancelHandler(func() {
pv.HideSearch()
})
@ -95,9 +95,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.panes, 0, 1, false)
pv.root.AddItem(pv.lanes, 0, 1, false)
} else {
pv.root.AddItem(pv.panes, 0, 1, true)
pv.root.AddItem(pv.lanes, 0, 1, true)
}
}
@ -112,36 +112,36 @@ func (pv *PluginView) refresh() {
if viewMode == model.ViewModeExpanded {
itemHeight = config.TaskBoxHeightExpanded
}
selectedPane := pv.pluginConfig.GetSelectedPane()
selectedLane := pv.pluginConfig.GetSelectedLane()
if len(pv.paneBoxes) != len(pv.pluginDef.Panes) {
pv.paneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Panes))
for range pv.pluginDef.Panes {
pv.paneBoxes = append(pv.paneBoxes, NewScrollableList())
if len(pv.laneBoxes) != len(pv.pluginDef.Lanes) {
pv.laneBoxes = make([]*ScrollableList, 0, len(pv.pluginDef.Lanes))
for range pv.pluginDef.Lanes {
pv.laneBoxes = append(pv.laneBoxes, NewScrollableList())
}
}
pv.panes.Clear()
pv.lanes.Clear()
for paneIdx := range pv.pluginDef.Panes {
paneContainer := pv.paneBoxes[paneIdx]
paneContainer.SetItemHeight(itemHeight)
paneContainer.Clear()
for laneIdx := range pv.pluginDef.Lanes {
laneContainer := pv.laneBoxes[laneIdx]
laneContainer.SetItemHeight(itemHeight)
laneContainer.Clear()
isSelectedPane := paneIdx == selectedPane
pv.panes.AddItem(paneContainer, 0, 1, isSelectedPane)
isSelectedLane := laneIdx == selectedLane
pv.lanes.AddItem(laneContainer, 0, 1, isSelectedLane)
tasks := pv.getPaneTasks(paneIdx)
if isSelectedPane {
tasks := pv.getLaneTasks(laneIdx)
if isSelectedLane {
pv.pluginConfig.ClampSelection(len(tasks))
}
if len(tasks) == 0 {
paneContainer.SetSelection(-1)
laneContainer.SetSelection(-1)
continue
}
columns := pv.pluginConfig.GetColumnsForPane(paneIdx)
selectedIndex := pv.pluginConfig.GetSelectedIndexForPane(paneIdx)
columns := pv.pluginConfig.GetColumnsForLane(laneIdx)
selectedIndex := pv.pluginConfig.GetSelectedIndexForLane(laneIdx)
selectedRow := selectedIndex / columns
numRows := (len(tasks) + columns - 1) / columns
@ -151,7 +151,7 @@ func (pv *PluginView) refresh() {
idx := row*columns + col
if idx < len(tasks) {
task := tasks[idx]
isSelected := isSelectedPane && idx == selectedIndex
isSelected := isSelectedLane && idx == selectedIndex
var taskBox *tview.Frame
if viewMode == model.ViewModeCompact {
taskBox = CreateCompactTaskBox(task, isSelected, config.GetColors())
@ -164,17 +164,17 @@ func (pv *PluginView) refresh() {
rowFlex.AddItem(spacer, 0, 1, false)
}
}
paneContainer.AddItem(rowFlex)
laneContainer.AddItem(rowFlex)
}
if isSelectedPane {
paneContainer.SetSelection(selectedRow)
if isSelectedLane {
laneContainer.SetSelection(selectedRow)
} else {
paneContainer.SetSelection(-1)
laneContainer.SetSelection(-1)
}
// Sync scroll offset from view to model for later pane navigation
pv.pluginConfig.SetScrollOffsetForPane(paneIdx, paneContainer.GetScrollOffset())
// Sync scroll offset from view to model for later lane navigation
pv.pluginConfig.SetScrollOffsetForLane(laneIdx, laneContainer.GetScrollOffset())
}
}
@ -208,9 +208,9 @@ func (pv *PluginView) OnBlur() {
// GetSelectedID returns the selected task ID
func (pv *PluginView) GetSelectedID() string {
pane := pv.pluginConfig.GetSelectedPane()
tasks := pv.getPaneTasks(pane)
idx := pv.pluginConfig.GetSelectedIndexForPane(pane)
lane := pv.pluginConfig.GetSelectedLane()
tasks := pv.getLaneTasks(lane)
idx := pv.pluginConfig.GetSelectedIndexForLane(lane)
if idx >= 0 && idx < len(tasks) {
return tasks[idx].ID
}
@ -219,12 +219,12 @@ func (pv *PluginView) GetSelectedID() string {
// SetSelectedID sets the selection to a task
func (pv *PluginView) SetSelectedID(id string) {
for pane := range pv.pluginDef.Panes {
tasks := pv.getPaneTasks(pane)
for lane := range pv.pluginDef.Lanes {
tasks := pv.getLaneTasks(lane)
for i, t := range tasks {
if t.ID == id {
pv.pluginConfig.SetSelectedPane(pane)
pv.pluginConfig.SetSelectedIndexForPane(pane, i)
pv.pluginConfig.SetSelectedLane(lane)
pv.pluginConfig.SetSelectedIndexForLane(lane, i)
return
}
}
@ -244,7 +244,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.panes, 0, 1, false)
pv.root.AddItem(pv.lanes, 0, 1, false)
return searchBox
}
@ -263,7 +263,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.panes, 0, 1, true)
pv.root.AddItem(pv.lanes, 0, 1, true)
}
// IsSearchVisible returns whether the search box is currently visible
@ -289,8 +289,8 @@ 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 {
total := 0
for pane := range pv.pluginDef.Panes {
tasks := pv.getPaneTasks(pane)
for lane := range pv.pluginDef.Lanes {
tasks := pv.getLaneTasks(lane)
total += len(tasks)
}
return []store.Stat{

View file

@ -15,14 +15,14 @@ import (
func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) {
taskStore := store.NewInMemoryStore()
pluginConfig := model.NewPluginConfig("TestPlugin")
pluginConfig.SetPaneLayout([]int{1})
pluginConfig.SetLaneLayout([]int{1})
pluginDef := &plugin.TikiPlugin{
BasePlugin: plugin.BasePlugin{
Name: "TestPlugin",
},
Panes: []plugin.TikiPane{
{Name: "Pane", Columns: 1},
Lanes: []plugin.TikiLane{
{Name: "Lane", Columns: 1},
},
}
@ -36,35 +36,35 @@ func TestPluginViewRefreshPreservesScrollOffset(t *testing.T) {
}
}
pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(pane int) []*task.Task {
pv := NewPluginView(taskStore, pluginConfig, pluginDef, func(lane int) []*task.Task {
return tasks
}, nil, controller.PluginViewActions())
if len(pv.paneBoxes) != 1 {
t.Fatalf("expected 1 pane box, got %d", len(pv.paneBoxes))
if len(pv.laneBoxes) != 1 {
t.Fatalf("expected 1 lane box, got %d", len(pv.laneBoxes))
}
pane := pv.paneBoxes[0]
lane := pv.laneBoxes[0]
itemHeight := config.TaskBoxHeight
pane.SetRect(0, 0, 80, itemHeight*5)
lane.SetRect(0, 0, 80, itemHeight*5)
pluginConfig.SetSelectedIndexForPane(0, len(tasks)-1)
pluginConfig.SetSelectedIndexForLane(0, len(tasks)-1)
pv.refresh()
expectedScrollOffset := len(tasks) - 5
if pane.scrollOffset != expectedScrollOffset {
t.Fatalf("expected scrollOffset %d, got %d", expectedScrollOffset, pane.scrollOffset)
if lane.scrollOffset != expectedScrollOffset {
t.Fatalf("expected scrollOffset %d, got %d", expectedScrollOffset, lane.scrollOffset)
}
paneBefore := pane
pluginConfig.SetSelectedIndexForPane(0, len(tasks)-2)
laneBefore := lane
pluginConfig.SetSelectedIndexForLane(0, len(tasks)-2)
pv.refresh()
if pv.paneBoxes[0] != paneBefore {
t.Fatalf("expected pane list to be reused across refresh")
if pv.laneBoxes[0] != laneBefore {
t.Fatalf("expected lane list to be reused across refresh")
}
if pane.scrollOffset != expectedScrollOffset {
t.Fatalf("expected scrollOffset to remain %d, got %d", expectedScrollOffset, pane.scrollOffset)
if lane.scrollOffset != expectedScrollOffset {
t.Fatalf("expected scrollOffset to remain %d, got %d", expectedScrollOffset, lane.scrollOffset)
}
}