tiki/controller/plugin.go

429 lines
13 KiB
Go
Raw Normal View History

2026-01-17 16:08:53 +00:00
package controller
import (
2026-04-05 16:05:39 +00:00
"context"
2026-01-17 16:08:53 +00:00
"log/slog"
"strings"
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"
2026-04-09 02:46:46 +00:00
"github.com/boolean-maybe/tiki/ruki"
2026-04-05 02:05:47 +00:00
"github.com/boolean-maybe/tiki/service"
2026-01-17 16:08:53 +00:00
"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,
2026-04-05 02:05:47 +00:00
mutationGate *service.TaskMutationGate,
2026-01-17 16:08:53 +00:00
pluginConfig *model.PluginConfig,
pluginDef *plugin.TikiPlugin,
navController *NavigationController,
2026-03-25 03:27:02 +00:00
statusline *model.StatuslineConfig,
2026-04-09 02:46:46 +00:00
schema ruki.Schema,
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,
2026-04-05 02:05:47 +00:00
mutationGate: mutationGate,
2026-03-20 17:02:53 +00:00
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-04-09 02:46:46 +00:00
schema: schema,
2026-03-20 17:02:53 +00:00
},
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)
}
2026-04-19 02:27:14 +00:00
action := Action{
2026-02-10 16:29:43 +00:00
ID: pluginActionID(a.Rune),
Key: tcell.KeyRune,
Rune: a.Rune,
Label: a.Label,
2026-04-19 02:27:14 +00:00
ShowInHeader: a.ShowInHeader,
}
if a.Action != nil && (a.Action.IsUpdate() || a.Action.IsDelete() || a.Action.IsPipe()) {
action.IsEnabled = selectionRequired
}
pc.registry.Register(action)
2026-02-10 16:29:43 +00:00
}
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-04-19 20:32:10 +00:00
// getPluginAction looks up a plugin action by ActionID.
func (pc *PluginController) getPluginAction(actionID ActionID) (*plugin.PluginAction, rune, bool) {
r := getPluginActionRune(actionID)
if r == 0 {
return nil, 0, false
}
2026-02-10 16:29:43 +00:00
for i := range pc.pluginDef.Actions {
if pc.pluginDef.Actions[i].Rune == r {
2026-04-19 20:32:10 +00:00
return &pc.pluginDef.Actions[i], r, true
2026-02-10 16:29:43 +00:00
}
}
2026-04-19 20:32:10 +00:00
return nil, 0, false
}
2026-04-09 02:46:46 +00:00
2026-04-19 20:32:10 +00:00
// buildExecutionInput builds the base ExecutionInput for an action, performing
// selection/create-template preflight. Returns ok=false if the action can't run.
func (pc *PluginController) buildExecutionInput(pa *plugin.PluginAction) (ruki.ExecutionInput, bool) {
2026-04-09 02:46:46 +00:00
input := ruki.ExecutionInput{}
2026-03-20 17:02:53 +00:00
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
2026-04-09 02:46:46 +00:00
2026-04-14 23:08:54 +00:00
if pa.Action.IsSelect() && !pa.Action.IsPipe() {
if taskID != "" {
input.SelectedTaskID = taskID
}
} else if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() {
2026-04-09 02:46:46 +00:00
if taskID == "" {
2026-04-19 20:32:10 +00:00
return input, false
2026-04-09 02:46:46 +00:00
}
input.SelectedTaskID = taskID
2026-02-10 16:29:43 +00:00
}
2026-04-09 02:46:46 +00:00
if pa.Action.IsCreate() {
template, err := pc.taskStore.NewTaskTemplate()
if err != nil {
2026-04-19 20:32:10 +00:00
slog.Error("failed to create task template for plugin action", "error", err)
return input, false
2026-04-09 02:46:46 +00:00
}
input.CreateTemplate = template
2026-02-10 16:29:43 +00:00
}
2026-04-19 20:32:10 +00:00
return input, true
}
// executeAndApply runs the executor and applies the result (store mutations, pipe, clipboard).
func (pc *PluginController) executeAndApply(pa *plugin.PluginAction, input ruki.ExecutionInput, r rune) bool {
executor := pc.newExecutor()
allTasks := pc.taskStore.GetAllTasks()
2026-04-09 02:46:46 +00:00
result, err := executor.Execute(pa.Action, allTasks, input)
2026-02-10 16:29:43 +00:00
if err != nil {
2026-04-19 20:32:10 +00:00
slog.Error("failed to execute plugin action", "task_id", input.SelectedTaskID, "key", string(r), "error", err)
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
2026-02-10 16:29:43 +00:00
return false
}
2026-04-09 02:46:46 +00:00
ctx := context.Background()
switch {
2026-04-14 23:08:54 +00:00
case result.Select != nil:
2026-04-19 20:32:10 +00:00
slog.Info("select plugin action executed", "task_id", input.SelectedTaskID, "key", string(r),
2026-04-14 23:08:54 +00:00
"label", pa.Label, "matched", len(result.Select.Tasks))
return true
2026-04-09 02:46:46 +00:00
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
}
2026-04-05 02:05:47 +00:00
}
2026-04-14 22:13:50 +00:00
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)
}
}
}
2026-04-15 01:15:09 +00:00
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)
}
2026-02-10 16:29:43 +00:00
}
2026-04-19 20:32:10 +00:00
slog.Info("plugin action applied", "task_id", input.SelectedTaskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name)
2026-02-10 16:29:43 +00:00
return true
}
2026-04-19 20:32:10 +00:00
// handlePluginAction applies a plugin shortcut action to the currently selected task.
func (pc *PluginController) handlePluginAction(r rune) bool {
pa, _, ok := pc.getPluginAction(pluginActionID(r))
if !ok {
return false
}
input, ok := pc.buildExecutionInput(pa)
if !ok {
return false
}
return pc.executeAndApply(pa, input, r)
}
// GetActionInputSpec returns the prompt and input type for an action, if it has input.
func (pc *PluginController) GetActionInputSpec(actionID ActionID) (string, ruki.ValueType, bool) {
pa, _, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return "", 0, false
}
return pa.Label + ": ", pa.InputType, true
}
// CanStartActionInput checks whether an input-backed action can currently run
// (selection/create-template preflight passes).
func (pc *PluginController) CanStartActionInput(actionID ActionID) (string, ruki.ValueType, bool) {
pa, _, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return "", 0, false
}
if _, ok := pc.buildExecutionInput(pa); !ok {
return "", 0, false
}
return pa.Label + ": ", pa.InputType, true
}
// HandleActionInput handles submitted text for an input-backed action.
func (pc *PluginController) HandleActionInput(actionID ActionID, text string) InputSubmitResult {
pa, r, ok := pc.getPluginAction(actionID)
if !ok || !pa.HasInput {
return InputKeepEditing
}
val, err := ruki.ParseScalarValue(pa.InputType, text)
if err != nil {
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
return InputKeepEditing
}
input, ok := pc.buildExecutionInput(pa)
if !ok {
return InputClose
}
input.InputValue = val
input.HasInput = true
pc.executeAndApply(pa, input, r)
return InputClose
}
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
}
2026-04-09 02:46:46 +00:00
actionStmt := pc.pluginDef.Lanes[targetLane].Action
if actionStmt == nil {
2026-01-21 21:16:54 +00:00
return false
}
2026-04-09 02:46:46 +00:00
allTasks := pc.taskStore.GetAllTasks()
executor := pc.newExecutor()
result, err := executor.Execute(actionStmt, allTasks, ruki.ExecutionInput{SelectedTaskID: taskID})
2026-01-21 21:16:54 +00:00
if err != nil {
2026-04-09 02:46:46 +00:00
slog.Error("failed to execute lane action", "task_id", taskID, "error", err)
2026-01-21 21:16:54 +00:00
return false
}
2026-04-09 02:46:46 +00:00
if result.Update == nil || len(result.Update.Updated) == 0 {
return false
}
updated := result.Update.Updated[0]
2026-04-05 16:05:39 +00:00
if err := pc.mutationGate.UpdateTask(context.Background(), 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-04-05 02:05:47 +00:00
if pc.statusline != nil {
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
}
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-04-09 02:46:46 +00:00
filterStmt := pc.pluginDef.Lanes[lane].Filter
2026-01-17 16:08:53 +00:00
allTasks := pc.taskStore.GetAllTasks()
var filtered []*task.Task
2026-04-09 02:46:46 +00:00
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
2026-01-17 16:08:53 +00:00
}
2026-04-09 02:46:46 +00:00
filtered = result.Select.Tasks
2026-01-17 16:08:53 +00:00
}
2026-04-09 02:46:46 +00:00
// narrow by search results if active
if searchResults := pc.pluginConfig.GetSearchResults(); searchResults != nil {
2026-01-21 21:16:54 +00:00
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
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())
}