mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
406 lines
11 KiB
Go
406 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
|
|
"github.com/boolean-maybe/tiki/model"
|
|
"github.com/boolean-maybe/tiki/service"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
)
|
|
|
|
// TaskEditCoordinator owns task edit lifecycle: preparing the view, wiring handlers,
|
|
// and implementing commit/cancel and field navigation policy.
|
|
type TaskEditCoordinator struct {
|
|
navController *NavigationController
|
|
taskController *TaskController
|
|
|
|
preparedView View
|
|
descOnly bool
|
|
tagsOnly bool
|
|
}
|
|
|
|
func NewTaskEditCoordinator(navController *NavigationController, taskController *TaskController) *TaskEditCoordinator {
|
|
return &TaskEditCoordinator{
|
|
navController: navController,
|
|
taskController: taskController,
|
|
}
|
|
}
|
|
|
|
// Prepare wires handlers and starts an edit session for the provided view instance.
|
|
// It is safe to call repeatedly; preparation is cached per active view instance.
|
|
func (c *TaskEditCoordinator) Prepare(activeView View, params model.TaskEditParams) {
|
|
if activeView == nil {
|
|
return
|
|
}
|
|
if c.preparedView == activeView {
|
|
return
|
|
}
|
|
|
|
if params.TaskID != "" {
|
|
c.taskController.SetCurrentTask(params.TaskID)
|
|
}
|
|
if params.Draft != nil {
|
|
c.taskController.SetDraft(params.Draft)
|
|
} else {
|
|
c.taskController.ClearDraft()
|
|
}
|
|
|
|
c.descOnly = params.DescOnly
|
|
c.tagsOnly = params.TagsOnly
|
|
c.prepareView(activeView, params.Focus)
|
|
c.preparedView = activeView
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) HandleKey(activeView View, event *tcell.EventKey) bool {
|
|
switch event.Key() {
|
|
case tcell.KeyCtrlS:
|
|
return c.CommitAndClose(activeView)
|
|
case tcell.KeyTab:
|
|
if c.descOnly || c.tagsOnly {
|
|
return false // let textarea handle Tab as literal tab
|
|
}
|
|
return c.FocusNextField(activeView)
|
|
case tcell.KeyBacktab:
|
|
if c.descOnly || c.tagsOnly {
|
|
return false
|
|
}
|
|
return c.FocusPrevField(activeView)
|
|
case tcell.KeyLeft:
|
|
if nav, ok := activeView.(RecurrencePartNavigable); ok {
|
|
if nav.MoveRecurrencePartLeft() {
|
|
c.updateFieldHint(activeView)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case tcell.KeyRight:
|
|
if nav, ok := activeView.(RecurrencePartNavigable); ok {
|
|
if nav.MoveRecurrencePartRight() {
|
|
c.updateFieldHint(activeView)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case tcell.KeyEscape:
|
|
return c.CancelAndClose()
|
|
case tcell.KeyUp:
|
|
return c.CycleFieldValueUp(activeView)
|
|
case tcell.KeyDown:
|
|
return c.CycleFieldValueDown(activeView)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) FocusNextField(activeView View) bool {
|
|
fieldFocusable, ok := activeView.(FieldFocusableView)
|
|
if !ok {
|
|
return false
|
|
}
|
|
result := fieldFocusable.FocusNextField()
|
|
c.updateFieldHint(activeView)
|
|
return result
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) FocusPrevField(activeView View) bool {
|
|
fieldFocusable, ok := activeView.(FieldFocusableView)
|
|
if !ok {
|
|
return false
|
|
}
|
|
result := fieldFocusable.FocusPrevField()
|
|
c.updateFieldHint(activeView)
|
|
return result
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) CycleFieldValueUp(activeView View) bool {
|
|
if cyclable, ok := activeView.(ValueCyclableView); ok {
|
|
return cyclable.CycleFieldValueUp()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) CycleFieldValueDown(activeView View) bool {
|
|
if cyclable, ok := activeView.(ValueCyclableView); ok {
|
|
return cyclable.CycleFieldValueDown()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) CommitAndClose(activeView View) bool {
|
|
if !c.commit(activeView) {
|
|
return false
|
|
}
|
|
c.clearFieldHint()
|
|
c.navController.HandleBack()
|
|
return true
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) CommitNoClose(activeView View) bool {
|
|
if !c.commit(activeView) {
|
|
return false
|
|
}
|
|
|
|
// Re-start edit session with newly saved task
|
|
taskID := c.taskController.currentTaskID
|
|
if editingTask := c.taskController.StartEditSession(taskID); editingTask != nil {
|
|
// Refresh view with new editing copy
|
|
if refreshable, ok := activeView.(interface{ Refresh() }); ok {
|
|
refreshable.Refresh()
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) CancelAndClose() bool {
|
|
// Cancel edit session (discards changes) and clear any draft.
|
|
c.taskController.CancelEditSession()
|
|
c.taskController.ClearDraft()
|
|
c.clearFieldHint()
|
|
c.navController.HandleBack()
|
|
return true
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) commit(activeView View) bool {
|
|
editorView, ok := activeView.(TaskEditView)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
// Check validation state - do not save if invalid
|
|
if validator, ok := activeView.(interface {
|
|
IsValid() bool
|
|
ValidationErrors() []string
|
|
}); ok {
|
|
if !validator.IsValid() {
|
|
if sl := c.taskController.statusline; sl != nil {
|
|
if errs := validator.ValidationErrors(); len(errs) > 0 {
|
|
sl.SetMessage(strings.Join(errs, "; "), model.MessageLevelError, true)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Update in-memory editing copy with latest widget values
|
|
c.taskController.SaveTitle(editorView.GetEditedTitle())
|
|
c.taskController.SaveDescription(editorView.GetEditedDescription())
|
|
c.taskController.SaveTags(editorView.GetEditedTags())
|
|
|
|
// Commit the edit session (writes to disk)
|
|
if err := c.taskController.CommitEditSession(); err != nil {
|
|
if sl := c.taskController.statusline; sl != nil {
|
|
sl.SetMessage(rejectionMessage(err), model.MessageLevelError, true)
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// updateFieldHint shows or clears a statusline hint based on the focused field.
|
|
func (c *TaskEditCoordinator) updateFieldHint(activeView View) {
|
|
sl := c.taskController.statusline
|
|
if sl == nil {
|
|
return
|
|
}
|
|
fieldFocusable, ok := activeView.(FieldFocusableView)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch fieldFocusable.GetFocusedField() {
|
|
case model.EditFieldStatus, model.EditFieldType, model.EditFieldPriority,
|
|
model.EditFieldAssignee, model.EditFieldPoints, model.EditFieldDue:
|
|
sl.SetMessage("↑↓ change value", model.MessageLevelInfo, false)
|
|
case model.EditFieldRecurrence:
|
|
if nav, ok := activeView.(RecurrencePartNavigable); ok && nav.IsRecurrenceValueFocused() {
|
|
sl.SetMessage("← edit pattern ↑↓ change value", model.MessageLevelInfo, false)
|
|
} else {
|
|
sl.SetMessage("↑↓ change pattern → edit value", model.MessageLevelInfo, false)
|
|
}
|
|
default:
|
|
sl.ClearMessage()
|
|
}
|
|
}
|
|
|
|
// clearFieldHint removes any statusline hint set by updateFieldHint.
|
|
func (c *TaskEditCoordinator) clearFieldHint() {
|
|
if sl := c.taskController.statusline; sl != nil {
|
|
sl.ClearMessage()
|
|
}
|
|
}
|
|
|
|
func (c *TaskEditCoordinator) prepareView(activeView View, focus model.EditField) {
|
|
app := c.navController.GetApp()
|
|
|
|
// Start edit session for existing tasks (creates in-memory copy)
|
|
// Draft tasks already have an in-memory copy via draftTask
|
|
if _, ok := activeView.(TaskEditView); ok {
|
|
if taskView, hasController := activeView.(interface{ SetTaskController(*TaskController) }); hasController {
|
|
taskView.SetTaskController(c.taskController)
|
|
}
|
|
|
|
// Only start edit session for non-draft tasks
|
|
if c.taskController.draftTask == nil {
|
|
taskID := c.taskController.currentTaskID
|
|
if taskID != "" {
|
|
c.taskController.StartEditSession(taskID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if titleEditableView, ok := activeView.(TitleEditableView); ok && !c.descOnly {
|
|
// Explicit save on Enter (commits and closes)
|
|
titleEditableView.SetTitleSaveHandler(func(_ string) {
|
|
c.CommitAndClose(activeView)
|
|
})
|
|
titleEditableView.SetTitleCancelHandler(func() {
|
|
c.CancelAndClose()
|
|
})
|
|
}
|
|
|
|
if descEditableView, ok := activeView.(DescriptionEditableView); ok {
|
|
if c.descOnly {
|
|
// in desc-only mode, Ctrl+S from description saves and exits
|
|
descEditableView.SetDescriptionSaveHandler(func(_ string) {
|
|
c.CommitAndClose(activeView)
|
|
})
|
|
} else {
|
|
descEditableView.SetDescriptionSaveHandler(func(_ string) {
|
|
c.CommitNoClose(activeView)
|
|
})
|
|
}
|
|
descEditableView.SetDescriptionCancelHandler(func() {
|
|
c.CancelAndClose()
|
|
})
|
|
}
|
|
|
|
if tagsEditableView, ok := activeView.(TagsEditableView); ok && c.tagsOnly {
|
|
tagsEditableView.SetTagsSaveHandler(func(_ string) {
|
|
c.CommitAndClose(activeView)
|
|
})
|
|
tagsEditableView.SetTagsCancelHandler(func() {
|
|
c.CancelAndClose()
|
|
})
|
|
}
|
|
|
|
if !c.descOnly {
|
|
if statusEditableView, ok := activeView.(StatusEditableView); ok {
|
|
statusEditableView.SetStatusSaveHandler(func(statusDisplay string) {
|
|
c.taskController.SaveStatus(statusDisplay)
|
|
})
|
|
}
|
|
|
|
if typeEditableView, ok := activeView.(TypeEditableView); ok {
|
|
typeEditableView.SetTypeSaveHandler(func(typeDisplay string) {
|
|
c.taskController.SaveType(typeDisplay)
|
|
})
|
|
}
|
|
|
|
if priorityEditableView, ok := activeView.(PriorityEditableView); ok {
|
|
priorityEditableView.SetPrioritySaveHandler(func(priority int) {
|
|
c.taskController.SavePriority(priority)
|
|
})
|
|
}
|
|
|
|
if assigneeEditableView, ok := activeView.(AssigneeEditableView); ok {
|
|
assigneeEditableView.SetAssigneeSaveHandler(func(assignee string) {
|
|
c.taskController.SaveAssignee(assignee)
|
|
})
|
|
}
|
|
|
|
if pointsEditableView, ok := activeView.(PointsEditableView); ok {
|
|
pointsEditableView.SetPointsSaveHandler(func(points int) {
|
|
c.taskController.SavePoints(points)
|
|
})
|
|
}
|
|
|
|
if dueEditableView, ok := activeView.(DueEditableView); ok {
|
|
dueEditableView.SetDueSaveHandler(func(dateStr string) {
|
|
c.taskController.SaveDue(dateStr)
|
|
})
|
|
}
|
|
|
|
if recurrenceEditableView, ok := activeView.(RecurrenceEditableView); ok {
|
|
recurrenceEditableView.SetRecurrenceSaveHandler(func(cron string) {
|
|
c.taskController.SaveRecurrence(cron)
|
|
})
|
|
}
|
|
}
|
|
|
|
// In tags-only mode, skip title focus entirely — go straight to tags textarea
|
|
if c.tagsOnly {
|
|
if tagsEditableView, ok := activeView.(TagsEditableView); ok {
|
|
if tags := tagsEditableView.ShowTagsEditor(); tags != nil {
|
|
app.SetFocus(tags)
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// In desc-only mode, skip title focus entirely — go straight to description
|
|
if c.descOnly {
|
|
if fieldFocusable, ok := activeView.(FieldFocusableView); ok {
|
|
fieldFocusable.SetFocusedField(model.EditFieldDescription)
|
|
}
|
|
if descEditableView, ok := activeView.(DescriptionEditableView); ok {
|
|
if desc := descEditableView.ShowDescriptionEditor(); desc != nil {
|
|
app.SetFocus(desc)
|
|
return
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Initialize with title field focused by default (or specified focus field)
|
|
if fieldFocusable, ok := activeView.(FieldFocusableView); ok {
|
|
fieldFocusable.SetFocusedField(model.EditFieldTitle)
|
|
}
|
|
|
|
if focus == model.EditFieldDescription {
|
|
if fieldFocusable, ok := activeView.(FieldFocusableView); ok {
|
|
fieldFocusable.SetFocusedField(model.EditFieldDescription)
|
|
}
|
|
if descEditableView, ok := activeView.(DescriptionEditableView); ok {
|
|
if desc := descEditableView.ShowDescriptionEditor(); desc != nil {
|
|
app.SetFocus(desc)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if titleEditableView, ok := activeView.(TitleEditableView); ok {
|
|
if title := titleEditableView.ShowTitleEditor(); title != nil {
|
|
app.SetFocus(title)
|
|
return
|
|
}
|
|
}
|
|
|
|
// fallback to next field focus cycle
|
|
_ = c.FocusNextField(activeView)
|
|
}
|
|
|
|
// rejectionMessage extracts a clean user-facing message from an error.
|
|
// For RejectionError: returns just the rejection reasons.
|
|
// For other errors: unwraps to the root cause to strip wrapper prefixes
|
|
// like "failed to update task: failed to save task:".
|
|
func rejectionMessage(err error) string {
|
|
var re *service.RejectionError
|
|
if errors.As(err, &re) {
|
|
reasons := make([]string, len(re.Rejections))
|
|
for i, r := range re.Rejections {
|
|
reasons[i] = r.Reason
|
|
}
|
|
return strings.Join(reasons, "; ")
|
|
}
|
|
// unwrap to the innermost error for a clean message
|
|
for {
|
|
inner := errors.Unwrap(err)
|
|
if inner == nil {
|
|
break
|
|
}
|
|
err = inner
|
|
}
|
|
return err.Error()
|
|
}
|