tiki/controller/plugin.go

274 lines
7.5 KiB
Go
Raw Normal View History

2026-01-17 16:08:53 +00:00
package controller
import (
"log/slog"
"strings"
"time"
2026-02-10 16:29:43 +00:00
"github.com/gdamore/tcell/v2"
2026-01-17 16:08:53 +00:00
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
// PluginController handles plugin view actions: navigation, open, create, delete.
type PluginController struct {
2026-03-20 17:02:53 +00:00
pluginBase
2026-01-17 16:08:53 +00:00
}
// NewPluginController creates a plugin controller
func NewPluginController(
taskStore store.Store,
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
2026-03-25 03:27:02 +00:00
statusline *model.StatuslineConfig,
2026-01-17 16:08:53 +00:00
) *PluginController {
2026-02-10 16:29:43 +00:00
pc := &PluginController{
2026-03-20 17:02:53 +00:00
pluginBase: pluginBase{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
2026-03-25 03:27:02 +00:00
statusline: statusline,
2026-03-20 17:02:53 +00:00
registry: PluginViewActions(),
},
2026-01-17 16:08:53 +00:00
}
2026-02-10 16:29:43 +00:00
// register plugin-specific shortcut actions, warn about conflicts
globalActions := DefaultGlobalActions()
for _, a := range pluginDef.Actions {
if existing, ok := globalActions.LookupRune(a.Rune); ok {
slog.Warn("plugin action key shadows global action and will be unreachable",
"plugin", pluginDef.Name, "key", string(a.Rune),
"plugin_action", a.Label, "global_action", existing.Label)
} else if existing, ok := pc.registry.LookupRune(a.Rune); ok {
slog.Warn("plugin action key shadows built-in action and will be unreachable",
"plugin", pluginDef.Name, "key", string(a.Rune),
"plugin_action", a.Label, "built_in_action", existing.Label)
}
pc.registry.Register(Action{
ID: pluginActionID(a.Rune),
Key: tcell.KeyRune,
Rune: a.Rune,
Label: a.Label,
ShowInHeader: true,
})
}
return pc
}
const pluginActionPrefix = "plugin_action:"
// pluginActionID returns an ActionID for a plugin shortcut action key.
func pluginActionID(r rune) ActionID {
return ActionID(pluginActionPrefix + string(r))
}
// getPluginActionRune extracts the rune from a plugin action ID.
// Returns 0 if the ID is not a plugin action.
func getPluginActionRune(id ActionID) rune {
s := string(id)
if !strings.HasPrefix(s, pluginActionPrefix) {
return 0
}
rest := s[len(pluginActionPrefix):]
if len(rest) == 0 {
return 0
}
runes := []rune(rest)
if len(runes) != 1 {
return 0
}
return runes[0]
2026-01-17 16:08:53 +00:00
}
2026-03-18 18:53:15 +00:00
// ShowNavigation returns true — regular plugin views show plugin navigation keys.
func (pc *PluginController) ShowNavigation() bool { return true }
2026-03-20 17:02:53 +00:00
// EnsureFirstNonEmptyLaneSelection delegates to pluginBase with this controller's filter.
func (pc *PluginController) EnsureFirstNonEmptyLaneSelection() bool {
return pc.pluginBase.EnsureFirstNonEmptyLaneSelection(pc.GetFilteredTasksForLane)
}
2026-01-17 16:08:53 +00:00
// HandleAction processes a plugin action
func (pc *PluginController) HandleAction(actionID ActionID) bool {
switch actionID {
case ActionNavUp:
2026-03-20 17:02:53 +00:00
return pc.handleNav("up", pc.GetFilteredTasksForLane)
2026-01-17 16:08:53 +00:00
case ActionNavDown:
2026-03-20 17:02:53 +00:00
return pc.handleNav("down", pc.GetFilteredTasksForLane)
2026-01-17 16:08:53 +00:00
case ActionNavLeft:
2026-03-20 17:02:53 +00:00
return pc.handleNav("left", pc.GetFilteredTasksForLane)
2026-01-17 16:08:53 +00:00
case ActionNavRight:
2026-03-20 17:02:53 +00:00
return pc.handleNav("right", pc.GetFilteredTasksForLane)
2026-01-21 21:16:54 +00:00
case ActionMoveTaskLeft:
return pc.handleMoveTask(-1)
case ActionMoveTaskRight:
return pc.handleMoveTask(1)
2026-01-17 16:08:53 +00:00
case ActionOpenFromPlugin:
2026-03-20 17:02:53 +00:00
return pc.handleOpenTask(pc.GetFilteredTasksForLane)
2026-01-17 16:08:53 +00:00
case ActionNewTask:
return pc.handleNewTask()
case ActionDeleteTask:
2026-03-20 17:02:53 +00:00
return pc.handleDeleteTask(pc.GetFilteredTasksForLane)
2026-01-17 16:08:53 +00:00
case ActionToggleViewMode:
2026-03-20 17:02:53 +00:00
pc.pluginConfig.ToggleViewMode()
return true
2026-01-17 16:08:53 +00:00
default:
2026-02-10 16:29:43 +00:00
if r := getPluginActionRune(actionID); r != 0 {
return pc.handlePluginAction(r)
}
2026-01-17 16:08:53 +00:00
return false
}
}
2026-03-20 17:02:53 +00:00
// 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)
})
2026-01-17 16:08:53 +00:00
}
2026-02-10 16:29:43 +00:00
// handlePluginAction applies a plugin shortcut action to the currently selected task.
func (pc *PluginController) handlePluginAction(r rune) bool {
// find the matching action definition
var pa *plugin.PluginAction
for i := range pc.pluginDef.Actions {
if pc.pluginDef.Actions[i].Rune == r {
pa = &pc.pluginDef.Actions[i]
break
}
}
if pa == nil {
return false
}
2026-03-20 17:02:53 +00:00
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
2026-02-10 16:29:43 +00:00
if taskID == "" {
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
2026-02-10 21:18:05 +00:00
updated, err := plugin.ApplyLaneAction(taskItem, pa.Action, currentUser)
2026-02-10 16:29:43 +00:00
if err != nil {
slog.Error("failed to apply plugin action", "task_id", taskID, "key", string(r), "error", err)
return false
}
if err := pc.taskStore.UpdateTask(updated); err != nil {
slog.Error("failed to update task after plugin action", "task_id", taskID, "key", string(r), "error", err)
return false
}
pc.ensureSearchResultIncludesTask(updated)
slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
return true
}
2026-01-21 21:16:54 +00:00
func (pc *PluginController) handleMoveTask(offset int) bool {
2026-03-20 17:02:53 +00:00
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
2026-01-21 21:16:54 +00:00
if taskID == "" {
return false
}
2026-02-10 21:18:05 +00:00
if pc.pluginDef == nil || len(pc.pluginDef.Lanes) == 0 {
2026-01-21 21:16:54 +00:00
return false
}
2026-02-10 21:18:05 +00:00
currentLane := pc.pluginConfig.GetSelectedLane()
targetLane := currentLane + offset
if targetLane < 0 || targetLane >= len(pc.pluginDef.Lanes) {
2026-01-21 21:16:54 +00:00
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
2026-02-10 21:18:05 +00:00
updated, err := plugin.ApplyLaneAction(taskItem, pc.pluginDef.Lanes[targetLane].Action, currentUser)
2026-01-21 21:16:54 +00:00
if err != nil {
2026-02-10 21:18:05 +00:00
slog.Error("failed to apply lane action", "task_id", taskID, "error", err)
2026-01-21 21:16:54 +00:00
return false
}
if err := pc.taskStore.UpdateTask(updated); err != nil {
2026-02-10 21:18:05 +00:00
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
2026-01-21 21:16:54 +00:00
return false
}
pc.ensureSearchResultIncludesTask(updated)
2026-03-20 17:02:53 +00:00
pc.selectTaskInLane(targetLane, taskID, pc.GetFilteredTasksForLane)
2026-01-21 21:16:54 +00:00
return true
}
2026-02-10 21:18:05 +00:00
// GetFilteredTasksForLane returns tasks filtered and sorted for a specific lane.
func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
2026-01-21 21:16:54 +00:00
if pc.pluginDef == nil {
return nil
}
2026-02-10 21:18:05 +00:00
if lane < 0 || lane >= len(pc.pluginDef.Lanes) {
2026-01-21 21:16:54 +00:00
return nil
}
2026-03-20 17:02:53 +00:00
// check if search is active
2026-01-17 16:08:53 +00:00
searchResults := pc.pluginConfig.GetSearchResults()
allTasks := pc.taskStore.GetAllTasks()
now := time.Now()
2026-01-21 21:16:54 +00:00
currentUser := getCurrentUserName(pc.taskStore)
2026-01-17 16:08:53 +00:00
var filtered []*task.Task
for _, task := range allTasks {
2026-02-10 21:18:05 +00:00
laneFilter := pc.pluginDef.Lanes[lane].Filter
if laneFilter == nil || laneFilter.Evaluate(task, now, currentUser) {
2026-01-17 16:08:53 +00:00
filtered = append(filtered, task)
}
}
2026-01-21 21:16:54 +00:00
if searchResults != nil {
searchTaskMap := make(map[string]bool, len(searchResults))
for _, result := range searchResults {
searchTaskMap[result.Task.ID] = true
}
filtered = filterTasksBySearch(filtered, searchTaskMap)
}
2026-01-17 16:08:53 +00:00
if len(pc.pluginDef.Sort) > 0 {
plugin.SortTasks(filtered, pc.pluginDef.Sort)
}
return filtered
}
2026-01-21 21:16:54 +00:00
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
if updated == nil {
return
}
searchResults := pc.pluginConfig.GetSearchResults()
if searchResults == nil {
return
}
for _, result := range searchResults {
if result.Task != nil && result.Task.ID == updated.ID {
return
}
}
searchResults = append(searchResults, task.SearchResult{
Task: updated,
Score: 1.0,
})
pc.pluginConfig.SetSearchResults(searchResults, pc.pluginConfig.GetSearchQuery())
}