read only view in deps plugin

This commit is contained in:
booleanmaybe 2026-03-20 13:02:53 -04:00
parent 86506bb137
commit d5e28b2a01
12 changed files with 380 additions and 374 deletions

View file

@ -260,6 +260,14 @@ func TaskDetailViewActions() *ActionRegistry {
return r
}
// ReadonlyTaskDetailViewActions returns a reduced registry for readonly task detail views.
// Only fullscreen toggle is available — no editing actions.
func ReadonlyTaskDetailViewActions() *ActionRegistry {
r := NewActionRegistry()
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
return r
}
// TaskEditViewActions returns the canonical action registry for the task edit view.
// Separate registry so view/edit modes can diverge while sharing rendering helpers.
func TaskEditViewActions() *ActionRegistry {
@ -396,7 +404,6 @@ func PluginViewActions() *ActionRegistry {
}
// DepsViewActions returns the action registry for the dependency editor view.
// Restricted to navigation, move task, view mode toggle, and search — no open/new/delete/plugin keys.
func DepsViewActions() *ActionRegistry {
r := NewActionRegistry()
@ -414,6 +421,11 @@ func DepsViewActions() *ActionRegistry {
r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true})
r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true})
// task actions
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
r.Register(Action{ID: ActionNewTask, Key: tcell.KeyRune, Rune: 'n', Label: "New", ShowInHeader: true})
r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true})
// view mode and search
r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true})
r.Register(Action{ID: ActionToggleViewMode, Key: tcell.KeyRune, Rune: 'v', Label: "View mode", ShowInHeader: true})

View file

@ -21,11 +21,7 @@ const (
// Unlike PluginController, move logic here updates different tasks depending on
// the source/target lane pair — sometimes the moved task, sometimes the context task.
type DepsController struct {
taskStore store.Store
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
navController *NavigationController
registry *ActionRegistry
pluginBase
}
// NewDepsController creates a dependency editor controller.
@ -36,33 +32,52 @@ func NewDepsController(
navController *NavigationController,
) *DepsController {
return &DepsController{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
registry: DepsViewActions(),
pluginBase: pluginBase{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
registry: DepsViewActions(),
},
}
}
func (dc *DepsController) GetActionRegistry() *ActionRegistry { return dc.registry }
func (dc *DepsController) GetPluginName() string { return dc.pluginDef.Name }
func (dc *DepsController) ShowNavigation() bool { return false }
func (dc *DepsController) ShowNavigation() bool { return false }
// EnsureFirstNonEmptyLaneSelection delegates to pluginBase with this controller's filter.
func (dc *DepsController) EnsureFirstNonEmptyLaneSelection() bool {
return dc.pluginBase.EnsureFirstNonEmptyLaneSelection(dc.GetFilteredTasksForLane)
}
// HandleAction routes actions to the appropriate handler.
func (dc *DepsController) HandleAction(actionID ActionID) bool {
switch actionID {
case ActionNavUp:
return dc.handleNav("up")
return dc.handleNav("up", dc.GetFilteredTasksForLane)
case ActionNavDown:
return dc.handleNav("down")
return dc.handleNav("down", dc.GetFilteredTasksForLane)
case ActionNavLeft:
return dc.handleNav("left")
return dc.handleNav("left", dc.GetFilteredTasksForLane)
case ActionNavRight:
return dc.handleNav("right")
return dc.handleNav("right", dc.GetFilteredTasksForLane)
case ActionMoveTaskLeft:
return dc.handleMoveTask(-1)
case ActionMoveTaskRight:
return dc.handleMoveTask(1)
case ActionOpenFromPlugin:
taskID := dc.getSelectedTaskID(dc.GetFilteredTasksForLane)
if taskID == "" {
return false
}
dc.navController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{
TaskID: taskID,
ReadOnly: true,
}))
return true
case ActionNewTask:
return dc.handleNewTask()
case ActionDeleteTask:
return dc.handleDeleteTask(dc.GetFilteredTasksForLane)
case ActionToggleViewMode:
dc.pluginConfig.ToggleViewMode()
return true
@ -73,18 +88,9 @@ func (dc *DepsController) HandleAction(actionID ActionID) bool {
// HandleSearch processes a search query, narrowing visible tasks within each lane.
func (dc *DepsController) HandleSearch(query string) {
query = strings.TrimSpace(query)
if query == "" {
return
}
dc.pluginConfig.SavePreSearchState()
results := dc.taskStore.Search(query, nil)
if len(results) == 0 {
dc.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
return
}
dc.pluginConfig.SetSearchResults(results, query)
dc.selectFirstNonEmptyLane()
dc.handleSearch(query, func() bool {
return dc.selectFirstNonEmptyLane(dc.GetFilteredTasksForLane)
})
}
// GetFilteredTasksForLane returns tasks for a given lane of the deps editor.
@ -127,17 +133,6 @@ func (dc *DepsController) GetFilteredTasksForLane(lane int) []*task.Task {
return result
}
// EnsureFirstNonEmptyLaneSelection selects the first non-empty lane if the current lane is empty.
func (dc *DepsController) EnsureFirstNonEmptyLaneSelection() bool {
currentLane := dc.pluginConfig.GetSelectedLane()
if currentLane >= 0 && currentLane < len(dc.pluginDef.Lanes) {
if len(dc.GetFilteredTasksForLane(currentLane)) > 0 {
return false
}
}
return dc.selectFirstNonEmptyLane()
}
// handleMoveTask applies dependency changes based on the source→target lane transition.
//
// From → To | What changes
@ -150,7 +145,7 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
return false
}
movedTaskID := dc.getSelectedTaskID()
movedTaskID := dc.getSelectedTaskID(dc.GetFilteredTasksForLane)
if movedTaskID == "" {
return false
}
@ -200,7 +195,7 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
}
}
dc.selectTaskInLane(targetLane, movedTaskID)
dc.selectTaskInLane(targetLane, movedTaskID, dc.GetFilteredTasksForLane)
return true
}
@ -251,84 +246,3 @@ func (dc *DepsController) computeAllLane(allTasks []*task.Task, contextID string
}
return result
}
func (dc *DepsController) handleNav(direction string) bool {
lane := dc.pluginConfig.GetSelectedLane()
tasks := dc.GetFilteredTasksForLane(lane)
if direction == "left" || direction == "right" {
if dc.pluginConfig.MoveSelection(direction, len(tasks)) {
return true
}
return dc.handleLaneSwitch(direction)
}
return dc.pluginConfig.MoveSelection(direction, len(tasks))
}
func (dc *DepsController) handleLaneSwitch(direction string) bool {
currentLane := dc.pluginConfig.GetSelectedLane()
nextLane := currentLane
switch direction {
case "left":
nextLane--
case "right":
nextLane++
default:
return false
}
for nextLane >= 0 && nextLane < len(dc.pluginDef.Lanes) {
tasks := dc.GetFilteredTasksForLane(nextLane)
if len(tasks) > 0 {
dc.pluginConfig.SetSelectedLane(nextLane)
scrollOffset := dc.pluginConfig.GetScrollOffsetForLane(nextLane)
if scrollOffset >= len(tasks) {
scrollOffset = len(tasks) - 1
}
if scrollOffset < 0 {
scrollOffset = 0
}
dc.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
return true
}
switch direction {
case "left":
nextLane--
case "right":
nextLane++
}
}
return false
}
func (dc *DepsController) getSelectedTaskID() string {
lane := dc.pluginConfig.GetSelectedLane()
tasks := dc.GetFilteredTasksForLane(lane)
idx := dc.pluginConfig.GetSelectedIndexForLane(lane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
func (dc *DepsController) selectTaskInLane(lane int, taskID string) {
tasks := dc.GetFilteredTasksForLane(lane)
targetIndex := 0
for i, t := range tasks {
if t.ID == taskID {
targetIndex = i
break
}
}
dc.pluginConfig.SetSelectedLane(lane)
dc.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
}
func (dc *DepsController) selectFirstNonEmptyLane() bool {
for lane := range dc.pluginDef.Lanes {
if len(dc.GetFilteredTasksForLane(lane)) > 0 {
dc.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
return true
}
}
return false
}

View file

@ -46,7 +46,8 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
pluginConfig := model.NewPluginConfig("deps:" + testCtxID)
pluginConfig.SetLaneLayout([]int{1, 2, 1})
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nil)
nav := newMockNavigationController()
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nav)
return dc, taskStore
}
@ -297,6 +298,53 @@ func TestDepsController_HandleAction(t *testing.T) {
}
})
t.Run("open task pushes detail view", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
result := dc.HandleAction(ActionOpenFromPlugin)
if !result {
t.Error("open should succeed when a task is selected")
}
top := dc.navController.navState.currentView()
if top == nil || top.ViewID != model.TaskDetailViewID {
t.Error("expected TaskDetailViewID to be pushed")
}
})
t.Run("new task pushes edit view", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
result := dc.HandleAction(ActionNewTask)
if !result {
t.Error("new task should succeed")
}
top := dc.navController.navState.currentView()
if top == nil || top.ViewID != model.TaskEditViewID {
t.Error("expected TaskEditViewID to be pushed")
}
})
t.Run("delete task removes from store", func(t *testing.T) {
dc, taskStore := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
// free task should be in the All lane
allTasks := dc.GetFilteredTasksForLane(depsLaneAll)
if len(allTasks) == 0 {
t.Fatal("expected at least one task in All lane")
}
deletedID := allTasks[0].ID
result := dc.HandleAction(ActionDeleteTask)
if !result {
t.Error("delete should succeed when a task is selected")
}
if taskStore.GetTask(deletedID) != nil {
t.Errorf("task %s should have been deleted", deletedID)
}
})
t.Run("invalid action returns false", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
if dc.HandleAction("nonexistent_action") {
@ -309,7 +357,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
t.Run("right from Blocks lands on All", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
result := dc.handleLaneSwitch("right")
result := dc.handleLaneSwitch("right", dc.GetFilteredTasksForLane)
if !result {
t.Error("should succeed — All lane has tasks")
}
@ -321,7 +369,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
t.Run("left from All lands on Blocks", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneAll)
result := dc.handleLaneSwitch("left")
result := dc.handleLaneSwitch("left", dc.GetFilteredTasksForLane)
if !result {
t.Error("should succeed — Blocks lane has tasks")
}
@ -333,7 +381,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
t.Run("left from Blocks returns false (boundary)", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
if dc.handleLaneSwitch("left") {
if dc.handleLaneSwitch("left", dc.GetFilteredTasksForLane) {
t.Error("should fail — no lane to the left of Blocks")
}
})
@ -341,7 +389,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
t.Run("right from Depends returns false (boundary)", func(t *testing.T) {
dc, _ := newDepsTestEnv(t)
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
if dc.handleLaneSwitch("right") {
if dc.handleLaneSwitch("right", dc.GetFilteredTasksForLane) {
t.Error("should fail — no lane to the right of Depends")
}
})
@ -381,27 +429,18 @@ func TestDepsController_EnsureFirstNonEmptyLaneSelection(t *testing.T) {
})
}
func TestDepsViewActions_NoOpenNewDelete(t *testing.T) {
func TestDepsViewActions(t *testing.T) {
registry := DepsViewActions()
actions := registry.GetActions()
forbidden := map[ActionID]bool{
ActionOpenFromPlugin: true,
ActionNewTask: true,
ActionDeleteTask: true,
}
for _, a := range actions {
if forbidden[a.ID] {
t.Errorf("DepsViewActions should not contain %s", a.ID)
}
}
required := map[ActionID]bool{
ActionNavUp: false,
ActionNavDown: false,
ActionMoveTaskLeft: false,
ActionMoveTaskRight: false,
ActionOpenFromPlugin: false,
ActionNewTask: false,
ActionDeleteTask: false,
ActionToggleViewMode: false,
ActionSearch: false,
}

View file

@ -320,7 +320,7 @@ func (ir *InputRouter) handleTaskInput(event *tcell.EventKey, params map[string]
ir.taskController.SetCurrentTask(taskID)
}
registry := ir.taskController.GetActionRegistry()
registry := ir.navController.GetActiveView().GetActionRegistry()
if action := registry.Match(event); action != nil {
switch action.ID {
case ActionEditTitle:

View file

@ -15,11 +15,7 @@ import (
// PluginController handles plugin view actions: navigation, open, create, delete.
type PluginController struct {
taskStore store.Store
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
navController *NavigationController
registry *ActionRegistry
pluginBase
}
// NewPluginController creates a plugin controller
@ -30,11 +26,13 @@ func NewPluginController(
navController *NavigationController,
) *PluginController {
pc := &PluginController{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
registry: PluginViewActions(),
pluginBase: pluginBase{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
registry: PluginViewActions(),
},
}
// register plugin-specific shortcut actions, warn about conflicts
@ -86,42 +84,38 @@ func getPluginActionRune(id ActionID) rune {
return runes[0]
}
// GetActionRegistry returns the actions for the plugin view
func (pc *PluginController) GetActionRegistry() *ActionRegistry {
return pc.registry
}
// GetPluginName returns the plugin name
func (pc *PluginController) GetPluginName() string {
return pc.pluginDef.Name
}
// ShowNavigation returns true — regular plugin views show plugin navigation keys.
func (pc *PluginController) ShowNavigation() bool { return true }
// EnsureFirstNonEmptyLaneSelection delegates to pluginBase with this controller's filter.
func (pc *PluginController) EnsureFirstNonEmptyLaneSelection() bool {
return pc.pluginBase.EnsureFirstNonEmptyLaneSelection(pc.GetFilteredTasksForLane)
}
// HandleAction processes a plugin action
func (pc *PluginController) HandleAction(actionID ActionID) bool {
switch actionID {
case ActionNavUp:
return pc.handleNav("up")
return pc.handleNav("up", pc.GetFilteredTasksForLane)
case ActionNavDown:
return pc.handleNav("down")
return pc.handleNav("down", pc.GetFilteredTasksForLane)
case ActionNavLeft:
return pc.handleNav("left")
return pc.handleNav("left", pc.GetFilteredTasksForLane)
case ActionNavRight:
return pc.handleNav("right")
return pc.handleNav("right", pc.GetFilteredTasksForLane)
case ActionMoveTaskLeft:
return pc.handleMoveTask(-1)
case ActionMoveTaskRight:
return pc.handleMoveTask(1)
case ActionOpenFromPlugin:
return pc.handleOpenTask()
return pc.handleOpenTask(pc.GetFilteredTasksForLane)
case ActionNewTask:
return pc.handleNewTask()
case ActionDeleteTask:
return pc.handleDeleteTask()
return pc.handleDeleteTask(pc.GetFilteredTasksForLane)
case ActionToggleViewMode:
return pc.handleToggleViewMode()
pc.pluginConfig.ToggleViewMode()
return true
default:
if r := getPluginActionRune(actionID); r != 0 {
return pc.handlePluginAction(r)
@ -130,96 +124,11 @@ func (pc *PluginController) HandleAction(actionID ActionID) bool {
}
}
func (pc *PluginController) handleNav(direction string) bool {
lane := pc.pluginConfig.GetSelectedLane()
tasks := pc.GetFilteredTasksForLane(lane)
if direction == "left" || direction == "right" {
if pc.pluginConfig.MoveSelection(direction, len(tasks)) {
return true
}
return pc.handleLaneSwitch(direction)
}
return pc.pluginConfig.MoveSelection(direction, len(tasks))
}
func (pc *PluginController) handleOpenTask() bool {
taskID := pc.getSelectedTaskID()
if taskID == "" {
return false
}
pc.navController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{
TaskID: taskID,
}))
return true
}
func (pc *PluginController) handleLaneSwitch(direction string) bool {
currentLane := pc.pluginConfig.GetSelectedLane()
nextLane := currentLane
switch direction {
case "left":
nextLane--
case "right":
nextLane++
default:
return false
}
for nextLane >= 0 && nextLane < len(pc.pluginDef.Lanes) {
tasks := pc.GetFilteredTasksForLane(nextLane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedLane(nextLane)
// Select the task at top of viewport (scroll offset) rather than keeping stale index
scrollOffset := pc.pluginConfig.GetScrollOffsetForLane(nextLane)
if scrollOffset >= len(tasks) {
scrollOffset = len(tasks) - 1
}
if scrollOffset < 0 {
scrollOffset = 0
}
pc.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
return true
}
switch direction {
case "left":
nextLane--
case "right":
nextLane++
}
}
return false
}
func (pc *PluginController) handleNewTask() bool {
task, err := pc.taskStore.NewTaskTemplate()
if err != nil {
slog.Error("failed to create task template", "error", err)
return false
}
pc.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: task.ID,
Draft: task,
Focus: model.EditFieldTitle,
}))
slog.Info("new tiki draft started from plugin", "task_id", task.ID, "plugin", pc.pluginDef.Name)
return true
}
func (pc *PluginController) handleDeleteTask() bool {
taskID := pc.getSelectedTaskID()
if taskID == "" {
return false
}
pc.taskStore.DeleteTask(taskID)
return true
}
func (pc *PluginController) handleToggleViewMode() bool {
pc.pluginConfig.ToggleViewMode()
return true
// HandleSearch processes a search query for the plugin view
func (pc *PluginController) HandleSearch(query string) {
pc.handleSearch(query, func() bool {
return pc.selectFirstNonEmptyLane(pc.GetFilteredTasksForLane)
})
}
// handlePluginAction applies a plugin shortcut action to the currently selected task.
@ -236,7 +145,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
return false
}
taskID := pc.getSelectedTaskID()
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {
return false
}
@ -264,7 +173,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
}
func (pc *PluginController) handleMoveTask(offset int) bool {
taskID := pc.getSelectedTaskID()
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {
return false
}
@ -297,44 +206,10 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
}
pc.ensureSearchResultIncludesTask(updated)
pc.selectTaskInLane(targetLane, taskID)
pc.selectTaskInLane(targetLane, taskID, pc.GetFilteredTasksForLane)
return true
}
// HandleSearch processes a search query for the plugin view
func (pc *PluginController) HandleSearch(query string) {
query = strings.TrimSpace(query)
if query == "" {
return // Don't search empty/whitespace
}
// Save current position
pc.pluginConfig.SavePreSearchState()
// 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)
return
}
pc.pluginConfig.SetSearchResults(results, query)
if pc.selectFirstNonEmptyLane() {
return
}
}
// getSelectedTaskID returns the ID of the currently selected task
func (pc *PluginController) getSelectedTaskID() string {
lane := pc.pluginConfig.GetSelectedLane()
tasks := pc.GetFilteredTasksForLane(lane)
idx := pc.pluginConfig.GetSelectedIndexForLane(lane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
// GetFilteredTasksForLane returns tasks filtered and sorted for a specific lane.
func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
if pc.pluginDef == nil {
@ -344,17 +219,13 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
return nil
}
// Check if search is active - if so, return search results instead
// check if search is active
searchResults := pc.pluginConfig.GetSearchResults()
// Normal filtering path when search is not active
allTasks := pc.taskStore.GetAllTasks()
now := time.Now()
// Get current user for "my tasks" type filters
currentUser := getCurrentUserName(pc.taskStore)
// Apply filter
var filtered []*task.Task
for _, task := range allTasks {
laneFilter := pc.pluginDef.Lanes[lane].Filter
@ -371,7 +242,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
filtered = filterTasksBySearch(filtered, searchTaskMap)
}
// Apply sort
if len(pc.pluginDef.Sort) > 0 {
plugin.SortTasks(filtered, pc.pluginDef.Sort)
}
@ -379,49 +249,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
return filtered
}
func (pc *PluginController) selectTaskInLane(lane int, taskID string) {
if lane < 0 || lane >= len(pc.pluginDef.Lanes) {
return
}
tasks := pc.GetFilteredTasksForLane(lane)
targetIndex := 0
for i, task := range tasks {
if task.ID == taskID {
targetIndex = i
break
}
}
pc.pluginConfig.SetSelectedLane(lane)
pc.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
}
func (pc *PluginController) selectFirstNonEmptyLane() bool {
for lane := range pc.pluginDef.Lanes {
tasks := pc.GetFilteredTasksForLane(lane)
if len(tasks) > 0 {
pc.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
return true
}
}
return false
}
func (pc *PluginController) EnsureFirstNonEmptyLaneSelection() bool {
if pc.pluginDef == nil {
return false
}
currentLane := pc.pluginConfig.GetSelectedLane()
if currentLane >= 0 && currentLane < len(pc.pluginDef.Lanes) {
tasks := pc.GetFilteredTasksForLane(currentLane)
if len(tasks) > 0 {
return false
}
}
return pc.selectFirstNonEmptyLane()
}
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
if updated == nil {
return
@ -442,16 +269,3 @@ func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
})
pc.pluginConfig.SetSearchResults(searchResults, pc.pluginConfig.GetSearchQuery())
}
func filterTasksBySearch(tasks []*task.Task, searchMap map[string]bool) []*task.Task {
if searchMap == nil {
return tasks
}
filtered := make([]*task.Task, 0, len(tasks))
for _, t := range tasks {
if searchMap[t.ID] {
filtered = append(filtered, t)
}
}
return filtered
}

186
controller/plugin_base.go Normal file
View file

@ -0,0 +1,186 @@
package controller
import (
"log/slog"
"strings"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
// pluginBase holds the shared fields and methods common to PluginController and DepsController.
// Methods that depend on per-controller filtering accept a filteredTasks callback.
type pluginBase struct {
taskStore store.Store
pluginConfig *model.PluginConfig
pluginDef *plugin.TikiPlugin
navController *NavigationController
registry *ActionRegistry
}
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
func (pb *pluginBase) GetPluginName() string { return pb.pluginDef.Name }
func (pb *pluginBase) handleNav(direction string, filteredTasks func(int) []*task.Task) bool {
lane := pb.pluginConfig.GetSelectedLane()
tasks := filteredTasks(lane)
if direction == "left" || direction == "right" {
if pb.pluginConfig.MoveSelection(direction, len(tasks)) {
return true
}
return pb.handleLaneSwitch(direction, filteredTasks)
}
return pb.pluginConfig.MoveSelection(direction, len(tasks))
}
func (pb *pluginBase) handleLaneSwitch(direction string, filteredTasks func(int) []*task.Task) bool {
currentLane := pb.pluginConfig.GetSelectedLane()
nextLane := currentLane
switch direction {
case "left":
nextLane--
case "right":
nextLane++
default:
return false
}
for nextLane >= 0 && nextLane < len(pb.pluginDef.Lanes) {
tasks := filteredTasks(nextLane)
if len(tasks) > 0 {
pb.pluginConfig.SetSelectedLane(nextLane)
// select the task at top of viewport (scroll offset) rather than keeping stale index
scrollOffset := pb.pluginConfig.GetScrollOffsetForLane(nextLane)
if scrollOffset >= len(tasks) {
scrollOffset = len(tasks) - 1
}
if scrollOffset < 0 {
scrollOffset = 0
}
pb.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
return true
}
switch direction {
case "left":
nextLane--
case "right":
nextLane++
}
}
return false
}
func (pb *pluginBase) getSelectedTaskID(filteredTasks func(int) []*task.Task) string {
lane := pb.pluginConfig.GetSelectedLane()
tasks := filteredTasks(lane)
idx := pb.pluginConfig.GetSelectedIndexForLane(lane)
if idx < 0 || idx >= len(tasks) {
return ""
}
return tasks[idx].ID
}
func (pb *pluginBase) selectTaskInLane(lane int, taskID string, filteredTasks func(int) []*task.Task) {
if lane < 0 || lane >= len(pb.pluginDef.Lanes) {
return
}
tasks := filteredTasks(lane)
targetIndex := 0
for i, t := range tasks {
if t.ID == taskID {
targetIndex = i
break
}
}
pb.pluginConfig.SetSelectedLane(lane)
pb.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
}
func (pb *pluginBase) selectFirstNonEmptyLane(filteredTasks func(int) []*task.Task) bool {
for lane := range pb.pluginDef.Lanes {
if len(filteredTasks(lane)) > 0 {
pb.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
return true
}
}
return false
}
// EnsureFirstNonEmptyLaneSelection selects the first non-empty lane if the current lane is empty.
func (pb *pluginBase) EnsureFirstNonEmptyLaneSelection(filteredTasks func(int) []*task.Task) bool {
if pb.pluginDef == nil {
return false
}
currentLane := pb.pluginConfig.GetSelectedLane()
if currentLane >= 0 && currentLane < len(pb.pluginDef.Lanes) {
if len(filteredTasks(currentLane)) > 0 {
return false
}
}
return pb.selectFirstNonEmptyLane(filteredTasks)
}
func (pb *pluginBase) handleSearch(query string, selectFirst func() bool) {
query = strings.TrimSpace(query)
if query == "" {
return
}
pb.pluginConfig.SavePreSearchState()
results := pb.taskStore.Search(query, nil)
if len(results) == 0 {
pb.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
return
}
pb.pluginConfig.SetSearchResults(results, query)
selectFirst()
}
func (pb *pluginBase) handleOpenTask(filteredTasks func(int) []*task.Task) bool {
taskID := pb.getSelectedTaskID(filteredTasks)
if taskID == "" {
return false
}
pb.navController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{
TaskID: taskID,
}))
return true
}
func (pb *pluginBase) handleNewTask() bool {
t, err := pb.taskStore.NewTaskTemplate()
if err != nil {
slog.Error("failed to create task template", "error", err)
return false
}
pb.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
TaskID: t.ID,
Draft: t,
Focus: model.EditFieldTitle,
}))
slog.Info("new tiki draft started from plugin", "task_id", t.ID, "plugin", pb.pluginDef.Name)
return true
}
func (pb *pluginBase) handleDeleteTask(filteredTasks func(int) []*task.Task) bool {
taskID := pb.getSelectedTaskID(filteredTasks)
if taskID == "" {
return false
}
pb.taskStore.DeleteTask(taskID)
return true
}
func filterTasksBySearch(tasks []*task.Task, searchMap map[string]bool) []*task.Task {
if searchMap == nil {
return tasks
}
filtered := make([]*task.Task, 0, len(tasks))
for _, t := range tasks {
if searchMap[t.ID] {
filtered = append(filtered, t)
}
}
return filtered
}

View file

@ -8,11 +8,9 @@ import (
// newMockNavigationController creates a new mock navigation controller
func newMockNavigationController() *NavigationController {
// Create a real NavigationController but we won't use most of its methods in tests
// The key is that TaskController only calls SuspendAndEdit which we can ignore in tests
return &NavigationController{
// minimal initialization - only used to satisfy type checking
app: nil, // Unit tests don't need the tview.Application
app: nil, // unit tests don't need the tview.Application
navState: newViewStack(),
}
}

View file

@ -9,11 +9,13 @@ const (
paramDraftTask = "draftTask"
paramFocus = "focus"
paramDescOnly = "descOnly"
paramReadOnly = "readOnly"
)
// TaskDetailParams are params for TaskDetailViewID.
type TaskDetailParams struct {
TaskID string
TaskID string
ReadOnly bool
}
// EncodeTaskDetailParams converts typed params into a navigation params map.
@ -21,9 +23,13 @@ func EncodeTaskDetailParams(p TaskDetailParams) map[string]interface{} {
if p.TaskID == "" {
return nil
}
return map[string]interface{}{
m := map[string]interface{}{
paramTaskID: p.TaskID,
}
if p.ReadOnly {
m[paramReadOnly] = true
}
return m
}
// DecodeTaskDetailParams converts a navigation params map into typed params.
@ -35,6 +41,9 @@ func DecodeTaskDetailParams(params map[string]interface{}) TaskDetailParams {
if id, ok := params[paramTaskID].(string); ok {
p.TaskID = id
}
if readOnly, ok := params[paramReadOnly].(bool); ok {
p.ReadOnly = readOnly
}
return p
}

View file

@ -95,6 +95,35 @@ func TestTaskDetailParams_DecodeInvalidParams(t *testing.T) {
}
}
func TestTaskDetailParams_ReadOnlyRoundTrip(t *testing.T) {
// ReadOnly=true should survive encode/decode
params := TaskDetailParams{
TaskID: "TIKI-1",
ReadOnly: true,
}
encoded := EncodeTaskDetailParams(params)
decoded := DecodeTaskDetailParams(encoded)
if !decoded.ReadOnly {
t.Error("round-trip failed: ReadOnly = false, want true")
}
// ReadOnly=false should not be stored (keeps params map clean)
paramsNoRO := TaskDetailParams{
TaskID: "TIKI-2",
}
encodedNoRO := EncodeTaskDetailParams(paramsNoRO)
if _, exists := encodedNoRO[paramReadOnly]; exists {
t.Error("ReadOnly=false should not be stored in encoded params")
}
decodedNoRO := DecodeTaskDetailParams(encodedNoRO)
if decodedNoRO.ReadOnly {
t.Error("ReadOnly should default to false")
}
}
func TestTaskEditParams_EncodeDecodeRoundTrip(t *testing.T) {
draftTask := &taskpkg.Task{
ID: "TIKI-42",

View file

@ -68,8 +68,8 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
switch viewID {
case model.TaskDetailViewID:
taskID := model.DecodeTaskDetailParams(params).TaskID
v = taskdetail.NewTaskDetailView(f.taskStore, taskID, f.imageManager, f.mermaidOpts)
detailParams := model.DecodeTaskDetailParams(params)
v = taskdetail.NewTaskDetailView(f.taskStore, detailParams.TaskID, detailParams.ReadOnly, f.imageManager, f.mermaidOpts)
case model.TaskEditViewID:
editParams := model.DecodeTaskEditParams(params)

View file

@ -27,7 +27,7 @@ func TestBuildMetadataColumns_Structure(t *testing.T) {
UpdatedAt: time.Date(2024, 1, 2, 14, 30, 0, 0, time.UTC),
}
view := NewTaskDetailView(s, task.ID, nil, nil)
view := NewTaskDetailView(s, task.ID, false, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()
@ -61,7 +61,7 @@ func TestBuildMetadataColumns_Column1Fields(t *testing.T) {
Points: 5,
}
view := NewTaskDetailView(s, task.ID, nil, nil)
view := NewTaskDetailView(s, task.ID, false, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()
@ -89,7 +89,7 @@ func TestBuildMetadataColumns_Column2Fields(t *testing.T) {
UpdatedAt: time.Date(2024, 1, 2, 14, 30, 0, 0, time.UTC),
}
view := NewTaskDetailView(s, task.ID, nil, nil)
view := NewTaskDetailView(s, task.ID, false, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()
@ -113,7 +113,7 @@ func TestBuildMetadataColumns_Column3Fields(t *testing.T) {
Title: "Test Task",
}
view := NewTaskDetailView(s, task.ID, nil, nil)
view := NewTaskDetailView(s, task.ID, false, nil, nil)
view.SetFallbackTask(task)
colors := config.GetColors()

View file

@ -28,8 +28,13 @@ type TaskDetailView struct {
storeListenerID int
}
// NewTaskDetailView creates a task detail view in read-only mode
func NewTaskDetailView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager, mermaidOpts *nav.MermaidOptions) *TaskDetailView {
// NewTaskDetailView creates a task detail view.
// When readOnly is true, only fullscreen is available — no editing actions.
func NewTaskDetailView(taskStore store.Store, taskID string, readOnly bool, imageManager *navtview.ImageManager, mermaidOpts *nav.MermaidOptions) *TaskDetailView {
registry := controller.TaskDetailViewActions()
if readOnly {
registry = controller.ReadonlyTaskDetailViewActions()
}
tv := &TaskDetailView{
Base: Base{
taskStore: taskStore,
@ -37,7 +42,7 @@ func NewTaskDetailView(taskStore store.Store, taskID string, imageManager *navtv
imageManager: imageManager,
mermaidOpts: mermaidOpts,
},
registry: controller.TaskDetailViewActions(),
registry: registry,
viewID: model.TaskDetailViewID,
}