tiki/controller/plugin.go
2026-03-24 23:27:02 -04:00

273 lines
7.5 KiB
Go

package controller
import (
"log/slog"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"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 {
pluginBase
}
// NewPluginController creates a plugin controller
func NewPluginController(
taskStore store.Store,
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
) *PluginController {
pc := &PluginController{
pluginBase: pluginBase{
taskStore: taskStore,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
statusline: statusline,
registry: PluginViewActions(),
},
}
// 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]
}
// 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", pc.GetFilteredTasksForLane)
case ActionNavDown:
return pc.handleNav("down", pc.GetFilteredTasksForLane)
case ActionNavLeft:
return pc.handleNav("left", pc.GetFilteredTasksForLane)
case ActionNavRight:
return pc.handleNav("right", pc.GetFilteredTasksForLane)
case ActionMoveTaskLeft:
return pc.handleMoveTask(-1)
case ActionMoveTaskRight:
return pc.handleMoveTask(1)
case ActionOpenFromPlugin:
return pc.handleOpenTask(pc.GetFilteredTasksForLane)
case ActionNewTask:
return pc.handleNewTask()
case ActionDeleteTask:
return pc.handleDeleteTask(pc.GetFilteredTasksForLane)
case ActionToggleViewMode:
pc.pluginConfig.ToggleViewMode()
return true
default:
if r := getPluginActionRune(actionID); r != 0 {
return pc.handlePluginAction(r)
}
return false
}
}
// 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.
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
}
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
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
}
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
}
func (pc *PluginController) handleMoveTask(offset int) bool {
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if taskID == "" {
return false
}
if pc.pluginDef == nil || len(pc.pluginDef.Lanes) == 0 {
return false
}
currentLane := pc.pluginConfig.GetSelectedLane()
targetLane := currentLane + offset
if targetLane < 0 || targetLane >= len(pc.pluginDef.Lanes) {
return false
}
taskItem := pc.taskStore.GetTask(taskID)
if taskItem == nil {
return false
}
currentUser := getCurrentUserName(pc.taskStore)
updated, err := plugin.ApplyLaneAction(taskItem, pc.pluginDef.Lanes[targetLane].Action, currentUser)
if err != nil {
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 lane move", "task_id", taskID, "error", err)
return false
}
pc.ensureSearchResultIncludesTask(updated)
pc.selectTaskInLane(targetLane, taskID, pc.GetFilteredTasksForLane)
return true
}
// 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 lane < 0 || lane >= len(pc.pluginDef.Lanes) {
return nil
}
// check if search is active
searchResults := pc.pluginConfig.GetSearchResults()
allTasks := pc.taskStore.GetAllTasks()
now := time.Now()
currentUser := getCurrentUserName(pc.taskStore)
var filtered []*task.Task
for _, task := range allTasks {
laneFilter := pc.pluginDef.Lanes[lane].Filter
if laneFilter == nil || laneFilter.Evaluate(task, now, currentUser) {
filtered = append(filtered, task)
}
}
if searchResults != nil {
searchTaskMap := make(map[string]bool, len(searchResults))
for _, result := range searchResults {
searchTaskMap[result.Task.ID] = true
}
filtered = filterTasksBySearch(filtered, searchTaskMap)
}
if len(pc.pluginDef.Sort) > 0 {
plugin.SortTasks(filtered, pc.pluginDef.Sort)
}
return filtered
}
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())
}