tiki/controller/plugin.go
2026-04-18 22:27:14 -04:00

358 lines
11 KiB
Go

package controller
import (
"context"
"log/slog"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/plugin"
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/service"
"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,
mutationGate *service.TaskMutationGate,
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
statusline *model.StatuslineConfig,
schema ruki.Schema,
) *PluginController {
pc := &PluginController{
pluginBase: pluginBase{
taskStore: taskStore,
mutationGate: mutationGate,
pluginConfig: pluginConfig,
pluginDef: pluginDef,
navController: navController,
statusline: statusline,
registry: PluginViewActions(),
schema: schema,
},
}
// 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)
}
action := Action{
ID: pluginActionID(a.Rune),
Key: tcell.KeyRune,
Rune: a.Rune,
Label: a.Label,
ShowInHeader: a.ShowInHeader,
}
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
action.IsEnabled = selectionRequired
}
pc.registry.Register(action)
}
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
}
executor := pc.newExecutor()
allTasks := pc.taskStore.GetAllTasks()
input := ruki.ExecutionInput{}
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
if pa.Action.IsSelect() && !pa.Action.IsPipe() {
// plain SELECT actions are side-effect only — pass task ID if available but don't require it
if taskID != "" {
input.SelectedTaskID = taskID
}
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
if taskID == "" {
return false
}
input.SelectedTaskID = taskID
}
if pa.Action.IsCreate() {
template, err := pc.taskStore.NewTaskTemplate()
if err != nil {
slog.Error("failed to create task template for plugin action", "key", string(r), "error", err)
return false
}
input.CreateTemplate = template
}
result, err := executor.Execute(pa.Action, allTasks, input)
if err != nil {
slog.Error("failed to execute plugin action", "task_id", taskID, "key", string(r), "error", err)
return false
}
ctx := context.Background()
switch {
case result.Select != nil:
slog.Info("select plugin action executed", "task_id", taskID, "key", string(r),
"label", pa.Label, "matched", len(result.Select.Tasks))
return true
case result.Update != nil:
for _, updated := range result.Update.Updated {
if err := pc.mutationGate.UpdateTask(ctx, updated); err != nil {
slog.Error("failed to update task after plugin action", "task_id", updated.ID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
pc.ensureSearchResultIncludesTask(updated)
}
case result.Create != nil:
if err := pc.mutationGate.CreateTask(ctx, result.Create.Task); err != nil {
slog.Error("failed to create task from plugin action", "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
case result.Delete != nil:
for _, deleted := range result.Delete.Deleted {
if err := pc.mutationGate.DeleteTask(ctx, deleted); err != nil {
slog.Error("failed to delete task from plugin action", "task_id", deleted.ID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
}
case result.Pipe != nil:
for _, row := range result.Pipe.Rows {
if err := service.ExecutePipeCommand(ctx, result.Pipe.Command, row); err != nil {
slog.Error("pipe command failed", "command", result.Pipe.Command, "args", row, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
// non-fatal: continue remaining rows
}
}
case result.Clipboard != nil:
if err := service.ExecuteClipboardPipe(result.Clipboard.Rows); err != nil {
slog.Error("clipboard pipe failed", "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return false
}
if pc.statusline != nil {
pc.statusline.SetMessage("copied to clipboard", model.MessageLevelInfo, true)
}
}
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
}
actionStmt := pc.pluginDef.Lanes[targetLane].Action
if actionStmt == nil {
return false
}
allTasks := pc.taskStore.GetAllTasks()
executor := pc.newExecutor()
result, err := executor.Execute(actionStmt, allTasks, ruki.ExecutionInput{SelectedTaskID: taskID})
if err != nil {
slog.Error("failed to execute lane action", "task_id", taskID, "error", err)
return false
}
if result.Update == nil || len(result.Update.Updated) == 0 {
return false
}
updated := result.Update.Updated[0]
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
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
}
filterStmt := pc.pluginDef.Lanes[lane].Filter
allTasks := pc.taskStore.GetAllTasks()
var filtered []*task.Task
if filterStmt == nil {
filtered = allTasks
} else {
executor := pc.newExecutor()
result, err := executor.Execute(filterStmt, allTasks)
if err != nil {
slog.Error("failed to execute lane filter", "lane", lane, "error", err)
return nil
}
filtered = result.Select.Tasks
}
// narrow by search results if active
if searchResults := pc.pluginConfig.GetSearchResults(); searchResults != nil {
searchTaskMap := make(map[string]bool, len(searchResults))
for _, result := range searchResults {
searchTaskMap[result.Task.ID] = true
}
filtered = filterTasksBySearch(filtered, searchTaskMap)
}
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())
}