mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
rename pane to lane
This commit is contained in:
parent
fe0af13cb3
commit
aa94c897d6
29 changed files with 524 additions and 524 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: Backlog
|
|||
foreground: "#5fff87"
|
||||
background: "#0b3d2e"
|
||||
key: "F3"
|
||||
panes:
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: Recent
|
|||
foreground: "#f4d6a6"
|
||||
background: "#5a3d1b"
|
||||
key: Ctrl-R
|
||||
panes:
|
||||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: NOW - UpdatedAt < 24hours
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ name: Roadmap
|
|||
foreground: "#e2e8f0"
|
||||
background: "#2a5f5a"
|
||||
key: "F4"
|
||||
panes:
|
||||
lanes:
|
||||
- name: Now
|
||||
columns: 1
|
||||
filter: type = 'epic' AND status = 'ready'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue