mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
read only view in deps plugin
This commit is contained in:
parent
86506bb137
commit
d5e28b2a01
12 changed files with 380 additions and 374 deletions
|
|
@ -260,6 +260,14 @@ func TaskDetailViewActions() *ActionRegistry {
|
|||
return r
|
||||
}
|
||||
|
||||
// ReadonlyTaskDetailViewActions returns a reduced registry for readonly task detail views.
|
||||
// Only fullscreen toggle is available — no editing actions.
|
||||
func ReadonlyTaskDetailViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
r.Register(Action{ID: ActionFullscreen, Key: tcell.KeyRune, Rune: 'f', Label: "Full screen", ShowInHeader: true})
|
||||
return r
|
||||
}
|
||||
|
||||
// TaskEditViewActions returns the canonical action registry for the task edit view.
|
||||
// Separate registry so view/edit modes can diverge while sharing rendering helpers.
|
||||
func TaskEditViewActions() *ActionRegistry {
|
||||
|
|
@ -396,7 +404,6 @@ func PluginViewActions() *ActionRegistry {
|
|||
}
|
||||
|
||||
// DepsViewActions returns the action registry for the dependency editor view.
|
||||
// Restricted to navigation, move task, view mode toggle, and search — no open/new/delete/plugin keys.
|
||||
func DepsViewActions() *ActionRegistry {
|
||||
r := NewActionRegistry()
|
||||
|
||||
|
|
@ -414,6 +421,11 @@ func DepsViewActions() *ActionRegistry {
|
|||
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})
|
||||
|
||||
// task actions
|
||||
r.Register(Action{ID: ActionOpenFromPlugin, Key: tcell.KeyEnter, Label: "Open", ShowInHeader: true})
|
||||
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})
|
||||
|
||||
// view mode and search
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@ const (
|
|||
// Unlike PluginController, move logic here updates different tasks depending on
|
||||
// the source/target lane pair — sometimes the moved task, sometimes the context task.
|
||||
type DepsController struct {
|
||||
taskStore store.Store
|
||||
pluginConfig *model.PluginConfig
|
||||
pluginDef *plugin.TikiPlugin
|
||||
navController *NavigationController
|
||||
registry *ActionRegistry
|
||||
pluginBase
|
||||
}
|
||||
|
||||
// NewDepsController creates a dependency editor controller.
|
||||
|
|
@ -36,33 +32,52 @@ func NewDepsController(
|
|||
navController *NavigationController,
|
||||
) *DepsController {
|
||||
return &DepsController{
|
||||
taskStore: taskStore,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: pluginDef,
|
||||
navController: navController,
|
||||
registry: DepsViewActions(),
|
||||
pluginBase: pluginBase{
|
||||
taskStore: taskStore,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: pluginDef,
|
||||
navController: navController,
|
||||
registry: DepsViewActions(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DepsController) GetActionRegistry() *ActionRegistry { return dc.registry }
|
||||
func (dc *DepsController) GetPluginName() string { return dc.pluginDef.Name }
|
||||
func (dc *DepsController) ShowNavigation() bool { return false }
|
||||
func (dc *DepsController) ShowNavigation() bool { return false }
|
||||
|
||||
// EnsureFirstNonEmptyLaneSelection delegates to pluginBase with this controller's filter.
|
||||
func (dc *DepsController) EnsureFirstNonEmptyLaneSelection() bool {
|
||||
return dc.pluginBase.EnsureFirstNonEmptyLaneSelection(dc.GetFilteredTasksForLane)
|
||||
}
|
||||
|
||||
// HandleAction routes actions to the appropriate handler.
|
||||
func (dc *DepsController) HandleAction(actionID ActionID) bool {
|
||||
switch actionID {
|
||||
case ActionNavUp:
|
||||
return dc.handleNav("up")
|
||||
return dc.handleNav("up", dc.GetFilteredTasksForLane)
|
||||
case ActionNavDown:
|
||||
return dc.handleNav("down")
|
||||
return dc.handleNav("down", dc.GetFilteredTasksForLane)
|
||||
case ActionNavLeft:
|
||||
return dc.handleNav("left")
|
||||
return dc.handleNav("left", dc.GetFilteredTasksForLane)
|
||||
case ActionNavRight:
|
||||
return dc.handleNav("right")
|
||||
return dc.handleNav("right", dc.GetFilteredTasksForLane)
|
||||
case ActionMoveTaskLeft:
|
||||
return dc.handleMoveTask(-1)
|
||||
case ActionMoveTaskRight:
|
||||
return dc.handleMoveTask(1)
|
||||
case ActionOpenFromPlugin:
|
||||
taskID := dc.getSelectedTaskID(dc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
dc.navController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{
|
||||
TaskID: taskID,
|
||||
ReadOnly: true,
|
||||
}))
|
||||
return true
|
||||
case ActionNewTask:
|
||||
return dc.handleNewTask()
|
||||
case ActionDeleteTask:
|
||||
return dc.handleDeleteTask(dc.GetFilteredTasksForLane)
|
||||
case ActionToggleViewMode:
|
||||
dc.pluginConfig.ToggleViewMode()
|
||||
return true
|
||||
|
|
@ -73,18 +88,9 @@ func (dc *DepsController) HandleAction(actionID ActionID) bool {
|
|||
|
||||
// HandleSearch processes a search query, narrowing visible tasks within each lane.
|
||||
func (dc *DepsController) HandleSearch(query string) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return
|
||||
}
|
||||
dc.pluginConfig.SavePreSearchState()
|
||||
results := dc.taskStore.Search(query, nil)
|
||||
if len(results) == 0 {
|
||||
dc.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
|
||||
return
|
||||
}
|
||||
dc.pluginConfig.SetSearchResults(results, query)
|
||||
dc.selectFirstNonEmptyLane()
|
||||
dc.handleSearch(query, func() bool {
|
||||
return dc.selectFirstNonEmptyLane(dc.GetFilteredTasksForLane)
|
||||
})
|
||||
}
|
||||
|
||||
// GetFilteredTasksForLane returns tasks for a given lane of the deps editor.
|
||||
|
|
@ -127,17 +133,6 @@ func (dc *DepsController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
return result
|
||||
}
|
||||
|
||||
// EnsureFirstNonEmptyLaneSelection selects the first non-empty lane if the current lane is empty.
|
||||
func (dc *DepsController) EnsureFirstNonEmptyLaneSelection() bool {
|
||||
currentLane := dc.pluginConfig.GetSelectedLane()
|
||||
if currentLane >= 0 && currentLane < len(dc.pluginDef.Lanes) {
|
||||
if len(dc.GetFilteredTasksForLane(currentLane)) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return dc.selectFirstNonEmptyLane()
|
||||
}
|
||||
|
||||
// handleMoveTask applies dependency changes based on the source→target lane transition.
|
||||
//
|
||||
// From → To | What changes
|
||||
|
|
@ -150,7 +145,7 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
movedTaskID := dc.getSelectedTaskID()
|
||||
movedTaskID := dc.getSelectedTaskID(dc.GetFilteredTasksForLane)
|
||||
if movedTaskID == "" {
|
||||
return false
|
||||
}
|
||||
|
|
@ -200,7 +195,7 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
}
|
||||
}
|
||||
|
||||
dc.selectTaskInLane(targetLane, movedTaskID)
|
||||
dc.selectTaskInLane(targetLane, movedTaskID, dc.GetFilteredTasksForLane)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -251,84 +246,3 @@ func (dc *DepsController) computeAllLane(allTasks []*task.Task, contextID string
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (dc *DepsController) handleNav(direction string) bool {
|
||||
lane := dc.pluginConfig.GetSelectedLane()
|
||||
tasks := dc.GetFilteredTasksForLane(lane)
|
||||
if direction == "left" || direction == "right" {
|
||||
if dc.pluginConfig.MoveSelection(direction, len(tasks)) {
|
||||
return true
|
||||
}
|
||||
return dc.handleLaneSwitch(direction)
|
||||
}
|
||||
return dc.pluginConfig.MoveSelection(direction, len(tasks))
|
||||
}
|
||||
|
||||
func (dc *DepsController) handleLaneSwitch(direction string) bool {
|
||||
currentLane := dc.pluginConfig.GetSelectedLane()
|
||||
nextLane := currentLane
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for nextLane >= 0 && nextLane < len(dc.pluginDef.Lanes) {
|
||||
tasks := dc.GetFilteredTasksForLane(nextLane)
|
||||
if len(tasks) > 0 {
|
||||
dc.pluginConfig.SetSelectedLane(nextLane)
|
||||
scrollOffset := dc.pluginConfig.GetScrollOffsetForLane(nextLane)
|
||||
if scrollOffset >= len(tasks) {
|
||||
scrollOffset = len(tasks) - 1
|
||||
}
|
||||
if scrollOffset < 0 {
|
||||
scrollOffset = 0
|
||||
}
|
||||
dc.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
|
||||
return true
|
||||
}
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (dc *DepsController) getSelectedTaskID() string {
|
||||
lane := dc.pluginConfig.GetSelectedLane()
|
||||
tasks := dc.GetFilteredTasksForLane(lane)
|
||||
idx := dc.pluginConfig.GetSelectedIndexForLane(lane)
|
||||
if idx < 0 || idx >= len(tasks) {
|
||||
return ""
|
||||
}
|
||||
return tasks[idx].ID
|
||||
}
|
||||
|
||||
func (dc *DepsController) selectTaskInLane(lane int, taskID string) {
|
||||
tasks := dc.GetFilteredTasksForLane(lane)
|
||||
targetIndex := 0
|
||||
for i, t := range tasks {
|
||||
if t.ID == taskID {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
dc.pluginConfig.SetSelectedLane(lane)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
|
||||
}
|
||||
|
||||
func (dc *DepsController) selectFirstNonEmptyLane() bool {
|
||||
for lane := range dc.pluginDef.Lanes {
|
||||
if len(dc.GetFilteredTasksForLane(lane)) > 0 {
|
||||
dc.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
|
|||
pluginConfig := model.NewPluginConfig("deps:" + testCtxID)
|
||||
pluginConfig.SetLaneLayout([]int{1, 2, 1})
|
||||
|
||||
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nil)
|
||||
nav := newMockNavigationController()
|
||||
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nav)
|
||||
return dc, taskStore
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +298,53 @@ func TestDepsController_HandleAction(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("open task pushes detail view", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
||||
result := dc.HandleAction(ActionOpenFromPlugin)
|
||||
if !result {
|
||||
t.Error("open should succeed when a task is selected")
|
||||
}
|
||||
top := dc.navController.navState.currentView()
|
||||
if top == nil || top.ViewID != model.TaskDetailViewID {
|
||||
t.Error("expected TaskDetailViewID to be pushed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new task pushes edit view", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
result := dc.HandleAction(ActionNewTask)
|
||||
if !result {
|
||||
t.Error("new task should succeed")
|
||||
}
|
||||
top := dc.navController.navState.currentView()
|
||||
if top == nil || top.ViewID != model.TaskEditViewID {
|
||||
t.Error("expected TaskEditViewID to be pushed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete task removes from store", func(t *testing.T) {
|
||||
dc, taskStore := newDepsTestEnv(t)
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
||||
|
||||
// free task should be in the All lane
|
||||
allTasks := dc.GetFilteredTasksForLane(depsLaneAll)
|
||||
if len(allTasks) == 0 {
|
||||
t.Fatal("expected at least one task in All lane")
|
||||
}
|
||||
deletedID := allTasks[0].ID
|
||||
|
||||
result := dc.HandleAction(ActionDeleteTask)
|
||||
if !result {
|
||||
t.Error("delete should succeed when a task is selected")
|
||||
}
|
||||
if taskStore.GetTask(deletedID) != nil {
|
||||
t.Errorf("task %s should have been deleted", deletedID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid action returns false", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
if dc.HandleAction("nonexistent_action") {
|
||||
|
|
@ -309,7 +357,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
|
|||
t.Run("right from Blocks lands on All", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
||||
result := dc.handleLaneSwitch("right")
|
||||
result := dc.handleLaneSwitch("right", dc.GetFilteredTasksForLane)
|
||||
if !result {
|
||||
t.Error("should succeed — All lane has tasks")
|
||||
}
|
||||
|
|
@ -321,7 +369,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
|
|||
t.Run("left from All lands on Blocks", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
result := dc.handleLaneSwitch("left")
|
||||
result := dc.handleLaneSwitch("left", dc.GetFilteredTasksForLane)
|
||||
if !result {
|
||||
t.Error("should succeed — Blocks lane has tasks")
|
||||
}
|
||||
|
|
@ -333,7 +381,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
|
|||
t.Run("left from Blocks returns false (boundary)", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneBlocks)
|
||||
if dc.handleLaneSwitch("left") {
|
||||
if dc.handleLaneSwitch("left", dc.GetFilteredTasksForLane) {
|
||||
t.Error("should fail — no lane to the left of Blocks")
|
||||
}
|
||||
})
|
||||
|
|
@ -341,7 +389,7 @@ func TestDepsController_HandleLaneSwitch(t *testing.T) {
|
|||
t.Run("right from Depends returns false (boundary)", func(t *testing.T) {
|
||||
dc, _ := newDepsTestEnv(t)
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneDepends)
|
||||
if dc.handleLaneSwitch("right") {
|
||||
if dc.handleLaneSwitch("right", dc.GetFilteredTasksForLane) {
|
||||
t.Error("should fail — no lane to the right of Depends")
|
||||
}
|
||||
})
|
||||
|
|
@ -381,27 +429,18 @@ func TestDepsController_EnsureFirstNonEmptyLaneSelection(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestDepsViewActions_NoOpenNewDelete(t *testing.T) {
|
||||
func TestDepsViewActions(t *testing.T) {
|
||||
registry := DepsViewActions()
|
||||
actions := registry.GetActions()
|
||||
|
||||
forbidden := map[ActionID]bool{
|
||||
ActionOpenFromPlugin: true,
|
||||
ActionNewTask: true,
|
||||
ActionDeleteTask: true,
|
||||
}
|
||||
|
||||
for _, a := range actions {
|
||||
if forbidden[a.ID] {
|
||||
t.Errorf("DepsViewActions should not contain %s", a.ID)
|
||||
}
|
||||
}
|
||||
|
||||
required := map[ActionID]bool{
|
||||
ActionNavUp: false,
|
||||
ActionNavDown: false,
|
||||
ActionMoveTaskLeft: false,
|
||||
ActionMoveTaskRight: false,
|
||||
ActionOpenFromPlugin: false,
|
||||
ActionNewTask: false,
|
||||
ActionDeleteTask: false,
|
||||
ActionToggleViewMode: false,
|
||||
ActionSearch: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ func (ir *InputRouter) handleTaskInput(event *tcell.EventKey, params map[string]
|
|||
ir.taskController.SetCurrentTask(taskID)
|
||||
}
|
||||
|
||||
registry := ir.taskController.GetActionRegistry()
|
||||
registry := ir.navController.GetActiveView().GetActionRegistry()
|
||||
if action := registry.Match(event); action != nil {
|
||||
switch action.ID {
|
||||
case ActionEditTitle:
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ import (
|
|||
|
||||
// PluginController handles plugin view actions: navigation, open, create, delete.
|
||||
type PluginController struct {
|
||||
taskStore store.Store
|
||||
pluginConfig *model.PluginConfig
|
||||
pluginDef *plugin.TikiPlugin
|
||||
navController *NavigationController
|
||||
registry *ActionRegistry
|
||||
pluginBase
|
||||
}
|
||||
|
||||
// NewPluginController creates a plugin controller
|
||||
|
|
@ -30,11 +26,13 @@ func NewPluginController(
|
|||
navController *NavigationController,
|
||||
) *PluginController {
|
||||
pc := &PluginController{
|
||||
taskStore: taskStore,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: pluginDef,
|
||||
navController: navController,
|
||||
registry: PluginViewActions(),
|
||||
pluginBase: pluginBase{
|
||||
taskStore: taskStore,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: pluginDef,
|
||||
navController: navController,
|
||||
registry: PluginViewActions(),
|
||||
},
|
||||
}
|
||||
|
||||
// register plugin-specific shortcut actions, warn about conflicts
|
||||
|
|
@ -86,42 +84,38 @@ func getPluginActionRune(id ActionID) rune {
|
|||
return runes[0]
|
||||
}
|
||||
|
||||
// GetActionRegistry returns the actions for the plugin view
|
||||
func (pc *PluginController) GetActionRegistry() *ActionRegistry {
|
||||
return pc.registry
|
||||
}
|
||||
|
||||
// GetPluginName returns the plugin name
|
||||
func (pc *PluginController) GetPluginName() string {
|
||||
return pc.pluginDef.Name
|
||||
}
|
||||
|
||||
// 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")
|
||||
return pc.handleNav("up", pc.GetFilteredTasksForLane)
|
||||
case ActionNavDown:
|
||||
return pc.handleNav("down")
|
||||
return pc.handleNav("down", pc.GetFilteredTasksForLane)
|
||||
case ActionNavLeft:
|
||||
return pc.handleNav("left")
|
||||
return pc.handleNav("left", pc.GetFilteredTasksForLane)
|
||||
case ActionNavRight:
|
||||
return pc.handleNav("right")
|
||||
return pc.handleNav("right", pc.GetFilteredTasksForLane)
|
||||
case ActionMoveTaskLeft:
|
||||
return pc.handleMoveTask(-1)
|
||||
case ActionMoveTaskRight:
|
||||
return pc.handleMoveTask(1)
|
||||
case ActionOpenFromPlugin:
|
||||
return pc.handleOpenTask()
|
||||
return pc.handleOpenTask(pc.GetFilteredTasksForLane)
|
||||
case ActionNewTask:
|
||||
return pc.handleNewTask()
|
||||
case ActionDeleteTask:
|
||||
return pc.handleDeleteTask()
|
||||
return pc.handleDeleteTask(pc.GetFilteredTasksForLane)
|
||||
case ActionToggleViewMode:
|
||||
return pc.handleToggleViewMode()
|
||||
pc.pluginConfig.ToggleViewMode()
|
||||
return true
|
||||
default:
|
||||
if r := getPluginActionRune(actionID); r != 0 {
|
||||
return pc.handlePluginAction(r)
|
||||
|
|
@ -130,96 +124,11 @@ func (pc *PluginController) HandleAction(actionID ActionID) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleNav(direction string) bool {
|
||||
lane := pc.pluginConfig.GetSelectedLane()
|
||||
tasks := pc.GetFilteredTasksForLane(lane)
|
||||
if direction == "left" || direction == "right" {
|
||||
if pc.pluginConfig.MoveSelection(direction, len(tasks)) {
|
||||
return true
|
||||
}
|
||||
return pc.handleLaneSwitch(direction)
|
||||
}
|
||||
return pc.pluginConfig.MoveSelection(direction, len(tasks))
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleOpenTask() bool {
|
||||
taskID := pc.getSelectedTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
pc.navController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{
|
||||
TaskID: taskID,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleLaneSwitch(direction string) bool {
|
||||
currentLane := pc.pluginConfig.GetSelectedLane()
|
||||
nextLane := currentLane
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for nextLane >= 0 && nextLane < len(pc.pluginDef.Lanes) {
|
||||
tasks := pc.GetFilteredTasksForLane(nextLane)
|
||||
if len(tasks) > 0 {
|
||||
pc.pluginConfig.SetSelectedLane(nextLane)
|
||||
// Select the task at top of viewport (scroll offset) rather than keeping stale index
|
||||
scrollOffset := pc.pluginConfig.GetScrollOffsetForLane(nextLane)
|
||||
if scrollOffset >= len(tasks) {
|
||||
scrollOffset = len(tasks) - 1
|
||||
}
|
||||
if scrollOffset < 0 {
|
||||
scrollOffset = 0
|
||||
}
|
||||
pc.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
|
||||
return true
|
||||
}
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleNewTask() bool {
|
||||
task, err := pc.taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("failed to create task template", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
pc.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: task.ID,
|
||||
Draft: task,
|
||||
Focus: model.EditFieldTitle,
|
||||
}))
|
||||
slog.Info("new tiki draft started from plugin", "task_id", task.ID, "plugin", pc.pluginDef.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleDeleteTask() bool {
|
||||
taskID := pc.getSelectedTaskID()
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
pc.taskStore.DeleteTask(taskID)
|
||||
return true
|
||||
}
|
||||
|
||||
func (pc *PluginController) handleToggleViewMode() bool {
|
||||
pc.pluginConfig.ToggleViewMode()
|
||||
return true
|
||||
// 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.
|
||||
|
|
@ -236,7 +145,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
taskID := pc.getSelectedTaskID()
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
|
|
@ -264,7 +173,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
}
|
||||
|
||||
func (pc *PluginController) handleMoveTask(offset int) bool {
|
||||
taskID := pc.getSelectedTaskID()
|
||||
taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
|
|
@ -297,44 +206,10 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
|
|||
}
|
||||
|
||||
pc.ensureSearchResultIncludesTask(updated)
|
||||
pc.selectTaskInLane(targetLane, taskID)
|
||||
pc.selectTaskInLane(targetLane, taskID, pc.GetFilteredTasksForLane)
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleSearch processes a search query for the plugin view
|
||||
func (pc *PluginController) HandleSearch(query string) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return // Don't search empty/whitespace
|
||||
}
|
||||
|
||||
// Save current position
|
||||
pc.pluginConfig.SavePreSearchState()
|
||||
|
||||
// Search across all tasks; lane membership is decided per lane
|
||||
results := pc.taskStore.Search(query, nil)
|
||||
if len(results) == 0 {
|
||||
pc.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
|
||||
return
|
||||
}
|
||||
|
||||
pc.pluginConfig.SetSearchResults(results, query)
|
||||
if pc.selectFirstNonEmptyLane() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// getSelectedTaskID returns the ID of the currently selected task
|
||||
func (pc *PluginController) getSelectedTaskID() string {
|
||||
lane := pc.pluginConfig.GetSelectedLane()
|
||||
tasks := pc.GetFilteredTasksForLane(lane)
|
||||
idx := pc.pluginConfig.GetSelectedIndexForLane(lane)
|
||||
if idx < 0 || idx >= len(tasks) {
|
||||
return ""
|
||||
}
|
||||
return tasks[idx].ID
|
||||
}
|
||||
|
||||
// GetFilteredTasksForLane returns tasks filtered and sorted for a specific lane.
|
||||
func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
||||
if pc.pluginDef == nil {
|
||||
|
|
@ -344,17 +219,13 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Check if search is active - if so, return search results instead
|
||||
// check if search is active
|
||||
searchResults := pc.pluginConfig.GetSearchResults()
|
||||
|
||||
// Normal filtering path when search is not active
|
||||
allTasks := pc.taskStore.GetAllTasks()
|
||||
now := time.Now()
|
||||
|
||||
// Get current user for "my tasks" type filters
|
||||
currentUser := getCurrentUserName(pc.taskStore)
|
||||
|
||||
// Apply filter
|
||||
var filtered []*task.Task
|
||||
for _, task := range allTasks {
|
||||
laneFilter := pc.pluginDef.Lanes[lane].Filter
|
||||
|
|
@ -371,7 +242,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
filtered = filterTasksBySearch(filtered, searchTaskMap)
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
if len(pc.pluginDef.Sort) > 0 {
|
||||
plugin.SortTasks(filtered, pc.pluginDef.Sort)
|
||||
}
|
||||
|
|
@ -379,49 +249,6 @@ func (pc *PluginController) GetFilteredTasksForLane(lane int) []*task.Task {
|
|||
return filtered
|
||||
}
|
||||
|
||||
func (pc *PluginController) selectTaskInLane(lane int, taskID string) {
|
||||
if lane < 0 || lane >= len(pc.pluginDef.Lanes) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks := pc.GetFilteredTasksForLane(lane)
|
||||
targetIndex := 0
|
||||
for i, task := range tasks {
|
||||
if task.ID == taskID {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pc.pluginConfig.SetSelectedLane(lane)
|
||||
pc.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
|
||||
}
|
||||
|
||||
func (pc *PluginController) selectFirstNonEmptyLane() bool {
|
||||
for lane := range pc.pluginDef.Lanes {
|
||||
tasks := pc.GetFilteredTasksForLane(lane)
|
||||
if len(tasks) > 0 {
|
||||
pc.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pc *PluginController) EnsureFirstNonEmptyLaneSelection() bool {
|
||||
if pc.pluginDef == nil {
|
||||
return false
|
||||
}
|
||||
currentLane := pc.pluginConfig.GetSelectedLane()
|
||||
if currentLane >= 0 && currentLane < len(pc.pluginDef.Lanes) {
|
||||
tasks := pc.GetFilteredTasksForLane(currentLane)
|
||||
if len(tasks) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return pc.selectFirstNonEmptyLane()
|
||||
}
|
||||
|
||||
func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
|
||||
if updated == nil {
|
||||
return
|
||||
|
|
@ -442,16 +269,3 @@ func (pc *PluginController) ensureSearchResultIncludesTask(updated *task.Task) {
|
|||
})
|
||||
pc.pluginConfig.SetSearchResults(searchResults, pc.pluginConfig.GetSearchQuery())
|
||||
}
|
||||
|
||||
func filterTasksBySearch(tasks []*task.Task, searchMap map[string]bool) []*task.Task {
|
||||
if searchMap == nil {
|
||||
return tasks
|
||||
}
|
||||
filtered := make([]*task.Task, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if searchMap[t.ID] {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
|
|
|||
186
controller/plugin_base.go
Normal file
186
controller/plugin_base.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// pluginBase holds the shared fields and methods common to PluginController and DepsController.
|
||||
// Methods that depend on per-controller filtering accept a filteredTasks callback.
|
||||
type pluginBase struct {
|
||||
taskStore store.Store
|
||||
pluginConfig *model.PluginConfig
|
||||
pluginDef *plugin.TikiPlugin
|
||||
navController *NavigationController
|
||||
registry *ActionRegistry
|
||||
}
|
||||
|
||||
func (pb *pluginBase) GetActionRegistry() *ActionRegistry { return pb.registry }
|
||||
func (pb *pluginBase) GetPluginName() string { return pb.pluginDef.Name }
|
||||
|
||||
func (pb *pluginBase) handleNav(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
lane := pb.pluginConfig.GetSelectedLane()
|
||||
tasks := filteredTasks(lane)
|
||||
if direction == "left" || direction == "right" {
|
||||
if pb.pluginConfig.MoveSelection(direction, len(tasks)) {
|
||||
return true
|
||||
}
|
||||
return pb.handleLaneSwitch(direction, filteredTasks)
|
||||
}
|
||||
return pb.pluginConfig.MoveSelection(direction, len(tasks))
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleLaneSwitch(direction string, filteredTasks func(int) []*task.Task) bool {
|
||||
currentLane := pb.pluginConfig.GetSelectedLane()
|
||||
nextLane := currentLane
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for nextLane >= 0 && nextLane < len(pb.pluginDef.Lanes) {
|
||||
tasks := filteredTasks(nextLane)
|
||||
if len(tasks) > 0 {
|
||||
pb.pluginConfig.SetSelectedLane(nextLane)
|
||||
// select the task at top of viewport (scroll offset) rather than keeping stale index
|
||||
scrollOffset := pb.pluginConfig.GetScrollOffsetForLane(nextLane)
|
||||
if scrollOffset >= len(tasks) {
|
||||
scrollOffset = len(tasks) - 1
|
||||
}
|
||||
if scrollOffset < 0 {
|
||||
scrollOffset = 0
|
||||
}
|
||||
pb.pluginConfig.SetSelectedIndexForLane(nextLane, scrollOffset)
|
||||
return true
|
||||
}
|
||||
switch direction {
|
||||
case "left":
|
||||
nextLane--
|
||||
case "right":
|
||||
nextLane++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pb *pluginBase) getSelectedTaskID(filteredTasks func(int) []*task.Task) string {
|
||||
lane := pb.pluginConfig.GetSelectedLane()
|
||||
tasks := filteredTasks(lane)
|
||||
idx := pb.pluginConfig.GetSelectedIndexForLane(lane)
|
||||
if idx < 0 || idx >= len(tasks) {
|
||||
return ""
|
||||
}
|
||||
return tasks[idx].ID
|
||||
}
|
||||
|
||||
func (pb *pluginBase) selectTaskInLane(lane int, taskID string, filteredTasks func(int) []*task.Task) {
|
||||
if lane < 0 || lane >= len(pb.pluginDef.Lanes) {
|
||||
return
|
||||
}
|
||||
tasks := filteredTasks(lane)
|
||||
targetIndex := 0
|
||||
for i, t := range tasks {
|
||||
if t.ID == taskID {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
pb.pluginConfig.SetSelectedLane(lane)
|
||||
pb.pluginConfig.SetSelectedIndexForLane(lane, targetIndex)
|
||||
}
|
||||
|
||||
func (pb *pluginBase) selectFirstNonEmptyLane(filteredTasks func(int) []*task.Task) bool {
|
||||
for lane := range pb.pluginDef.Lanes {
|
||||
if len(filteredTasks(lane)) > 0 {
|
||||
pb.pluginConfig.SetSelectedLaneAndIndex(lane, 0)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnsureFirstNonEmptyLaneSelection selects the first non-empty lane if the current lane is empty.
|
||||
func (pb *pluginBase) EnsureFirstNonEmptyLaneSelection(filteredTasks func(int) []*task.Task) bool {
|
||||
if pb.pluginDef == nil {
|
||||
return false
|
||||
}
|
||||
currentLane := pb.pluginConfig.GetSelectedLane()
|
||||
if currentLane >= 0 && currentLane < len(pb.pluginDef.Lanes) {
|
||||
if len(filteredTasks(currentLane)) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return pb.selectFirstNonEmptyLane(filteredTasks)
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleSearch(query string, selectFirst func() bool) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return
|
||||
}
|
||||
pb.pluginConfig.SavePreSearchState()
|
||||
results := pb.taskStore.Search(query, nil)
|
||||
if len(results) == 0 {
|
||||
pb.pluginConfig.SetSearchResults([]task.SearchResult{}, query)
|
||||
return
|
||||
}
|
||||
pb.pluginConfig.SetSearchResults(results, query)
|
||||
selectFirst()
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleOpenTask(filteredTasks func(int) []*task.Task) bool {
|
||||
taskID := pb.getSelectedTaskID(filteredTasks)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
pb.navController.PushView(model.TaskDetailViewID, model.EncodeTaskDetailParams(model.TaskDetailParams{
|
||||
TaskID: taskID,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleNewTask() bool {
|
||||
t, err := pb.taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("failed to create task template", "error", err)
|
||||
return false
|
||||
}
|
||||
pb.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{
|
||||
TaskID: t.ID,
|
||||
Draft: t,
|
||||
Focus: model.EditFieldTitle,
|
||||
}))
|
||||
slog.Info("new tiki draft started from plugin", "task_id", t.ID, "plugin", pb.pluginDef.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
func (pb *pluginBase) handleDeleteTask(filteredTasks func(int) []*task.Task) bool {
|
||||
taskID := pb.getSelectedTaskID(filteredTasks)
|
||||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
pb.taskStore.DeleteTask(taskID)
|
||||
return true
|
||||
}
|
||||
|
||||
func filterTasksBySearch(tasks []*task.Task, searchMap map[string]bool) []*task.Task {
|
||||
if searchMap == nil {
|
||||
return tasks
|
||||
}
|
||||
filtered := make([]*task.Task, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if searchMap[t.ID] {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
|
@ -8,11 +8,9 @@ import (
|
|||
|
||||
// newMockNavigationController creates a new mock navigation controller
|
||||
func newMockNavigationController() *NavigationController {
|
||||
// Create a real NavigationController but we won't use most of its methods in tests
|
||||
// The key is that TaskController only calls SuspendAndEdit which we can ignore in tests
|
||||
return &NavigationController{
|
||||
// minimal initialization - only used to satisfy type checking
|
||||
app: nil, // Unit tests don't need the tview.Application
|
||||
app: nil, // unit tests don't need the tview.Application
|
||||
navState: newViewStack(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ const (
|
|||
paramDraftTask = "draftTask"
|
||||
paramFocus = "focus"
|
||||
paramDescOnly = "descOnly"
|
||||
paramReadOnly = "readOnly"
|
||||
)
|
||||
|
||||
// TaskDetailParams are params for TaskDetailViewID.
|
||||
type TaskDetailParams struct {
|
||||
TaskID string
|
||||
TaskID string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// EncodeTaskDetailParams converts typed params into a navigation params map.
|
||||
|
|
@ -21,9 +23,13 @@ func EncodeTaskDetailParams(p TaskDetailParams) map[string]interface{} {
|
|||
if p.TaskID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
m := map[string]interface{}{
|
||||
paramTaskID: p.TaskID,
|
||||
}
|
||||
if p.ReadOnly {
|
||||
m[paramReadOnly] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// DecodeTaskDetailParams converts a navigation params map into typed params.
|
||||
|
|
@ -35,6 +41,9 @@ func DecodeTaskDetailParams(params map[string]interface{}) TaskDetailParams {
|
|||
if id, ok := params[paramTaskID].(string); ok {
|
||||
p.TaskID = id
|
||||
}
|
||||
if readOnly, ok := params[paramReadOnly].(bool); ok {
|
||||
p.ReadOnly = readOnly
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,35 @@ func TestTaskDetailParams_DecodeInvalidParams(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailParams_ReadOnlyRoundTrip(t *testing.T) {
|
||||
// ReadOnly=true should survive encode/decode
|
||||
params := TaskDetailParams{
|
||||
TaskID: "TIKI-1",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
encoded := EncodeTaskDetailParams(params)
|
||||
decoded := DecodeTaskDetailParams(encoded)
|
||||
|
||||
if !decoded.ReadOnly {
|
||||
t.Error("round-trip failed: ReadOnly = false, want true")
|
||||
}
|
||||
|
||||
// ReadOnly=false should not be stored (keeps params map clean)
|
||||
paramsNoRO := TaskDetailParams{
|
||||
TaskID: "TIKI-2",
|
||||
}
|
||||
encodedNoRO := EncodeTaskDetailParams(paramsNoRO)
|
||||
if _, exists := encodedNoRO[paramReadOnly]; exists {
|
||||
t.Error("ReadOnly=false should not be stored in encoded params")
|
||||
}
|
||||
|
||||
decodedNoRO := DecodeTaskDetailParams(encodedNoRO)
|
||||
if decodedNoRO.ReadOnly {
|
||||
t.Error("ReadOnly should default to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEditParams_EncodeDecodeRoundTrip(t *testing.T) {
|
||||
draftTask := &taskpkg.Task{
|
||||
ID: "TIKI-42",
|
||||
|
|
|
|||
|
|
@ -68,8 +68,8 @@ func (f *ViewFactory) CreateView(viewID model.ViewID, params map[string]interfac
|
|||
|
||||
switch viewID {
|
||||
case model.TaskDetailViewID:
|
||||
taskID := model.DecodeTaskDetailParams(params).TaskID
|
||||
v = taskdetail.NewTaskDetailView(f.taskStore, taskID, f.imageManager, f.mermaidOpts)
|
||||
detailParams := model.DecodeTaskDetailParams(params)
|
||||
v = taskdetail.NewTaskDetailView(f.taskStore, detailParams.TaskID, detailParams.ReadOnly, f.imageManager, f.mermaidOpts)
|
||||
|
||||
case model.TaskEditViewID:
|
||||
editParams := model.DecodeTaskEditParams(params)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func TestBuildMetadataColumns_Structure(t *testing.T) {
|
|||
UpdatedAt: time.Date(2024, 1, 2, 14, 30, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
view := NewTaskDetailView(s, task.ID, nil, nil)
|
||||
view := NewTaskDetailView(s, task.ID, false, nil, nil)
|
||||
view.SetFallbackTask(task)
|
||||
|
||||
colors := config.GetColors()
|
||||
|
|
@ -61,7 +61,7 @@ func TestBuildMetadataColumns_Column1Fields(t *testing.T) {
|
|||
Points: 5,
|
||||
}
|
||||
|
||||
view := NewTaskDetailView(s, task.ID, nil, nil)
|
||||
view := NewTaskDetailView(s, task.ID, false, nil, nil)
|
||||
view.SetFallbackTask(task)
|
||||
|
||||
colors := config.GetColors()
|
||||
|
|
@ -89,7 +89,7 @@ func TestBuildMetadataColumns_Column2Fields(t *testing.T) {
|
|||
UpdatedAt: time.Date(2024, 1, 2, 14, 30, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
view := NewTaskDetailView(s, task.ID, nil, nil)
|
||||
view := NewTaskDetailView(s, task.ID, false, nil, nil)
|
||||
view.SetFallbackTask(task)
|
||||
|
||||
colors := config.GetColors()
|
||||
|
|
@ -113,7 +113,7 @@ func TestBuildMetadataColumns_Column3Fields(t *testing.T) {
|
|||
Title: "Test Task",
|
||||
}
|
||||
|
||||
view := NewTaskDetailView(s, task.ID, nil, nil)
|
||||
view := NewTaskDetailView(s, task.ID, false, nil, nil)
|
||||
view.SetFallbackTask(task)
|
||||
|
||||
colors := config.GetColors()
|
||||
|
|
|
|||
|
|
@ -28,8 +28,13 @@ type TaskDetailView struct {
|
|||
storeListenerID int
|
||||
}
|
||||
|
||||
// NewTaskDetailView creates a task detail view in read-only mode
|
||||
func NewTaskDetailView(taskStore store.Store, taskID string, imageManager *navtview.ImageManager, mermaidOpts *nav.MermaidOptions) *TaskDetailView {
|
||||
// NewTaskDetailView creates a task detail view.
|
||||
// When readOnly is true, only fullscreen is available — no editing actions.
|
||||
func NewTaskDetailView(taskStore store.Store, taskID string, readOnly bool, imageManager *navtview.ImageManager, mermaidOpts *nav.MermaidOptions) *TaskDetailView {
|
||||
registry := controller.TaskDetailViewActions()
|
||||
if readOnly {
|
||||
registry = controller.ReadonlyTaskDetailViewActions()
|
||||
}
|
||||
tv := &TaskDetailView{
|
||||
Base: Base{
|
||||
taskStore: taskStore,
|
||||
|
|
@ -37,7 +42,7 @@ func NewTaskDetailView(taskStore store.Store, taskID string, imageManager *navtv
|
|||
imageManager: imageManager,
|
||||
mermaidOpts: mermaidOpts,
|
||||
},
|
||||
registry: controller.TaskDetailViewActions(),
|
||||
registry: registry,
|
||||
viewID: model.TaskDetailViewID,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue