diff --git a/controller/actions.go b/controller/actions.go index 479976f..51d477b 100644 --- a/controller/actions.go +++ b/controller/actions.go @@ -92,6 +92,7 @@ func InitPluginActions(plugins []PluginInfo) { if p.Key == 0 && p.Rune == 0 { continue // skip plugins without key binding } + pluginViewID := model.MakePluginViewID(p.Name) pluginActionRegistry.Register(Action{ ID: ActionID("plugin:" + p.Name), Key: p.Key, @@ -99,6 +100,12 @@ func InitPluginActions(plugins []PluginInfo) { Modifier: p.Modifier, Label: p.Name, ShowInHeader: true, + IsEnabled: func(view *ViewEntry, _ View) bool { + if view == nil { + return true + } + return view.ViewID != pluginViewID + }, }) } } @@ -124,12 +131,14 @@ func GetPluginNameFromAction(id ActionID) string { // Action represents a keyboard shortcut binding type Action struct { - ID ActionID - Key tcell.Key - Rune rune // for letter keys (when Key == tcell.KeyRune) - Label string - Modifier tcell.ModMask - ShowInHeader bool // whether to display in header bar + ID ActionID + Key tcell.Key + Rune rune // for letter keys (when Key == tcell.KeyRune) + Label string + Modifier tcell.ModMask + ShowInHeader bool // whether to display in header bar + HideFromPalette bool // when true, action is excluded from the action palette (zero value = visible) + IsEnabled func(view *ViewEntry, activeView View) bool } // keyWithMod is a composite map key for special-key lookups, disambiguating @@ -246,10 +255,19 @@ func (r *ActionRegistry) Match(event *tcell.EventKey) *Action { return nil } +// selectionRequired is an IsEnabled predicate that returns true only when +// the active view has a non-empty selection (for use with plugin/deps actions). +func selectionRequired(_ *ViewEntry, activeView View) bool { + if sv, ok := activeView.(SelectableView); ok { + return sv.GetSelectedID() != "" + } + return false +} + // DefaultGlobalActions returns common actions available in all views func DefaultGlobalActions() *ActionRegistry { r := NewActionRegistry() - r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true}) + r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", ShowInHeader: true, HideFromPalette: true}) r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit", ShowInHeader: true}) r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh", ShowInHeader: true}) r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Hide Header", ShowInHeader: true}) @@ -267,6 +285,39 @@ func (r *ActionRegistry) GetHeaderActions() []Action { return result } +// GetPaletteActions returns palette-visible actions, deduped by ActionID (first registration wins). +func (r *ActionRegistry) GetPaletteActions() []Action { + if r == nil { + return nil + } + seen := make(map[ActionID]bool) + var result []Action + for _, a := range r.actions { + if a.HideFromPalette { + continue + } + if seen[a.ID] { + continue + } + seen[a.ID] = true + result = append(result, a) + } + return result +} + +// ContainsID returns true if the registry has an action with the given ID. +func (r *ActionRegistry) ContainsID(id ActionID) bool { + if r == nil { + return false + } + for _, a := range r.actions { + if a.ID == id { + return true + } + } + return false +} + // ToHeaderActions converts the registry's header actions to model.HeaderAction slice. // This bridges the controller→model boundary without requiring callers to do the mapping. func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction { @@ -293,15 +344,22 @@ func (r *ActionRegistry) ToHeaderActions() []model.HeaderAction { func TaskDetailViewActions() *ActionRegistry { r := NewActionRegistry() - r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true}) - r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true}) - r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true}) + taskDetailEnabled := func(view *ViewEntry, _ View) bool { + if view == nil || view.ViewID != model.TaskDetailViewID { + return false + } + return model.DecodeTaskDetailParams(view.Params).TaskID != "" + } + + r.Register(Action{ID: ActionEditTitle, Key: tcell.KeyRune, Rune: 'e', Label: "Edit", ShowInHeader: true, IsEnabled: taskDetailEnabled}) + r.Register(Action{ID: ActionEditDesc, Key: tcell.KeyRune, Rune: 'D', Label: "Edit desc", ShowInHeader: true, IsEnabled: taskDetailEnabled}) + r.Register(Action{ID: ActionEditSource, Key: tcell.KeyRune, Rune: 's', Label: "Edit source", ShowInHeader: true, IsEnabled: taskDetailEnabled}) r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true}) - r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true}) - r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true}) + r.Register(Action{ID: ActionEditDeps, Key: tcell.KeyCtrlD, Modifier: tcell.ModCtrl, Label: "Dependencies", ShowInHeader: true, IsEnabled: taskDetailEnabled}) + r.Register(Action{ID: ActionEditTags, Key: tcell.KeyRune, Rune: 'T', Label: "Edit tags", ShowInHeader: true, IsEnabled: taskDetailEnabled}) if config.GetAIAgent() != "" { - r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true}) + r.Register(Action{ID: ActionChat, Key: tcell.KeyRune, Rune: 'c', Label: "Chat", ShowInHeader: true, IsEnabled: taskDetailEnabled}) } return r @@ -321,8 +379,8 @@ func TaskEditViewActions() *ActionRegistry { r := NewActionRegistry() r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true}) - r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true}) + r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev", ShowInHeader: true, HideFromPalette: true}) return r } @@ -330,15 +388,15 @@ func TaskEditViewActions() *ActionRegistry { // CommonFieldNavigationActions returns actions available in all field editors (Tab/Shift-Tab navigation) func CommonFieldNavigationActions() *ActionRegistry { r := NewActionRegistry() - r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true}) + r.Register(Action{ID: ActionNextField, Key: tcell.KeyTab, Label: "Next field", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevField, Key: tcell.KeyBacktab, Label: "Prev field", ShowInHeader: true, HideFromPalette: true}) return r } // TaskEditTitleActions returns actions available when editing the title field func TaskEditTitleActions() *ActionRegistry { r := NewActionRegistry() - r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true}) + r.Register(Action{ID: ActionQuickSave, Key: tcell.KeyEnter, Label: "Quick Save", ShowInHeader: true, HideFromPalette: true}) r.Register(Action{ID: ActionSaveTask, Key: tcell.KeyCtrlS, Label: "Save", ShowInHeader: true}) r.Merge(CommonFieldNavigationActions()) return r @@ -347,16 +405,16 @@ func TaskEditTitleActions() *ActionRegistry { // TaskEditStatusActions returns actions available when editing the status field func TaskEditStatusActions() *ActionRegistry { r := CommonFieldNavigationActions() - r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true}) + r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true}) return r } // TaskEditTypeActions returns actions available when editing the type field func TaskEditTypeActions() *ActionRegistry { r := CommonFieldNavigationActions() - r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true}) + r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true}) return r } @@ -370,8 +428,8 @@ func TaskEditPriorityActions() *ActionRegistry { // TaskEditAssigneeActions returns actions available when editing the assignee field func TaskEditAssigneeActions() *ActionRegistry { r := CommonFieldNavigationActions() - r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true}) + r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true}) return r } @@ -385,19 +443,19 @@ func TaskEditPointsActions() *ActionRegistry { // TaskEditDueActions returns actions available when editing the due date field func TaskEditDueActions() *ActionRegistry { r := CommonFieldNavigationActions() - r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true}) - r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true}) + r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionClearField, Key: tcell.KeyCtrlU, Label: "Clear", ShowInHeader: true, HideFromPalette: true}) return r } // TaskEditRecurrenceActions returns actions available when editing the recurrence field func TaskEditRecurrenceActions() *ActionRegistry { r := CommonFieldNavigationActions() - r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true}) - r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true}) - r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true}) - r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true}) + r.Register(Action{ID: ActionNextValue, Key: tcell.KeyDown, Label: "Next ↓", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionPrevValue, Key: tcell.KeyUp, Label: "Prev ↑", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "← Part", ShowInHeader: true, HideFromPalette: true}) + r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "Part →", ShowInHeader: true, HideFromPalette: true}) return r } @@ -455,22 +513,22 @@ func GetActionsForField(field model.EditField) *ActionRegistry { func PluginViewActions() *ActionRegistry { r := NewActionRegistry() - // navigation (not shown in header) - r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"}) - r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"}) - r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"}) - r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"}) - r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"}) - r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"}) - r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"}) - r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"}) + // navigation (not shown in header, hidden from palette) + r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true}) + r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true}) + r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true}) + r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true}) + r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true}) + r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true}) + r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true}) + r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true}) // plugin actions (shown in header) - r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true}) - 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}) + r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired}) + r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired}) + r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired}) 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}) + r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired}) 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}) @@ -484,24 +542,24 @@ func PluginViewActions() *ActionRegistry { func DepsViewActions() *ActionRegistry { r := NewActionRegistry() - // navigation (not shown in header) - r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"}) - r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"}) - r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←"}) - r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→"}) - r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑"}) - r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓"}) - r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←"}) - r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→"}) + // navigation (not shown in header, hidden from palette) + r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑", HideFromPalette: true}) + r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓", HideFromPalette: true}) + r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyLeft, Label: "←", HideFromPalette: true}) + r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRight, Label: "→", HideFromPalette: true}) + r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑", HideFromPalette: true}) + r.Register(Action{ID: ActionNavDown, Key: tcell.KeyRune, Rune: 'j', Label: "↓", HideFromPalette: true}) + r.Register(Action{ID: ActionNavLeft, Key: tcell.KeyRune, Rune: 'h', Label: "←", HideFromPalette: true}) + r.Register(Action{ID: ActionNavRight, Key: tcell.KeyRune, Rune: 'l', Label: "→", HideFromPalette: true}) // move task between lanes (shown in header) - 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}) + r.Register(Action{ID: ActionMoveTaskLeft, Key: tcell.KeyLeft, Modifier: tcell.ModShift, Label: "Move ←", ShowInHeader: true, IsEnabled: selectionRequired}) + r.Register(Action{ID: ActionMoveTaskRight, Key: tcell.KeyRight, Modifier: tcell.ModShift, Label: "Move →", ShowInHeader: true, IsEnabled: selectionRequired}) // task actions - r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true}) + r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true, IsEnabled: selectionRequired}) 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}) + r.Register(Action{ID: ActionDeleteTask, Key: tcell.KeyRune, Rune: 'd', Label: "Delete", ShowInHeader: true, IsEnabled: selectionRequired}) // view mode and search r.Register(Action{ID: ActionSearch, Key: tcell.KeyRune, Rune: '/', Label: "Search", ShowInHeader: true}) diff --git a/controller/actions_test.go b/controller/actions_test.go index e45eac4..40f3e7f 100644 --- a/controller/actions_test.go +++ b/controller/actions_test.go @@ -630,3 +630,163 @@ func TestMatchWithModifiers(t *testing.T) { t.Error("M (no modifier) should not match action with Alt-M binding") } } + +func TestGetPaletteActions_HidesMarkedActions(t *testing.T) { + r := NewActionRegistry() + r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"}) + r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back", HideFromPalette: true}) + r.Register(Action{ID: ActionRefresh, Key: tcell.KeyRune, Rune: 'r', Label: "Refresh"}) + + actions := r.GetPaletteActions() + if len(actions) != 2 { + t.Fatalf("expected 2 palette actions, got %d", len(actions)) + } + if actions[0].ID != ActionQuit { + t.Errorf("expected first action to be Quit, got %v", actions[0].ID) + } + if actions[1].ID != ActionRefresh { + t.Errorf("expected second action to be Refresh, got %v", actions[1].ID) + } +} + +func TestGetPaletteActions_DedupsByActionID(t *testing.T) { + r := NewActionRegistry() + r.Register(Action{ID: ActionNavUp, Key: tcell.KeyUp, Label: "↑"}) + r.Register(Action{ID: ActionNavUp, Key: tcell.KeyRune, Rune: 'k', Label: "↑ (vim)"}) + r.Register(Action{ID: ActionNavDown, Key: tcell.KeyDown, Label: "↓"}) + + actions := r.GetPaletteActions() + if len(actions) != 2 { + t.Fatalf("expected 2 deduped actions, got %d", len(actions)) + } + if actions[0].ID != ActionNavUp { + t.Errorf("expected first action to be NavUp, got %v", actions[0].ID) + } + if actions[0].Key != tcell.KeyUp { + t.Error("dedup should keep first registered binding (arrow key), not vim key") + } + if actions[1].ID != ActionNavDown { + t.Errorf("expected second action to be NavDown, got %v", actions[1].ID) + } +} + +func TestGetPaletteActions_NilRegistry(t *testing.T) { + var r *ActionRegistry + if actions := r.GetPaletteActions(); actions != nil { + t.Errorf("expected nil from nil registry, got %v", actions) + } +} + +func TestContainsID(t *testing.T) { + r := NewActionRegistry() + r.Register(Action{ID: ActionQuit, Key: tcell.KeyRune, Rune: 'q', Label: "Quit"}) + r.Register(Action{ID: ActionBack, Key: tcell.KeyEscape, Label: "Back"}) + + if !r.ContainsID(ActionQuit) { + t.Error("expected ContainsID to find ActionQuit") + } + if !r.ContainsID(ActionBack) { + t.Error("expected ContainsID to find ActionBack") + } + if r.ContainsID(ActionRefresh) { + t.Error("expected ContainsID to not find ActionRefresh") + } +} + +func TestContainsID_NilRegistry(t *testing.T) { + var r *ActionRegistry + if r.ContainsID(ActionQuit) { + t.Error("expected false from nil registry") + } +} + +func TestDefaultGlobalActions_BackHiddenFromPalette(t *testing.T) { + registry := DefaultGlobalActions() + paletteActions := registry.GetPaletteActions() + + for _, a := range paletteActions { + if a.ID == ActionBack { + t.Error("ActionBack should be hidden from palette") + } + } + + if !registry.ContainsID(ActionBack) { + t.Error("ActionBack should still be registered in global actions") + } +} + +func TestPluginViewActions_NavHiddenFromPalette(t *testing.T) { + registry := PluginViewActions() + paletteActions := registry.GetPaletteActions() + + navIDs := map[ActionID]bool{ + ActionNavUp: true, ActionNavDown: true, + ActionNavLeft: true, ActionNavRight: true, + } + for _, a := range paletteActions { + if navIDs[a.ID] { + t.Errorf("navigation action %v should be hidden from palette", a.ID) + } + } + + // semantic actions should remain visible + found := map[ActionID]bool{} + for _, a := range paletteActions { + found[a.ID] = true + } + for _, want := range []ActionID{ActionOpenFromPlugin, ActionNewTask, ActionDeleteTask, ActionSearch} { + if !found[want] { + t.Errorf("expected palette-visible action %v", want) + } + } +} + +func TestTaskEditActions_FieldLocalHidden_SaveVisible(t *testing.T) { + registry := TaskEditTitleActions() + paletteActions := registry.GetPaletteActions() + + found := map[ActionID]bool{} + for _, a := range paletteActions { + found[a.ID] = true + } + + if !found[ActionSaveTask] { + t.Error("Save should be palette-visible in task edit") + } + if found[ActionQuickSave] { + t.Error("Quick Save should be hidden from palette") + } + if found[ActionNextField] { + t.Error("Next field should be hidden from palette") + } + if found[ActionPrevField] { + t.Error("Prev field should be hidden from palette") + } +} + +func TestInitPluginActions_ActivePluginDisabled(t *testing.T) { + InitPluginActions([]PluginInfo{ + {Name: "Kanban", Key: tcell.KeyRune, Rune: '1'}, + {Name: "Backlog", Key: tcell.KeyRune, Rune: '2'}, + }) + + registry := GetPluginActions() + actions := registry.GetPaletteActions() + + kanbanViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Kanban")} + backlogViewEntry := &ViewEntry{ViewID: model.MakePluginViewID("Backlog")} + + for _, a := range actions { + if a.ID == "plugin:Kanban" { + if a.IsEnabled == nil { + t.Fatal("expected IsEnabled on plugin:Kanban") + } + if a.IsEnabled(kanbanViewEntry, nil) { + t.Error("Kanban activation should be disabled when Kanban view is active") + } + if !a.IsEnabled(backlogViewEntry, nil) { + t.Error("Kanban activation should be enabled when Backlog view is active") + } + } + } +} diff --git a/controller/interfaces.go b/controller/interfaces.go index bd4232d..4b3fb97 100644 --- a/controller/interfaces.go +++ b/controller/interfaces.go @@ -272,6 +272,13 @@ type RecurrencePartNavigable interface { IsRecurrenceValueFocused() bool } +// ActionChangeNotifier is implemented by views that mutate their action registry +// or live enablement/presentation state while the same view instance stays active. +// RootLayout wires the handler and reruns syncViewContextFromView when fired. +type ActionChangeNotifier interface { + SetActionChangeHandler(handler func()) +} + // ViewInfoProvider is a view that provides its name and description for the header info section type ViewInfoProvider interface { GetViewName() string diff --git a/controller/plugin.go b/controller/plugin.go index 6a4769e..63df2fe 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -55,13 +55,17 @@ func NewPluginController( "plugin", pluginDef.Name, "key", string(a.Rune), "plugin_action", a.Label, "built_in_action", existing.Label) } - pc.registry.Register(Action{ + action := Action{ ID: pluginActionID(a.Rune), Key: tcell.KeyRune, Rune: a.Rune, Label: a.Label, - ShowInHeader: true, - }) + 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 diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 4c24f57..b2ed290 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -116,7 +116,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { return nil, err } InitPluginActionRegistry(plugins) - syncHeaderPluginActions(headerConfig) + viewContext := model.NewViewContext() pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins) // Phase 6.5: Trigger system @@ -163,11 +163,12 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { viewFactory.RegisterPlugin(name, cfg, def, ctrl) }) - headerWidget := header.NewHeaderWidget(headerConfig) + headerWidget := header.NewHeaderWidget(headerConfig, viewContext) statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig) rootLayout := view.NewRootLayout(view.RootLayoutOpts{ Header: headerWidget, HeaderConfig: headerConfig, + ViewContext: viewContext, LayoutModel: layoutModel, ViewFactory: viewFactory, TaskStore: taskStore, @@ -219,12 +220,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { }, nil } -// syncHeaderPluginActions syncs plugin action shortcuts from the controller registry -// into the header model. -func syncHeaderPluginActions(headerConfig *model.HeaderConfig) { - headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions()) -} - // wireOnViewActivated wires focus setters into views as they become active. func wireOnViewActivated(rootLayout *view.RootLayout, app *tview.Application) { rootLayout.SetOnViewActivated(func(v controller.View) { diff --git a/model/header_config.go b/model/header_config.go index dbdc04c..9aee20c 100644 --- a/model/header_config.go +++ b/model/header_config.go @@ -25,17 +25,13 @@ type StatValue struct { Priority int } -// HeaderConfig manages ALL header state - both content AND visibility. +// HeaderConfig manages header visibility and burndown state. +// View identity and actions are now in ViewContext. // Thread-safe model that notifies listeners when state changes. type HeaderConfig struct { mu sync.RWMutex - // Content state - viewActions []HeaderAction - pluginActions []HeaderAction - viewName string // current view name for info section - viewDescription string // current view description for info section - burndown []store.BurndownPoint + burndown []store.BurndownPoint // Visibility state visible bool // current header visibility (may be overridden by fullscreen view) @@ -56,59 +52,6 @@ func NewHeaderConfig() *HeaderConfig { } } -// SetViewActions updates the view-specific header actions -func (hc *HeaderConfig) SetViewActions(actions []HeaderAction) { - hc.mu.Lock() - hc.viewActions = actions - hc.mu.Unlock() - hc.notifyListeners() -} - -// GetViewActions returns the current view's header actions -func (hc *HeaderConfig) GetViewActions() []HeaderAction { - hc.mu.RLock() - defer hc.mu.RUnlock() - return hc.viewActions -} - -// SetPluginActions updates the plugin navigation header actions -func (hc *HeaderConfig) SetPluginActions(actions []HeaderAction) { - hc.mu.Lock() - hc.pluginActions = actions - hc.mu.Unlock() - hc.notifyListeners() -} - -// GetPluginActions returns the plugin navigation header actions -func (hc *HeaderConfig) GetPluginActions() []HeaderAction { - hc.mu.RLock() - defer hc.mu.RUnlock() - return hc.pluginActions -} - -// SetViewInfo sets the current view name and description for the header info section -func (hc *HeaderConfig) SetViewInfo(name, description string) { - hc.mu.Lock() - hc.viewName = name - hc.viewDescription = description - hc.mu.Unlock() - hc.notifyListeners() -} - -// GetViewName returns the current view name -func (hc *HeaderConfig) GetViewName() string { - hc.mu.RLock() - defer hc.mu.RUnlock() - return hc.viewName -} - -// GetViewDescription returns the current view description -func (hc *HeaderConfig) GetViewDescription() string { - hc.mu.RLock() - defer hc.mu.RUnlock() - return hc.viewDescription -} - // SetBurndown updates the burndown chart data func (hc *HeaderConfig) SetBurndown(points []store.BurndownPoint) { hc.mu.Lock() diff --git a/model/header_config_test.go b/model/header_config_test.go index 833f0a9..a2d7454 100644 --- a/model/header_config_test.go +++ b/model/header_config_test.go @@ -6,8 +6,6 @@ import ( "time" "github.com/boolean-maybe/tiki/store" - - "github.com/gdamore/tcell/v2" ) func TestNewHeaderConfig(t *testing.T) { @@ -17,7 +15,6 @@ func TestNewHeaderConfig(t *testing.T) { t.Fatal("NewHeaderConfig() returned nil") } - // Initial visibility should be true if !hc.IsVisible() { t.Error("initial IsVisible() = false, want true") } @@ -26,125 +23,11 @@ func TestNewHeaderConfig(t *testing.T) { t.Error("initial GetUserPreference() = false, want true") } - // Initial collections should be empty - if len(hc.GetViewActions()) != 0 { - t.Error("initial GetViewActions() should be empty") - } - - if len(hc.GetPluginActions()) != 0 { - t.Error("initial GetPluginActions() should be empty") - } - - if hc.GetViewName() != "" { - t.Error("initial GetViewName() should be empty") - } - - if hc.GetViewDescription() != "" { - t.Error("initial GetViewDescription() should be empty") - } - if len(hc.GetBurndown()) != 0 { t.Error("initial GetBurndown() should be empty") } } -func TestHeaderConfig_ViewActions(t *testing.T) { - hc := NewHeaderConfig() - - actions := []HeaderAction{ - { - ID: "action1", - Key: tcell.KeyEnter, - Label: "Enter", - ShowInHeader: true, - }, - { - ID: "action2", - Key: tcell.KeyEscape, - Label: "Esc", - ShowInHeader: true, - }, - } - - hc.SetViewActions(actions) - - got := hc.GetViewActions() - if len(got) != 2 { - t.Errorf("len(GetViewActions()) = %d, want 2", len(got)) - } - - if got[0].ID != "action1" { - t.Errorf("ViewActions[0].ID = %q, want %q", got[0].ID, "action1") - } - - if got[1].ID != "action2" { - t.Errorf("ViewActions[1].ID = %q, want %q", got[1].ID, "action2") - } -} - -func TestHeaderConfig_PluginActions(t *testing.T) { - hc := NewHeaderConfig() - - actions := []HeaderAction{ - { - ID: "plugin1", - Rune: '1', - Label: "Plugin 1", - ShowInHeader: true, - }, - } - - hc.SetPluginActions(actions) - - got := hc.GetPluginActions() - if len(got) != 1 { - t.Errorf("len(GetPluginActions()) = %d, want 1", len(got)) - } - - if got[0].ID != "plugin1" { - t.Errorf("PluginActions[0].ID = %q, want %q", got[0].ID, "plugin1") - } -} - -func TestHeaderConfig_ViewInfo(t *testing.T) { - hc := NewHeaderConfig() - - hc.SetViewInfo("Kanban", "Tasks moving through stages") - - if got := hc.GetViewName(); got != "Kanban" { - t.Errorf("GetViewName() = %q, want %q", got, "Kanban") - } - - if got := hc.GetViewDescription(); got != "Tasks moving through stages" { - t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages") - } - - // update overwrites - hc.SetViewInfo("Backlog", "Upcoming tasks") - - if got := hc.GetViewName(); got != "Backlog" { - t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog") - } - - if got := hc.GetViewDescription(); got != "Upcoming tasks" { - t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks") - } -} - -func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) { - hc := NewHeaderConfig() - - hc.SetViewInfo("Task Detail", "") - - if got := hc.GetViewName(); got != "Task Detail" { - t.Errorf("GetViewName() = %q, want %q", got, "Task Detail") - } - - if got := hc.GetViewDescription(); got != "" { - t.Errorf("GetViewDescription() = %q, want empty", got) - } -} - func TestHeaderConfig_Burndown(t *testing.T) { hc := NewHeaderConfig() @@ -177,18 +60,15 @@ func TestHeaderConfig_Burndown(t *testing.T) { func TestHeaderConfig_Visibility(t *testing.T) { hc := NewHeaderConfig() - // Default should be visible if !hc.IsVisible() { t.Error("default IsVisible() = false, want true") } - // Set invisible hc.SetVisible(false) if hc.IsVisible() { t.Error("IsVisible() after SetVisible(false) = true, want false") } - // Set visible again hc.SetVisible(true) if !hc.IsVisible() { t.Error("IsVisible() after SetVisible(true) = false, want true") @@ -198,12 +78,10 @@ func TestHeaderConfig_Visibility(t *testing.T) { func TestHeaderConfig_UserPreference(t *testing.T) { hc := NewHeaderConfig() - // Default preference should be true if !hc.GetUserPreference() { t.Error("default GetUserPreference() = false, want true") } - // Set preference hc.SetUserPreference(false) if hc.GetUserPreference() { t.Error("GetUserPreference() after SetUserPreference(false) = true, want false") @@ -218,27 +96,21 @@ func TestHeaderConfig_UserPreference(t *testing.T) { func TestHeaderConfig_ToggleUserPreference(t *testing.T) { hc := NewHeaderConfig() - // Initial state initialPref := hc.GetUserPreference() initialVisible := hc.IsVisible() - // Toggle hc.ToggleUserPreference() - // Preference should be toggled if hc.GetUserPreference() == initialPref { t.Error("ToggleUserPreference() did not toggle preference") } - // Visible should match new preference if hc.IsVisible() != hc.GetUserPreference() { t.Error("visible state should match preference after toggle") } - // Toggle back hc.ToggleUserPreference() - // Should return to initial state if hc.GetUserPreference() != initialPref { t.Error("ToggleUserPreference() twice did not return to initial state") } @@ -258,14 +130,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) { listenerID := hc.AddListener(listener) - // Test various operations trigger notification tests := []struct { name string action func() }{ - {"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }}, - {"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }}, - {"SetViewInfo", func() { hc.SetViewInfo("Test", "desc") }}, {"SetBurndown", func() { hc.SetBurndown([]store.BurndownPoint{{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}}) }}, {"SetVisible", func() { hc.SetVisible(false); hc.SetVisible(true) }}, {"ToggleUserPreference", func() { hc.ToggleUserPreference() }}, @@ -284,11 +152,10 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) { }) } - // Remove listener hc.RemoveListener(listenerID) called = false - hc.SetViewActions([]HeaderAction{{ID: "test2"}}) + hc.SetBurndown(nil) time.Sleep(10 * time.Millisecond) @@ -305,8 +172,7 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) { callCount++ }) - // Set to current value (no change) - hc.SetVisible(true) // Already true by default + hc.SetVisible(true) // already true by default time.Sleep(10 * time.Millisecond) @@ -314,7 +180,6 @@ func TestHeaderConfig_SetVisibleNoChangeNoNotify(t *testing.T) { t.Error("listener called when visibility didn't change") } - // Now change it hc.SetVisible(false) time.Sleep(10 * time.Millisecond) @@ -344,8 +209,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) { id1 := hc.AddListener(listener1) id2 := hc.AddListener(listener2) - // Both should be notified - hc.SetViewInfo("Test", "desc") + hc.SetBurndown(nil) time.Sleep(10 * time.Millisecond) @@ -355,10 +219,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) { } mu.Unlock() - // Remove one hc.RemoveListener(id1) - hc.SetViewInfo("Test2", "desc2") + hc.SetBurndown(nil) time.Sleep(10 * time.Millisecond) @@ -368,10 +231,9 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) { } mu.Unlock() - // Remove second hc.RemoveListener(id2) - hc.SetViewInfo("Test3", "desc3") + hc.SetBurndown(nil) time.Sleep(10 * time.Millisecond) @@ -387,24 +249,6 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) { done := make(chan bool) - // Writer goroutine - actions - go func() { - for i := range 50 { - hc.SetViewActions([]HeaderAction{{ID: string(rune('a' + i%26))}}) - hc.SetPluginActions([]HeaderAction{{ID: string(rune('A' + i%26))}}) - } - done <- true - }() - - // Writer goroutine - view info - go func() { - for i := range 50 { - hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc") - } - done <- true - }() - - // Writer goroutine - visibility go func() { for i := range 50 { hc.SetVisible(i%2 == 0) @@ -415,13 +259,15 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) { done <- true }() - // Reader goroutine + go func() { + for range 50 { + hc.SetBurndown(nil) + } + done <- true + }() + go func() { for range 100 { - _ = hc.GetViewActions() - _ = hc.GetPluginActions() - _ = hc.GetViewName() - _ = hc.GetViewDescription() _ = hc.GetBurndown() _ = hc.IsVisible() _ = hc.GetUserPreference() @@ -429,30 +275,14 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) { done <- true }() - // Wait for all - for range 4 { + for range 3 { <-done } - - // If we get here without panic, test passes } -func TestHeaderConfig_EmptyCollections(t *testing.T) { +func TestHeaderConfig_EmptyBurndown(t *testing.T) { hc := NewHeaderConfig() - // Set empty actions - hc.SetViewActions([]HeaderAction{}) - if len(hc.GetViewActions()) != 0 { - t.Error("GetViewActions() should return empty slice") - } - - // Set nil actions - hc.SetPluginActions(nil) - if len(hc.GetPluginActions()) != 0 { - t.Error("GetPluginActions() with nil input should return empty slice") - } - - // Set empty burndown hc.SetBurndown([]store.BurndownPoint{}) if len(hc.GetBurndown()) != 0 { t.Error("GetBurndown() should return empty slice") diff --git a/model/view_context.go b/model/view_context.go new file mode 100644 index 0000000..3210416 --- /dev/null +++ b/model/view_context.go @@ -0,0 +1,96 @@ +package model + +import "sync" + +// ViewContext holds the active view's identity and action DTOs. +// Subscribed to by HeaderWidget and ActionPalette. Written by RootLayout +// via syncViewContextFromView when the active view or its actions change. +type ViewContext struct { + mu sync.RWMutex + + viewID ViewID + viewName string + viewDescription string + viewActions []HeaderAction + pluginActions []HeaderAction + + listeners map[int]func() + nextListener int +} + +func NewViewContext() *ViewContext { + return &ViewContext{ + listeners: make(map[int]func()), + nextListener: 1, + } +} + +// SetFromView atomically updates all view-context fields and fires exactly one notification. +func (vc *ViewContext) SetFromView(id ViewID, name, description string, viewActions, pluginActions []HeaderAction) { + vc.mu.Lock() + vc.viewID = id + vc.viewName = name + vc.viewDescription = description + vc.viewActions = viewActions + vc.pluginActions = pluginActions + vc.mu.Unlock() + vc.notifyListeners() +} + +func (vc *ViewContext) GetViewID() ViewID { + vc.mu.RLock() + defer vc.mu.RUnlock() + return vc.viewID +} + +func (vc *ViewContext) GetViewName() string { + vc.mu.RLock() + defer vc.mu.RUnlock() + return vc.viewName +} + +func (vc *ViewContext) GetViewDescription() string { + vc.mu.RLock() + defer vc.mu.RUnlock() + return vc.viewDescription +} + +func (vc *ViewContext) GetViewActions() []HeaderAction { + vc.mu.RLock() + defer vc.mu.RUnlock() + return vc.viewActions +} + +func (vc *ViewContext) GetPluginActions() []HeaderAction { + vc.mu.RLock() + defer vc.mu.RUnlock() + return vc.pluginActions +} + +func (vc *ViewContext) AddListener(listener func()) int { + vc.mu.Lock() + defer vc.mu.Unlock() + id := vc.nextListener + vc.nextListener++ + vc.listeners[id] = listener + return id +} + +func (vc *ViewContext) RemoveListener(id int) { + vc.mu.Lock() + defer vc.mu.Unlock() + delete(vc.listeners, id) +} + +func (vc *ViewContext) notifyListeners() { + vc.mu.RLock() + listeners := make([]func(), 0, len(vc.listeners)) + for _, listener := range vc.listeners { + listeners = append(listeners, listener) + } + vc.mu.RUnlock() + + for _, listener := range listeners { + listener() + } +} diff --git a/model/view_context_test.go b/model/view_context_test.go new file mode 100644 index 0000000..ae40253 --- /dev/null +++ b/model/view_context_test.go @@ -0,0 +1,93 @@ +package model + +import ( + "sync/atomic" + "testing" +) + +func TestViewContext_SetFromView_SingleNotification(t *testing.T) { + vc := NewViewContext() + var count int32 + vc.AddListener(func() { atomic.AddInt32(&count, 1) }) + + vc.SetFromView("plugin:Kanban", "Kanban", "desc", nil, nil) + + if got := atomic.LoadInt32(&count); got != 1 { + t.Errorf("expected exactly 1 notification, got %d", got) + } +} + +func TestViewContext_Getters(t *testing.T) { + vc := NewViewContext() + + viewActions := []HeaderAction{{ID: "edit", Label: "Edit"}} + pluginActions := []HeaderAction{{ID: "plugin:Kanban", Label: "Kanban"}} + + vc.SetFromView(TaskDetailViewID, "Tiki Detail", "desc", viewActions, pluginActions) + + if vc.GetViewID() != TaskDetailViewID { + t.Errorf("expected %v, got %v", TaskDetailViewID, vc.GetViewID()) + } + if vc.GetViewName() != "Tiki Detail" { + t.Errorf("expected 'Tiki Detail', got %q", vc.GetViewName()) + } + if vc.GetViewDescription() != "desc" { + t.Errorf("expected 'desc', got %q", vc.GetViewDescription()) + } + if len(vc.GetViewActions()) != 1 || vc.GetViewActions()[0].ID != "edit" { + t.Errorf("unexpected view actions: %v", vc.GetViewActions()) + } + if len(vc.GetPluginActions()) != 1 || vc.GetPluginActions()[0].ID != "plugin:Kanban" { + t.Errorf("unexpected plugin actions: %v", vc.GetPluginActions()) + } +} + +func TestViewContext_RemoveListener(t *testing.T) { + vc := NewViewContext() + var count int32 + id := vc.AddListener(func() { atomic.AddInt32(&count, 1) }) + + vc.SetFromView("v1", "n", "d", nil, nil) + if atomic.LoadInt32(&count) != 1 { + t.Fatal("listener should have fired once") + } + + vc.RemoveListener(id) + vc.SetFromView("v2", "n", "d", nil, nil) + if atomic.LoadInt32(&count) != 1 { + t.Error("listener should not fire after removal") + } +} + +func TestViewContext_MultipleListeners(t *testing.T) { + vc := NewViewContext() + var a, b int32 + vc.AddListener(func() { atomic.AddInt32(&a, 1) }) + vc.AddListener(func() { atomic.AddInt32(&b, 1) }) + + vc.SetFromView("v1", "n", "d", nil, nil) + + if atomic.LoadInt32(&a) != 1 { + t.Errorf("listener A: expected 1, got %d", atomic.LoadInt32(&a)) + } + if atomic.LoadInt32(&b) != 1 { + t.Errorf("listener B: expected 1, got %d", atomic.LoadInt32(&b)) + } +} + +func TestViewContext_ZeroValueIsEmpty(t *testing.T) { + vc := NewViewContext() + + if vc.GetViewID() != "" { + t.Errorf("expected empty view ID, got %v", vc.GetViewID()) + } + if vc.GetViewName() != "" { + t.Errorf("expected empty view name, got %q", vc.GetViewName()) + } + if vc.GetViewActions() != nil { + t.Errorf("expected nil view actions, got %v", vc.GetViewActions()) + } + if vc.GetPluginActions() != nil { + t.Errorf("expected nil plugin actions, got %v", vc.GetPluginActions()) + } +} diff --git a/plugin/definition.go b/plugin/definition.go index 168fe89..a241132 100644 --- a/plugin/definition.go +++ b/plugin/definition.go @@ -83,13 +83,15 @@ type PluginActionConfig struct { Key string `yaml:"key" mapstructure:"key"` Label string `yaml:"label" mapstructure:"label"` Action string `yaml:"action" mapstructure:"action"` + Hot *bool `yaml:"hot,omitempty" mapstructure:"hot"` } // PluginAction represents a parsed shortcut action bound to a key. type PluginAction struct { - Rune rune - Label string - Action *ruki.ValidatedStatement + Rune rune + Label string + Action *ruki.ValidatedStatement + ShowInHeader bool } // PluginLaneConfig represents a lane in YAML or config definitions. diff --git a/plugin/loader.go b/plugin/loader.go index 6cd66ec..ec7db60 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -46,7 +46,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct return nil, nil, []string{fmt.Sprintf("%s: %v", path, err)} } - if len(wf.Views.Plugins) == 0 { + if len(wf.Views.Plugins) == 0 && len(wf.Views.Actions) == 0 { return nil, nil, nil } @@ -55,6 +55,7 @@ func loadPluginsFromFile(path string, schema ruki.Schema) ([]Plugin, []PluginAct for i := range wf.Views.Plugins { totalConverted += transformer.ConvertPluginConfig(&wf.Views.Plugins[i]) } + totalConverted += convertLegacyGlobalActions(transformer, wf.Views.Actions) if totalConverted > 0 { slog.Info("converted legacy workflow expressions to ruki", "count", totalConverted, "path", path) } @@ -257,6 +258,27 @@ func mergeGlobalActionsIntoPlugins(plugins []Plugin, globalActions []PluginActio } } +// convertLegacyGlobalActions converts legacy action expressions in global views.actions +// to ruki format, matching the same conversion applied to per-plugin actions. +func convertLegacyGlobalActions(transformer *LegacyConfigTransformer, actions []PluginActionConfig) int { + count := 0 + for i := range actions { + action := &actions[i] + if action.Action != "" && !isRukiAction(action.Action) { + newAction, err := transformer.ConvertAction(action.Action) + if err != nil { + slog.Warn("failed to convert legacy global action, passing through", + "error", err, "action", action.Action, "key", action.Key) + continue + } + slog.Debug("converted legacy global action", "old", action.Action, "new", newAction, "key", action.Key) + action.Action = newAction + count++ + } + } + return count +} + // DefaultPlugin returns the first plugin marked as default, or the first plugin // in the list if none are marked. The caller must ensure plugins is non-empty. func DefaultPlugin(plugins []Plugin) Plugin { diff --git a/plugin/parser.go b/plugin/parser.go index 308d54a..440268c 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -210,10 +210,15 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl if err != nil { return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err) } + showInHeader := true + if cfg.Hot != nil { + showInHeader = *cfg.Hot + } actions = append(actions, PluginAction{ - Rune: r, - Label: cfg.Label, - Action: actionStmt, + Rune: r, + Label: cfg.Label, + Action: actionStmt, + ShowInHeader: showInHeader, }) } diff --git a/plugin/parser_test.go b/plugin/parser_test.go index bec2919..b42f2f8 100644 --- a/plugin/parser_test.go +++ b/plugin/parser_test.go @@ -761,3 +761,81 @@ foreground: "#00ff00" t.Errorf("Expected URL, got %q", dokiPlugin.URL) } } + +func TestParsePluginActions_HotDefault(t *testing.T) { + parser := testParser() + configs := []PluginActionConfig{ + {Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`}, + } + actions, err := parsePluginActions(configs, parser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !actions[0].ShowInHeader { + t.Error("absent hot should default to ShowInHeader=true") + } +} + +func TestParsePluginActions_HotExplicitFalse(t *testing.T) { + parser := testParser() + hotFalse := false + configs := []PluginActionConfig{ + {Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotFalse}, + } + actions, err := parsePluginActions(configs, parser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if actions[0].ShowInHeader { + t.Error("hot: false should set ShowInHeader=false") + } +} + +func TestParsePluginActions_HotExplicitTrue(t *testing.T) { + parser := testParser() + hotTrue := true + configs := []PluginActionConfig{ + {Key: "b", Label: "Board", Action: `update where id = id() set status="ready"`, Hot: &hotTrue}, + } + actions, err := parsePluginActions(configs, parser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !actions[0].ShowInHeader { + t.Error("hot: true should set ShowInHeader=true") + } +} + +func TestParsePluginYAML_HotFlagFromYAML(t *testing.T) { + yamlData := []byte(` +name: Test +key: T +lanes: + - name: Backlog + filter: select where status = "backlog" +actions: + - key: "b" + label: "Board" + action: update where id = id() set status = "ready" + hot: false + - key: "a" + label: "Assign" + action: update where id = id() set assignee = user() +`) + + p, err := parsePluginYAML(yamlData, "test.yaml", testSchema()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tiki, ok := p.(*TikiPlugin) + if !ok { + t.Fatalf("expected TikiPlugin, got %T", p) + } + + if tiki.Actions[0].ShowInHeader { + t.Error("action with hot: false should have ShowInHeader=false") + } + if !tiki.Actions[1].ShowInHeader { + t.Error("action without hot should default to ShowInHeader=true") + } +} diff --git a/testutil/integration_helpers.go b/testutil/integration_helpers.go index 1d8ac18..a64b34d 100644 --- a/testutil/integration_helpers.go +++ b/testutil/integration_helpers.go @@ -41,6 +41,7 @@ type TestApp struct { taskController *controller.TaskController statuslineConfig *model.StatuslineConfig headerConfig *model.HeaderConfig + viewContext *model.ViewContext layoutModel *model.LayoutModel } @@ -113,11 +114,13 @@ func NewTestApp(t *testing.T) *TestApp { viewFactory := view.NewViewFactory(taskStore) // 7. Create header widget, statusline, and RootLayout - headerWidget := header.NewHeaderWidget(headerConfig) + viewContext := model.NewViewContext() + headerWidget := header.NewHeaderWidget(headerConfig, viewContext) statuslineWidget := statusline.NewStatuslineWidget(statuslineConfig) rootLayout := view.NewRootLayout(view.RootLayoutOpts{ Header: headerWidget, HeaderConfig: headerConfig, + ViewContext: viewContext, LayoutModel: layoutModel, ViewFactory: viewFactory, TaskStore: taskStore, @@ -181,6 +184,7 @@ func NewTestApp(t *testing.T) *TestApp { taskController: taskController, statuslineConfig: statuslineConfig, headerConfig: headerConfig, + viewContext: viewContext, layoutModel: layoutModel, } @@ -441,13 +445,14 @@ func (ta *TestApp) LoadPlugins() error { }) // Recreate RootLayout with new view factory - headerWidget := header.NewHeaderWidget(ta.headerConfig) + headerWidget := header.NewHeaderWidget(ta.headerConfig, ta.viewContext) ta.RootLayout.Cleanup() slConfig := model.NewStatuslineConfig() slWidget := statusline.NewStatuslineWidget(slConfig) ta.RootLayout = view.NewRootLayout(view.RootLayoutOpts{ Header: headerWidget, HeaderConfig: ta.headerConfig, + ViewContext: ta.viewContext, LayoutModel: ta.layoutModel, ViewFactory: viewFactory, TaskStore: ta.TaskStore, diff --git a/view/doki_plugin_view.go b/view/doki_plugin_view.go index 90aeb86..2f265e2 100644 --- a/view/doki_plugin_view.go +++ b/view/doki_plugin_view.go @@ -29,13 +29,14 @@ var customMd string // DokiView renders a documentation plugin (navigable markdown) type DokiView struct { - root *tview.Flex - titleBar tview.Primitive - md *markdown.NavigableMarkdown - pluginDef *plugin.DokiPlugin - registry *controller.ActionRegistry - imageManager *navtview.ImageManager - mermaidOpts *nav.MermaidOptions + root *tview.Flex + titleBar tview.Primitive + md *markdown.NavigableMarkdown + pluginDef *plugin.DokiPlugin + registry *controller.ActionRegistry + imageManager *navtview.ImageManager + mermaidOpts *nav.MermaidOptions + actionChangeHandler func() } // NewDokiView creates a doki view @@ -172,6 +173,10 @@ func (dv *DokiView) OnBlur() { } } +func (dv *DokiView) SetActionChangeHandler(handler func()) { + dv.actionChangeHandler = handler +} + // UpdateNavigationActions updates the registry to reflect current navigation state func (dv *DokiView) UpdateNavigationActions() { // Clear and rebuild the registry @@ -212,6 +217,10 @@ func (dv *DokiView) UpdateNavigationActions() { ShowInHeader: true, }) } + + if dv.actionChangeHandler != nil { + dv.actionChangeHandler() + } } // internalDokiProvider implements navidown.ContentProvider for embedded/internal docs. diff --git a/view/header/header.go b/view/header/header.go index 3b50724..133f8c1 100644 --- a/view/header/header.go +++ b/view/header/header.go @@ -55,20 +55,23 @@ type HeaderWidget struct { rightSpacer *tview.Box logo *tview.TextView - // Model reference - headerConfig *model.HeaderConfig - listenerID int + // Model references + headerConfig *model.HeaderConfig + viewContext *model.ViewContext + headerListenerID int + viewContextListenerID int // Layout state lastWidth int chartVisible bool } -// NewHeaderWidget creates a header widget that observes HeaderConfig for all state -func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget { +// NewHeaderWidget creates a header widget that observes HeaderConfig (burndown/visibility) +// and ViewContext (view info + actions) for state. +func NewHeaderWidget(headerConfig *model.HeaderConfig, viewContext *model.ViewContext) *HeaderWidget { info := NewInfoWidget() contextHelp := NewContextHelpWidget() - chart := NewChartWidgetSimple() // No store dependency, data comes from HeaderConfig + chart := NewChartWidgetSimple() logo := tview.NewTextView() logo.SetDynamicColors(true) @@ -87,29 +90,27 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget { chart: chart, rightSpacer: tview.NewBox(), headerConfig: headerConfig, + viewContext: viewContext, } - // Subscribe to header config changes - hw.listenerID = headerConfig.AddListener(hw.rebuild) + hw.headerListenerID = headerConfig.AddListener(hw.rebuild) + if viewContext != nil { + hw.viewContextListenerID = viewContext.AddListener(hw.rebuild) + } hw.rebuild() hw.rebuildLayout(0) return hw } -// rebuild reads all data from HeaderConfig and updates display +// rebuild reads data from ViewContext (view info + actions) and HeaderConfig (burndown) func (h *HeaderWidget) rebuild() { - // Update view info from HeaderConfig - h.info.SetViewInfo(h.headerConfig.GetViewName(), h.headerConfig.GetViewDescription()) + if h.viewContext != nil { + h.info.SetViewInfo(h.viewContext.GetViewName(), h.viewContext.GetViewDescription()) + h.contextHelp.SetActionsFromModel(h.viewContext.GetViewActions(), h.viewContext.GetPluginActions()) + } - // Update burndown chart from HeaderConfig - burndown := h.headerConfig.GetBurndown() - h.chart.UpdateBurndown(burndown) - - // Update context help from HeaderConfig - viewActions := h.headerConfig.GetViewActions() - pluginActions := h.headerConfig.GetPluginActions() - h.contextHelp.SetActionsFromModel(viewActions, pluginActions) + h.chart.UpdateBurndown(h.headerConfig.GetBurndown()) if h.lastWidth > 0 { h.rebuildLayout(h.lastWidth) @@ -125,9 +126,12 @@ func (h *HeaderWidget) Draw(screen tcell.Screen) { h.Flex.Draw(screen) } -// Cleanup removes the listener from HeaderConfig +// Cleanup removes all listeners func (h *HeaderWidget) Cleanup() { - h.headerConfig.RemoveListener(h.listenerID) + h.headerConfig.RemoveListener(h.headerListenerID) + if h.viewContext != nil { + h.viewContext.RemoveListener(h.viewContextListenerID) + } } // rebuildLayout recalculates and rebuilds the flex layout based on terminal width. diff --git a/view/header/header_layout_test.go b/view/header/header_layout_test.go index 777acf1..e597b48 100644 --- a/view/header/header_layout_test.go +++ b/view/header/header_layout_test.go @@ -138,7 +138,7 @@ func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) { func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) { headerConfig := model.NewHeaderConfig() - h := NewHeaderWidget(headerConfig) + h := NewHeaderWidget(headerConfig, model.NewViewContext()) defer h.Cleanup() h.contextHelp.width = 10 @@ -154,7 +154,7 @@ func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) { func TestHeaderWidget_chartVisibilityThreshold_growsWithContextHelp(t *testing.T) { headerConfig := model.NewHeaderConfig() - h := NewHeaderWidget(headerConfig) + h := NewHeaderWidget(headerConfig, model.NewViewContext()) defer h.Cleanup() h.contextHelp.width = 60 diff --git a/view/root_layout.go b/view/root_layout.go index 31578fe..6c6aa8a 100644 --- a/view/root_layout.go +++ b/view/root_layout.go @@ -36,6 +36,8 @@ type RootLayout struct { contentView controller.View lastParamsKey string + viewContext *model.ViewContext + headerListenerID int layoutListenerID int storeListenerID int @@ -50,6 +52,7 @@ type RootLayout struct { type RootLayoutOpts struct { Header *header.HeaderWidget HeaderConfig *model.HeaderConfig + ViewContext *model.ViewContext LayoutModel *model.LayoutModel ViewFactory controller.ViewFactory TaskStore store.Store @@ -65,6 +68,7 @@ func NewRootLayout(opts RootLayoutOpts) *RootLayout { header: opts.Header, contentArea: tview.NewFlex().SetDirection(tview.FlexRow), headerConfig: opts.HeaderConfig, + viewContext: opts.ViewContext, layoutModel: opts.LayoutModel, viewFactory: opts.ViewFactory, taskStore: opts.TaskStore, @@ -139,20 +143,8 @@ func (rl *RootLayout) onLayoutChange() { rl.contentArea.AddItem(newView.GetPrimitive(), 0, 1, true) rl.contentView = newView - // Update header with new view's actions - rl.headerConfig.SetViewActions(newView.GetActionRegistry().ToHeaderActions()) - - // Show or hide plugin navigation keys based on the view's declaration - if np, ok := newView.(controller.NavigationProvider); ok && np.ShowNavigation() { - rl.headerConfig.SetPluginActions(controller.GetPluginActions().ToHeaderActions()) - } else { - rl.headerConfig.SetPluginActions(nil) - } - - // Update header info section with view name and description - if vip, ok := newView.(controller.ViewInfoProvider); ok { - rl.headerConfig.SetViewInfo(vip.GetViewName(), vip.GetViewDescription()) - } + // Sync view context (writes to both ViewContext and HeaderConfig for header actions) + rl.syncViewContextFromView(newView) // Update statusline stats from the view rl.updateStatuslineViewStats(newView) @@ -169,6 +161,13 @@ func (rl *RootLayout) onLayoutChange() { }) } + // Wire up action change notifications (registry or enablement changes on the same view) + if notifier, ok := newView.(controller.ActionChangeNotifier); ok { + notifier.SetActionChangeHandler(func() { + rl.syncViewContextFromView(newView) + }) + } + // Focus the view newView.OnFocus() if newView.GetViewID() == model.TaskEditViewID { @@ -199,6 +198,32 @@ func (rl *RootLayout) onLayoutChange() { rl.app.SetFocus(newView.GetPrimitive()) } +// syncViewContextFromView computes view name/description, view actions, and plugin actions +// from the active view, then writes to both ViewContext (single atomic update) and HeaderConfig +// (for backward-compatible header display during the transition period). +func (rl *RootLayout) syncViewContextFromView(v controller.View) { + if v == nil { + return + } + + viewActions := v.GetActionRegistry().ToHeaderActions() + + var pluginActions []model.HeaderAction + if np, ok := v.(controller.NavigationProvider); ok && np.ShowNavigation() { + pluginActions = controller.GetPluginActions().ToHeaderActions() + } + + var viewName, viewDesc string + if vip, ok := v.(controller.ViewInfoProvider); ok { + viewName = vip.GetViewName() + viewDesc = vip.GetViewDescription() + } + + if rl.viewContext != nil { + rl.viewContext.SetFromView(v.GetViewID(), viewName, viewDesc, viewActions, pluginActions) + } +} + // recomputeHeaderVisibility computes header visibility based on view requirements and user preference func (rl *RootLayout) recomputeHeaderVisibility(v controller.View) { // Start from user preference diff --git a/view/taskdetail/task_edit_nav.go b/view/taskdetail/task_edit_nav.go index 616a2b3..719b053 100644 --- a/view/taskdetail/task_edit_nav.go +++ b/view/taskdetail/task_edit_nav.go @@ -256,11 +256,13 @@ func (ev *TaskEditView) IsRecurrenceValueFocused() bool { func (ev *TaskEditView) UpdateHeaderForField(field model.EditField) { if ev.descOnly { ev.registry = controller.DescOnlyEditActions() - return - } - if ev.tagsOnly { + } else if ev.tagsOnly { ev.registry = controller.TagsOnlyEditActions() - return + } else { + ev.registry = controller.GetActionsForField(field) + } + + if ev.actionChangeHandler != nil { + ev.actionChangeHandler() } - ev.registry = controller.GetActionsForField(field) } diff --git a/view/taskdetail/task_edit_view.go b/view/taskdetail/task_edit_view.go index 3f0a162..e393a6a 100644 --- a/view/taskdetail/task_edit_view.go +++ b/view/taskdetail/task_edit_view.go @@ -45,28 +45,34 @@ type TaskEditView struct { tagsEditing bool // All callbacks - onTitleSave func(string) - onTitleChange func(string) - onTitleCancel func() - onDescSave func(string) - onDescCancel func() - onStatusSave func(string) - onTypeSave func(string) - onPrioritySave func(int) - onAssigneeSave func(string) - onPointsSave func(int) - onDueSave func(string) - onRecurrenceSave func(string) - onTagsSave func(string) - onTagsCancel func() + onTitleSave func(string) + onTitleChange func(string) + onTitleCancel func() + onDescSave func(string) + onDescCancel func() + onStatusSave func(string) + onTypeSave func(string) + onPrioritySave func(int) + onAssigneeSave func(string) + onPointsSave func(int) + onDueSave func(string) + onRecurrenceSave func(string) + onTagsSave func(string) + onTagsCancel func() + actionChangeHandler func() } // Compile-time interface checks var ( - _ controller.View = (*TaskEditView)(nil) - _ controller.FocusSettable = (*TaskEditView)(nil) + _ controller.View = (*TaskEditView)(nil) + _ controller.FocusSettable = (*TaskEditView)(nil) + _ controller.ActionChangeNotifier = (*TaskEditView)(nil) ) +func (ev *TaskEditView) SetActionChangeHandler(handler func()) { + ev.actionChangeHandler = handler +} + // NewTaskEditView creates a task edit view func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager) *TaskEditView { ev := &TaskEditView{ @@ -75,7 +81,7 @@ func NewTaskEditView(taskStore store.Store, taskID string, imageManager *navtvie taskID: taskID, imageManager: imageManager, }, - registry: controller.TaskEditViewActions(), + registry: controller.GetActionsForField(model.EditFieldTitle), viewID: model.TaskEditViewID, focusedField: model.EditFieldTitle, titleEditing: true, @@ -142,7 +148,11 @@ func (ev *TaskEditView) SetDescOnly(descOnly bool) { ev.descOnly = descOnly if descOnly { ev.focusedField = model.EditFieldDescription + ev.registry = controller.DescOnlyEditActions() ev.refresh() + if ev.actionChangeHandler != nil { + ev.actionChangeHandler() + } } } @@ -156,7 +166,11 @@ func (ev *TaskEditView) IsDescOnly() bool { func (ev *TaskEditView) SetTagsOnly(tagsOnly bool) { ev.tagsOnly = tagsOnly if tagsOnly { + ev.registry = controller.TagsOnlyEditActions() ev.refresh() + if ev.actionChangeHandler != nil { + ev.actionChangeHandler() + } } } diff --git a/view/tiki_plugin_view.go b/view/tiki_plugin_view.go index f0bbbe3..6c887c8 100644 --- a/view/tiki_plugin_view.go +++ b/view/tiki_plugin_view.go @@ -29,6 +29,7 @@ type PluginView struct { selectionListenerID int getLaneTasks func(lane int) []*task.Task // injected from controller ensureSelection func() bool // injected from controller + actionChangeHandler func() } // NewPluginView creates a plugin view @@ -186,6 +187,14 @@ func (pv *PluginView) refresh() { // Sync scroll offset from view to model for later lane navigation pv.pluginConfig.SetScrollOffsetForLane(laneIdx, laneContainer.GetScrollOffset()) } + + if pv.actionChangeHandler != nil { + pv.actionChangeHandler() + } +} + +func (pv *PluginView) SetActionChangeHandler(handler func()) { + pv.actionChangeHandler = handler } // GetPrimitive returns the root tview primitive