mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
434 lines
13 KiB
Go
434 lines
13 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/boolean-maybe/tiki/config"
|
|
"github.com/boolean-maybe/tiki/model"
|
|
"github.com/boolean-maybe/tiki/store"
|
|
taskpkg "github.com/boolean-maybe/tiki/task"
|
|
|
|
"time"
|
|
)
|
|
|
|
// TaskController handles task detail actions: editing, status changes, comments.
|
|
|
|
// TaskController handles task detail view actions
|
|
type TaskController struct {
|
|
taskStore store.Store
|
|
navController *NavigationController
|
|
currentTaskID string
|
|
draftTask *taskpkg.Task // For new task creation only
|
|
editingTask *taskpkg.Task // In-memory copy being edited (existing tasks)
|
|
originalMtime time.Time // LoadedMtime when edit started
|
|
registry *ActionRegistry
|
|
editRegistry *ActionRegistry
|
|
focusedField model.EditField // currently focused field in edit mode
|
|
}
|
|
|
|
// NewTaskController creates a new TaskController for managing task detail operations.
|
|
// It initializes action registries for both detail and edit views.
|
|
func NewTaskController(
|
|
taskStore store.Store,
|
|
navController *NavigationController,
|
|
) *TaskController {
|
|
return &TaskController{
|
|
taskStore: taskStore,
|
|
navController: navController,
|
|
registry: TaskDetailViewActions(),
|
|
editRegistry: TaskEditViewActions(),
|
|
}
|
|
}
|
|
|
|
// SetCurrentTask sets the task ID for the currently viewed or edited task.
|
|
func (tc *TaskController) SetCurrentTask(taskID string) {
|
|
tc.currentTaskID = taskID
|
|
}
|
|
|
|
// SetDraft sets a draft task for creation flow (not yet persisted).
|
|
func (tc *TaskController) SetDraft(task *taskpkg.Task) {
|
|
tc.draftTask = task
|
|
if task != nil {
|
|
tc.currentTaskID = task.ID
|
|
}
|
|
}
|
|
|
|
// ClearDraft removes any in-progress draft task.
|
|
func (tc *TaskController) ClearDraft() {
|
|
tc.draftTask = nil
|
|
}
|
|
|
|
// StartEditSession creates an in-memory copy of the specified task for editing.
|
|
// It loads the task from the store and records its modification time for optimistic locking.
|
|
// Returns the editing copy, or nil if the task cannot be found.
|
|
func (tc *TaskController) StartEditSession(taskID string) *taskpkg.Task {
|
|
task := tc.taskStore.GetTask(taskID)
|
|
if task == nil {
|
|
return nil
|
|
}
|
|
|
|
tc.editingTask = task.Clone()
|
|
tc.originalMtime = task.LoadedMtime
|
|
tc.currentTaskID = taskID
|
|
|
|
return tc.editingTask
|
|
}
|
|
|
|
// GetEditingTask returns the task being edited (or nil if not editing)
|
|
func (tc *TaskController) GetEditingTask() *taskpkg.Task {
|
|
return tc.editingTask
|
|
}
|
|
|
|
// GetDraftTask returns the draft task being created (or nil if not creating)
|
|
func (tc *TaskController) GetDraftTask() *taskpkg.Task {
|
|
return tc.draftTask
|
|
}
|
|
|
|
// CancelEditSession discards the editing copy without saving changes.
|
|
// This clears the in-memory editing task and resets the current task ID.
|
|
func (tc *TaskController) CancelEditSession() {
|
|
tc.editingTask = nil
|
|
tc.originalMtime = time.Time{}
|
|
tc.currentTaskID = ""
|
|
}
|
|
|
|
// CommitEditSession validates and persists changes from the current edit session.
|
|
// For draft tasks (new task creation), it validates, sets timestamps, and creates the file.
|
|
// For existing tasks, it checks for external modifications and updates the task in the store.
|
|
// Returns an error if validation fails or the task cannot be saved.
|
|
func (tc *TaskController) CommitEditSession() error {
|
|
// Handle draft task creation
|
|
if tc.draftTask != nil {
|
|
// Validate draft task before persisting
|
|
if errors := tc.draftTask.Validate(); errors.HasErrors() {
|
|
slog.Warn("draft task validation failed", "errors", errors.Error())
|
|
return nil // Don't save invalid draft
|
|
}
|
|
|
|
// Set timestamps and author for new task
|
|
now := time.Now()
|
|
if tc.draftTask.CreatedAt.IsZero() {
|
|
tc.draftTask.CreatedAt = now
|
|
}
|
|
setAuthorFromGit(tc.draftTask, tc.taskStore)
|
|
|
|
// Create the task file
|
|
if err := tc.taskStore.CreateTask(tc.draftTask); err != nil {
|
|
slog.Error("failed to create draft task", "error", err)
|
|
return fmt.Errorf("failed to create task: %w", err)
|
|
}
|
|
|
|
// Clear the draft
|
|
tc.draftTask = nil
|
|
return nil
|
|
}
|
|
|
|
// Handle existing task updates
|
|
if tc.editingTask == nil {
|
|
return nil // No active edit session, nothing to commit
|
|
}
|
|
|
|
// Validate editing task before persisting
|
|
if errors := tc.editingTask.Validate(); errors.HasErrors() {
|
|
slog.Warn("editing task validation failed", "taskID", tc.currentTaskID, "errors", errors.Error())
|
|
return fmt.Errorf("validation failed: %w", errors)
|
|
}
|
|
|
|
// Check for conflicts (file was modified externally)
|
|
currentTask := tc.taskStore.GetTask(tc.currentTaskID)
|
|
if currentTask != nil && !currentTask.LoadedMtime.Equal(tc.originalMtime) {
|
|
// TODO: Better error handling - show error to user
|
|
slog.Warn("task was modified externally", "taskID", tc.currentTaskID)
|
|
// For now, proceed with save (last write wins)
|
|
}
|
|
|
|
// Update the task in the store
|
|
if err := tc.taskStore.UpdateTask(tc.editingTask); err != nil {
|
|
slog.Error("failed to update task", "taskID", tc.currentTaskID, "error", err)
|
|
return fmt.Errorf("failed to update task: %w", err)
|
|
}
|
|
|
|
// Clear the edit session
|
|
tc.editingTask = nil
|
|
tc.originalMtime = time.Time{}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetActionRegistry returns the actions for the task detail view
|
|
func (tc *TaskController) GetActionRegistry() *ActionRegistry {
|
|
return tc.registry
|
|
}
|
|
|
|
// GetEditActionRegistry returns the actions for the task edit view
|
|
func (tc *TaskController) GetEditActionRegistry() *ActionRegistry {
|
|
return tc.editRegistry
|
|
}
|
|
|
|
// HandleAction processes task detail view actions such as editing title or source.
|
|
// Returns true if the action was handled, false otherwise.
|
|
func (tc *TaskController) HandleAction(actionID ActionID) bool {
|
|
switch actionID {
|
|
case ActionEditTitle:
|
|
return tc.handleEditTitle()
|
|
case ActionEditSource:
|
|
return tc.handleEditSource()
|
|
case ActionCloneTask:
|
|
return tc.handleCloneTask()
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (tc *TaskController) handleEditTitle() bool {
|
|
task := tc.GetCurrentTask()
|
|
if task == nil {
|
|
return false
|
|
}
|
|
|
|
// Title editing is handled by InputRouter which has access to the view
|
|
// This method is kept for consistency but the actual work is done in InputRouter
|
|
return true
|
|
}
|
|
|
|
func (tc *TaskController) handleEditSource() bool {
|
|
task := tc.GetCurrentTask()
|
|
if task == nil {
|
|
return false
|
|
}
|
|
|
|
// Construct the file path for this task
|
|
filename := strings.ToLower(task.ID) + ".md"
|
|
filePath := filepath.Join(config.GetTaskDir(), filename)
|
|
|
|
// Suspend the tview app and open the editor
|
|
tc.navController.SuspendAndEdit(filePath)
|
|
|
|
// Reload only this task after editing (more efficient than reloading all tasks)
|
|
// This preserves any custom YAML fields, comments, or formatting added in the external editor
|
|
_ = tc.taskStore.ReloadTask(task.ID)
|
|
|
|
return true
|
|
}
|
|
|
|
// SaveTitle saves the new title to the current task (draft or editing).
|
|
// For draft tasks (new task creation), updates the draft; for editing tasks, updates the editing copy.
|
|
// Returns true if a task was updated, false if no task is being edited.
|
|
func (tc *TaskController) SaveTitle(newTitle string) bool {
|
|
// Update draft task first (new task creation takes priority)
|
|
if tc.draftTask != nil {
|
|
tc.draftTask.Title = newTitle
|
|
return true
|
|
}
|
|
// Otherwise update editing task (existing task editing)
|
|
if tc.editingTask != nil {
|
|
tc.editingTask.Title = newTitle
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SaveDescription saves the new description to the current task (draft or editing).
|
|
// For draft tasks (new task creation), updates the draft; for editing tasks, updates the editing copy.
|
|
// Returns true if a task was updated, false if no task is being edited.
|
|
func (tc *TaskController) SaveDescription(newDescription string) bool {
|
|
// Update draft task first (new task creation takes priority)
|
|
if tc.draftTask != nil {
|
|
tc.draftTask.Description = newDescription
|
|
return true
|
|
}
|
|
// Otherwise update editing task (existing task editing)
|
|
if tc.editingTask != nil {
|
|
tc.editingTask.Description = newDescription
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// updateTaskField updates a field in either the draft task or editing task.
|
|
// It applies the setter function to the appropriate task based on priority:
|
|
// draft task (new task creation) takes priority over editing task (existing task edit).
|
|
// Returns true if a task was updated, false if no task is being edited.
|
|
func (tc *TaskController) updateTaskField(setter func(*taskpkg.Task)) bool {
|
|
if tc.draftTask != nil {
|
|
setter(tc.draftTask)
|
|
return true
|
|
}
|
|
if tc.editingTask != nil {
|
|
setter(tc.editingTask)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SaveStatus saves the new status to the current task after validating the display value.
|
|
// Returns true if the status was successfully updated, false otherwise.
|
|
func (tc *TaskController) SaveStatus(statusDisplay string) bool {
|
|
// Parse status display back to TaskStatus
|
|
// Try to match the display string to a known status
|
|
var newStatus taskpkg.Status
|
|
statusFound := false
|
|
|
|
for _, s := range []taskpkg.Status{
|
|
taskpkg.StatusBacklog,
|
|
taskpkg.StatusReady,
|
|
taskpkg.StatusInProgress,
|
|
taskpkg.StatusReview,
|
|
taskpkg.StatusDone,
|
|
} {
|
|
if taskpkg.StatusDisplay(s) == statusDisplay {
|
|
newStatus = s
|
|
statusFound = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !statusFound {
|
|
// fallback: try to normalize the input
|
|
newStatus = taskpkg.NormalizeStatus(statusDisplay)
|
|
}
|
|
|
|
// Validate using StatusValidator
|
|
tempTask := &taskpkg.Task{Status: newStatus}
|
|
if err := tempTask.ValidateField("status"); err != nil {
|
|
slog.Warn("invalid status", "display", statusDisplay, "normalized", newStatus, "error", err.Message)
|
|
return false
|
|
}
|
|
|
|
// Use generic updater
|
|
return tc.updateTaskField(func(t *taskpkg.Task) {
|
|
t.Status = newStatus
|
|
})
|
|
}
|
|
|
|
// SaveType saves the new type to the current task after validating the display value.
|
|
// Returns true if the type was successfully updated, false otherwise.
|
|
func (tc *TaskController) SaveType(typeDisplay string) bool {
|
|
// Parse type display back to TaskType
|
|
var newType taskpkg.Type
|
|
typeFound := false
|
|
|
|
for _, t := range []taskpkg.Type{
|
|
taskpkg.TypeStory,
|
|
taskpkg.TypeBug,
|
|
taskpkg.TypeSpike,
|
|
taskpkg.TypeEpic,
|
|
} {
|
|
if taskpkg.TypeDisplay(t) == typeDisplay {
|
|
newType = t
|
|
typeFound = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !typeFound {
|
|
newType = taskpkg.NormalizeType(typeDisplay)
|
|
}
|
|
|
|
// Validate using TypeValidator
|
|
tempTask := &taskpkg.Task{Type: newType}
|
|
if err := tempTask.ValidateField("type"); err != nil {
|
|
slog.Warn("invalid type", "display", typeDisplay, "normalized", newType, "error", err.Message)
|
|
return false
|
|
}
|
|
|
|
return tc.updateTaskField(func(t *taskpkg.Task) {
|
|
t.Type = newType
|
|
})
|
|
}
|
|
|
|
// SavePriority saves the new priority to the current task.
|
|
// Returns true if the priority was successfully updated, false otherwise.
|
|
func (tc *TaskController) SavePriority(priority int) bool {
|
|
// Validate using PriorityValidator
|
|
tempTask := &taskpkg.Task{Priority: priority}
|
|
if err := tempTask.ValidateField("priority"); err != nil {
|
|
slog.Warn("invalid priority", "value", priority, "error", err.Message)
|
|
return false
|
|
}
|
|
|
|
return tc.updateTaskField(func(t *taskpkg.Task) {
|
|
t.Priority = priority
|
|
})
|
|
}
|
|
|
|
// SaveAssignee saves the new assignee to the current task.
|
|
// The special value "Unassigned" is normalized to an empty string.
|
|
// Returns true if the assignee was successfully updated, false otherwise.
|
|
func (tc *TaskController) SaveAssignee(assignee string) bool {
|
|
// Normalize "Unassigned" to empty string
|
|
if assignee == "Unassigned" {
|
|
assignee = ""
|
|
}
|
|
|
|
return tc.updateTaskField(func(t *taskpkg.Task) {
|
|
t.Assignee = assignee
|
|
})
|
|
}
|
|
|
|
// SavePoints saves the new story points to the current task.
|
|
// Returns true if the points were successfully updated, false otherwise.
|
|
func (tc *TaskController) SavePoints(points int) bool {
|
|
// Validate using PointsValidator
|
|
tempTask := &taskpkg.Task{Points: points}
|
|
if err := tempTask.ValidateField("points"); err != nil {
|
|
slog.Warn("invalid points", "value", points, "error", err.Message)
|
|
return false
|
|
}
|
|
|
|
return tc.updateTaskField(func(t *taskpkg.Task) {
|
|
t.Points = points
|
|
})
|
|
}
|
|
|
|
func (tc *TaskController) handleCloneTask() bool {
|
|
// TODO: trigger task clone flow from detail view
|
|
return true
|
|
}
|
|
|
|
// GetCurrentTask returns the task being viewed or edited.
|
|
// Returns nil if no task is currently active.
|
|
func (tc *TaskController) GetCurrentTask() *taskpkg.Task {
|
|
if tc.currentTaskID == "" {
|
|
return nil
|
|
}
|
|
return tc.taskStore.GetTask(tc.currentTaskID)
|
|
}
|
|
|
|
// GetCurrentTaskID returns the ID of the current task
|
|
func (tc *TaskController) GetCurrentTaskID() string {
|
|
return tc.currentTaskID
|
|
}
|
|
|
|
// GetFocusedField returns the currently focused field in edit mode
|
|
func (tc *TaskController) GetFocusedField() model.EditField {
|
|
return tc.focusedField
|
|
}
|
|
|
|
// SetFocusedField sets the currently focused field in edit mode
|
|
func (tc *TaskController) SetFocusedField(field model.EditField) {
|
|
tc.focusedField = field
|
|
}
|
|
|
|
// UpdateTask persists changes to the specified task in the store.
|
|
func (tc *TaskController) UpdateTask(task *taskpkg.Task) {
|
|
_ = tc.taskStore.UpdateTask(task)
|
|
}
|
|
|
|
// AddComment adds a new comment to the current task with the specified author and text.
|
|
// Returns false if no task is currently active, true if the comment was added successfully.
|
|
func (tc *TaskController) AddComment(author, text string) bool {
|
|
if tc.currentTaskID == "" {
|
|
return false
|
|
}
|
|
|
|
comment := taskpkg.Comment{
|
|
ID: generateID(),
|
|
Author: author,
|
|
Text: text,
|
|
}
|
|
return tc.taskStore.AddComment(tc.currentTaskID, comment)
|
|
}
|