diff --git a/internal/ui/app.go b/internal/ui/app.go index 554a350b..70c1d9f3 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -29,6 +29,16 @@ import ( "github.com/bborn/workflow/internal/tasksummary" ) +// DashboardViewMode selects how the dashboard renders the task list. +type DashboardViewMode int + +const ( + // ViewModeBoard is the kanban board (default). + ViewModeBoard DashboardViewMode = iota + // ViewModeList is the sortable, filterable table. + ViewModeList +) + // View represents the current view. type View int @@ -102,11 +112,13 @@ type KeyMap struct { SpotlightSync key.Binding // Quick input focus QuickInput key.Binding + // Toggle between board and list views + ToggleView key.Binding } // ShortHelp returns key bindings to show in the mini help. func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Left, k.Right, k.Up, k.Down, k.Enter, k.New, k.Queue, k.Filter, k.CommandPalette, k.OpenBrowser, k.Help, k.Quit} + return []key.Binding{k.Left, k.Right, k.Up, k.Down, k.Enter, k.New, k.Queue, k.Filter, k.ToggleView, k.CommandPalette, k.OpenBrowser, k.Help, k.Quit} } // FullHelp returns keybindings for the expanded help view. @@ -117,7 +129,7 @@ func (k KeyMap) FullHelp() [][]key.Binding { {k.FocusBacklog, k.FocusInProgress, k.FocusBlocked, k.FocusDone, k.CollapseBacklog, k.CollapseDone}, {k.Enter, k.New, k.Queue, k.QueueDangerous, k.Close}, {k.Retry, k.Archive, k.Delete, k.OpenWorktree, k.OpenBrowser, k.Spotlight}, - {k.Filter, k.CommandPalette, k.Settings}, + {k.Filter, k.ToggleView, k.CommandPalette, k.Settings}, {k.ChangeStatus, k.TogglePin, k.Refresh, k.Help}, {k.Quit}, } @@ -290,6 +302,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("tab"), key.WithHelp("tab", "input"), ), + ToggleView: key.NewBinding( + key.WithKeys("v"), + key.WithHelp("v", "board/list"), + ), } } @@ -393,6 +409,8 @@ type AppModel struct { // Dashboard state tasks []*db.Task kanban *KanbanBoard + listView *ListView + viewMode DashboardViewMode loading bool err error notification string // Notification banner text @@ -563,7 +581,7 @@ func (m *AppModel) updateTaskInList(task *db.Task) { break } } - m.kanban.SetTasks(m.tasks) + m.setDashboardTasks(m.tasks) } // NewAppModel creates a new application model. @@ -587,6 +605,7 @@ func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string, ve // Start with zero size - will be set by WindowSizeMsg kanban := NewKanbanBoard(0, 0) + listView := NewListView(0, 0) // Setup help h := help.New() @@ -630,6 +649,7 @@ func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string, ve help: h, currentView: ViewDashboard, kanban: kanban, + listView: listView, loading: true, prevStatuses: make(map[int64]string), tasksNeedingInput: make(map[int64]bool), @@ -663,6 +683,131 @@ func (m *AppModel) SetTasks(tasks []*db.Task) { m.tasks = tasks m.loading = false m.kanban.SetTasks(tasks) + m.listView.SetTasks(tasks) +} + +// setDashboardTasks updates both dashboard renderers (board + list) with the same +// task set so switching views is instant and consistent. +func (m *AppModel) setDashboardTasks(tasks []*db.Task) { + m.kanban.SetTasks(tasks) + if m.listView != nil { + m.listView.SetTasks(tasks) + } +} + +// dashboardSelectedTask returns the selected task for whichever dashboard view is +// currently active. +func (m *AppModel) dashboardSelectedTask() *db.Task { + if m.viewMode == ViewModeList { + return m.listView.SelectedTask() + } + return m.kanban.SelectedTask() +} + +// dashboardHasPrevTask reports whether the active view has a previous task. +func (m *AppModel) dashboardHasPrevTask() bool { + if m.viewMode == ViewModeList { + return m.listView.HasPrevTask() + } + return m.kanban.HasPrevTask() +} + +// dashboardHasNextTask reports whether the active view has a next task. +func (m *AppModel) dashboardHasNextTask() bool { + if m.viewMode == ViewModeList { + return m.listView.HasNextTask() + } + return m.kanban.HasNextTask() +} + +// dashboardMoveUp moves the selection up in the active view. +func (m *AppModel) dashboardMoveUp() { + if m.viewMode == ViewModeList { + m.listView.MoveUp() + return + } + m.kanban.MoveUp() +} + +// dashboardMoveDown moves the selection down in the active view. +func (m *AppModel) dashboardMoveDown() { + if m.viewMode == ViewModeList { + m.listView.MoveDown() + return + } + m.kanban.MoveDown() +} + +// toggleViewMode switches between the kanban board and the list view, carrying +// the current selection across so the same task stays focused. +func (m *AppModel) toggleViewMode() { + if m.viewMode == ViewModeBoard { + m.viewMode = ViewModeList + if t := m.kanban.SelectedTask(); t != nil { + m.listView.SelectTask(t.ID) + } + } else { + m.viewMode = ViewModeBoard + if t := m.listView.SelectedTask(); t != nil { + m.kanban.SelectTask(t.ID) + } + } +} + +// updateListNav handles navigation, sorting, and filtering keys that are specific +// to the list view. It returns handled=true when the key was consumed. +func (m *AppModel) updateListNav(msg tea.KeyMsg) (bool, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Up): + m.listView.MoveUp() + return true, nil + case key.Matches(msg, m.keys.Down): + m.listView.MoveDown() + return true, nil + case key.Matches(msg, m.keys.Left): + m.listView.PrevSortColumn() + return true, nil + case key.Matches(msg, m.keys.Right): + m.listView.NextSortColumn() + return true, nil + } + + switch msg.String() { + case " ": + m.listView.ToggleSortDirection() + return true, nil + // [ ] cycle the project filter, matching the kanban "/[project]" convention + // where square brackets already mean "project". + case "[": + m.listView.CycleProjectFilter(-1) + return true, nil + case "]": + m.listView.CycleProjectFilter(1) + return true, nil + // { } cycle the status filter. + case "{": + m.listView.CycleStatusFilter(-1) + return true, nil + case "}": + m.listView.CycleStatusFilter(1) + return true, nil + case "<": + m.listView.CycleDateFilter(-1) + return true, nil + case ">": + m.listView.CycleDateFilter(1) + return true, nil + } + + // Numeric shortcuts select and open the Nth visible row. + if s := msg.String(); len(s) == 1 && s >= "1" && s <= "9" { + if task := m.listView.SelectVisibleRow(int(s[0] - '0')); task != nil { + return true, m.loadTask(task.ID) + } + return true, nil + } + + return false, nil } // SetDebugStatePath sets the path for dumping debug state. @@ -817,8 +962,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: // Handle mouse clicks on dashboard view if m.currentView == ViewDashboard && msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft { - // Check if clicking on a task card - if task := m.kanban.HandleClick(msg.X, msg.Y); task != nil { + // Check if clicking on a task card / row + if m.viewMode == ViewModeList { + if task := m.listView.HandleClick(msg.X, msg.Y); task != nil { + return m, m.loadTask(task.ID) + } + } else if task := m.kanban.HandleClick(msg.X, msg.Y); task != nil { return m, m.loadTask(task.ID) } } @@ -956,12 +1105,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.kanban.SetRunningProcesses(running) m.kanban.SetTasksNeedingInput(m.tasksNeedingInput) m.kanban.SetBlockedByDeps(msg.blockedByDeps) + m.listView.SetRunningProcesses(running) + m.listView.SetTasksNeedingInput(m.tasksNeedingInput) + m.listView.SetBlockedByDeps(msg.blockedByDeps) // Load cached PR info from database for instant display for _, t := range m.tasks { if t.PRInfoJSON != "" { if info := github.UnmarshalPRInfo(t.PRInfoJSON); info != nil { m.kanban.SetPRInfo(t.ID, info) + m.listView.SetPRInfo(t.ID, info) } } } @@ -1043,6 +1196,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update PR info in kanban and detail view if msg.info != nil { m.kanban.SetPRInfo(msg.taskID, msg.info) + m.listView.SetPRInfo(msg.taskID, msg.info) // Update detail view if showing this task if m.detailView != nil && m.selectedTask != nil && m.selectedTask.ID == msg.taskID { m.detailView.SetPRInfo(msg.info) @@ -1057,6 +1211,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, result := range msg.results { if result.info != nil { m.kanban.SetPRInfo(result.taskID, result.info) + m.listView.SetPRInfo(result.taskID, result.info) // Update detail view if showing this task if m.detailView != nil && m.selectedTask != nil && m.selectedTask.ID == result.taskID { m.detailView.SetPRInfo(result.info) @@ -1327,8 +1482,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } } - m.kanban.SetTasks(m.tasks) + m.setDashboardTasks(m.tasks) m.kanban.SetTasksNeedingInput(m.tasksNeedingInput) + m.listView.SetTasksNeedingInput(m.tasksNeedingInput) // Update detail view if showing this task if m.selectedTask != nil && m.selectedTask.ID == event.TaskID { @@ -1368,6 +1524,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { running[m.selectedTask.ID] = true } m.kanban.SetRunningProcesses(running) + m.listView.SetRunningProcesses(running) } cmds = append(cmds, m.tick()) @@ -1437,6 +1594,9 @@ func (m *AppModel) applyWindowSize(width, height int) { m.height = height m.help.Width = width m.kanban.SetSize(width, height-4) + if m.listView != nil { + m.listView.SetSize(width, height-4) + } if m.detailView != nil { m.detailView.SetSize(width, height) } @@ -1615,7 +1775,7 @@ func (m *AppModel) viewDashboard() string { // Render executor prompt preview if applicable promptPreview := "" promptPreviewHeight := 0 - if task := m.kanban.SelectedTask(); task != nil && m.tasksNeedingInput[task.ID] { + if task := m.dashboardSelectedTask(); task != nil && m.tasksNeedingInput[task.ID] { promptPreview = m.renderExecutorPromptPreview(task) promptPreviewHeight = lipgloss.Height(promptPreview) } @@ -1637,8 +1797,9 @@ func (m *AppModel) viewDashboard() string { kanbanHeight = 10 } - // Update kanban size + // Update view sizes m.kanban.SetSize(m.width, kanbanHeight) + m.listView.SetSize(m.width, kanbanHeight) var contentParts []string if header != "" { @@ -1648,16 +1809,22 @@ func (m *AppModel) viewDashboard() string { contentParts = append(contentParts, filterBar) } - // Show welcome/getting started message when kanban is empty - if m.showWelcome && m.kanban.IsEmpty() { + // Show welcome/getting started message when the board is empty (only on the + // kanban view; the list view shows its own empty state with filters intact). + if m.viewMode == ViewModeBoard && m.showWelcome && m.kanban.IsEmpty() { welcomeView := m.renderWelcomeMessage(kanbanHeight) contentParts = append(contentParts, welcomeView, helpView) } else { - kanbanView := m.kanban.View() + var boardView string + if m.viewMode == ViewModeList { + boardView = m.listView.View() + } else { + boardView = m.kanban.View() + } if promptPreview != "" { - contentParts = append(contentParts, kanbanView, promptPreview, helpView) + contentParts = append(contentParts, boardView, promptPreview, helpView) } else { - contentParts = append(contentParts, kanbanView, helpView) + contentParts = append(contentParts, boardView, helpView) } } @@ -1933,6 +2100,20 @@ func stripAnsiCodes(s string) string { } func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Toggling between board and list works in either mode. + if key.Matches(msg, m.keys.ToggleView) { + m.toggleViewMode() + return m, nil + } + + // In list mode, intercept navigation/sort/filter keys before the board + // handlers below get a chance to act on the (hidden) kanban. + if m.viewMode == ViewModeList { + if handled, cmd := m.updateListNav(msg); handled { + return m, cmd + } + } + switch { // Column navigation case key.Matches(msg, m.keys.Left): @@ -2038,7 +2219,7 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.Enter): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m, m.loadTask(task.ID) } @@ -2049,7 +2230,7 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.newTaskForm.Init() case key.Matches(msg, m.keys.Queue): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { // Don't allow queueing if task is already processing if task.Status == db.StatusProcessing { return m, nil @@ -2062,7 +2243,7 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.QueueDangerous): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { // Don't allow queueing if task is already processing if task.Status == db.StatusProcessing { return m, nil @@ -2075,13 +2256,13 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.TogglePin): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m, m.toggleTaskPinned(task.ID) } return m, nil case key.Matches(msg, m.keys.Retry): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { // Allow retry for blocked, done, or backlog tasks if task.Status == db.StatusBlocked || task.Status == db.StatusDone || task.Status == db.StatusBacklog { @@ -2094,12 +2275,12 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.Close): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m.showCloseConfirm(task) } case key.Matches(msg, m.keys.Archive): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { if task.Status == db.StatusArchived { // Unarchive the task return m, m.unarchiveTask(task.ID) @@ -2108,33 +2289,33 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.keys.Delete): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m.showDeleteConfirm(task) } case key.Matches(msg, m.keys.OpenWorktree): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m, m.openWorktreeInEditor(task) } case key.Matches(msg, m.keys.OpenBrowser): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m, m.openBrowser(task) } case key.Matches(msg, m.keys.Spotlight): - if task := m.kanban.SelectedTask(); task != nil && task.WorktreePath != "" { + if task := m.dashboardSelectedTask(); task != nil && task.WorktreePath != "" { return m, m.toggleSpotlight(task) } case key.Matches(msg, m.keys.SpotlightSync): - if task := m.kanban.SelectedTask(); task != nil && task.WorktreePath != "" { + if task := m.dashboardSelectedTask(); task != nil && task.WorktreePath != "" { return m, m.syncSpotlight(task) } case key.Matches(msg, m.keys.QuickInput): // Focus the quick input field if selected task needs input - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { if m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID) { m.quickInputFocused = true m.replyInput.SetValue("") @@ -2154,7 +2335,7 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.loadTasks() case key.Matches(msg, m.keys.ChangeStatus): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m.showChangeStatus(task) } @@ -2163,14 +2344,14 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.ApprovePrompt): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { if m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID) { return m, m.approveExecutorPrompt(task.ID) } } case key.Matches(msg, m.keys.DenyPrompt): - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { if m.tasksNeedingInput[task.ID] || m.detectPermissionPrompt(task.ID) { return m, m.denyExecutorPrompt(task.ID) } @@ -2222,7 +2403,7 @@ func (m *AppModel) updateQuickInput(msg tea.Msg) (tea.Model, tea.Cmd) { m.replyInput.Blur() return m, nil } - task := m.kanban.SelectedTask() + task := m.dashboardSelectedTask() if task == nil { m.quickInputFocused = false m.replyInput.SetValue("") @@ -2442,7 +2623,7 @@ func (m *AppModel) resolveProjectAliases(query string) string { func (m *AppModel) applyFilter() { if m.filterText == "" { // No filter, show all tasks - m.kanban.SetTasks(m.tasks) + m.setDashboardTasks(m.tasks) return } @@ -2472,7 +2653,7 @@ func (m *AppModel) applyFilter() { for i, st := range scored { filtered[i] = st.task } - m.kanban.SetTasks(filtered) + m.setDashboardTasks(filtered) } // parseFilterProjects extracts completed [project] tags, any trailing partial project, @@ -2738,8 +2919,8 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { // Arrow key navigation to prev/next task in the same column // j/k keys are passed through to the viewport for scrolling if key.Matches(keyMsg, m.keys.Up) { - // Ignore if no previous task exists - if !m.kanban.HasPrevTask() { + // Ignore if no previous task exists (in the active dashboard view) + if !m.dashboardHasPrevTask() { return m, nil } // Ignore if transition already in progress to prevent duplicate panes @@ -2752,18 +2933,18 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.CleanupWithoutSaving() m.detailView = nil } - // Move selection up in the kanban - m.kanban.MoveUp() + // Move selection up in the active view + m.dashboardMoveUp() // Load the new task - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m, m.loadTask(task.ID) } m.taskTransitionInProgress = false return m, nil } if key.Matches(keyMsg, m.keys.Down) { - // Ignore if no next task exists - if !m.kanban.HasNextTask() { + // Ignore if no next task exists (in the active dashboard view) + if !m.dashboardHasNextTask() { return m, nil } // Ignore if transition already in progress to prevent duplicate panes @@ -2776,10 +2957,10 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { m.detailView.CleanupWithoutSaving() m.detailView = nil } - // Move selection down in the kanban - m.kanban.MoveDown() + // Move selection down in the active view + m.dashboardMoveDown() // Load the new task - if task := m.kanban.SelectedTask(); task != nil { + if task := m.dashboardSelectedTask(); task != nil { return m, m.loadTask(task.ID) } m.taskTransitionInProgress = false diff --git a/internal/ui/debug.go b/internal/ui/debug.go index dc0b6034..e4ea0428 100644 --- a/internal/ui/debug.go +++ b/internal/ui/debug.go @@ -28,6 +28,18 @@ type DebugDashboard struct { Columns []DebugColumn `json:"columns"` FocusedColumn int `json:"focused_column"` SelectedTask int64 `json:"selected_task_id,omitempty"` + ViewMode string `json:"view_mode"` // "board" or "list" + List *DebugList `json:"list,omitempty"` // populated when in list view +} + +// DebugList captures the list view's sort/filter state for QA assertions. +type DebugList struct { + Sort string `json:"sort"` + SortDesc bool `json:"sort_desc"` + StatusFilter string `json:"status_filter"` + ProjectFilter string `json:"project_filter"` + DateFilter string `json:"date_filter"` + Rows []DebugTask `json:"rows"` } type DebugColumn struct { @@ -91,6 +103,15 @@ func (m *AppModel) GenerateDebugState() DebugState { if m.currentView == ViewDashboard { dash := &DebugDashboard{ FocusedColumn: m.kanban.selectedCol, + ViewMode: "board", + } + + if m.viewMode == ViewModeList && m.listView != nil { + dash.ViewMode = "list" + dash.List = m.listView.debugState() + if task := m.listView.SelectedTask(); task != nil { + dash.SelectedTask = task.ID + } } // Reconstruct columns from kanban state @@ -114,8 +135,10 @@ func (m *AppModel) GenerateDebugState() DebugState { dash.Columns = append(dash.Columns, dCol) } - if task := m.kanban.SelectedTask(); task != nil { - dash.SelectedTask = task.ID + if m.viewMode != ViewModeList { + if task := m.kanban.SelectedTask(); task != nil { + dash.SelectedTask = task.ID + } } s.Dashboard = dash diff --git a/internal/ui/list_view.go b/internal/ui/list_view.go new file mode 100644 index 00000000..0b0155c6 --- /dev/null +++ b/internal/ui/list_view.go @@ -0,0 +1,903 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/github" +) + +// SortColumn identifies which task field the list view is sorted by. +type SortColumn int + +const ( + SortByUpdated SortColumn = iota + SortByCreated + SortByID + SortByStatus + SortByTitle + SortByProject +) + +// sortColumns is the ordered set of columns the user can sort by (cycled with ←/→). +var sortColumns = []SortColumn{ + SortByUpdated, + SortByCreated, + SortByID, + SortByStatus, + SortByTitle, + SortByProject, +} + +// label returns the short header label for a sort column. +func (s SortColumn) label() string { + switch s { + case SortByUpdated: + return "Updated" + case SortByCreated: + return "Created" + case SortByID: + return "#" + case SortByStatus: + return "Status" + case SortByTitle: + return "Title" + case SortByProject: + return "Project" + default: + return "" + } +} + +// defaultDesc returns the natural sort direction for a column when first selected. +// Time-based and ID columns default to descending (newest/highest first); text +// columns default to ascending (A→Z). +func (s SortColumn) defaultDesc() bool { + switch s { + case SortByUpdated, SortByCreated, SortByID: + return true + default: + return false + } +} + +// statusRank orders statuses for the "Status" sort column so active work floats +// to the top and completed work sinks to the bottom. +func statusRank(status string) int { + switch status { + case db.StatusProcessing: + return 0 + case db.StatusBlocked: + return 1 + case db.StatusQueued: + return 2 + case db.StatusBacklog: + return 3 + case db.StatusDone: + return 4 + case db.StatusArchived: + return 5 + default: + return 6 + } +} + +// statusFilterOption represents a discrete status filter the user can cycle through. +type statusFilterOption struct { + Label string + Statuses []string // empty == match all +} + +var statusFilterOptions = []statusFilterOption{ + {Label: "All"}, + {Label: "Backlog", Statuses: []string{db.StatusBacklog}}, + {Label: "In Progress", Statuses: []string{db.StatusQueued, db.StatusProcessing}}, + {Label: "Blocked", Statuses: []string{db.StatusBlocked}}, + {Label: "Done", Statuses: []string{db.StatusDone}}, +} + +// dateFilterOption represents a discrete "updated within" filter. +type dateFilterOption struct { + Label string + Within time.Duration // 0 == match all +} + +var dateFilterOptions = []dateFilterOption{ + {Label: "Any time"}, + {Label: "Today", Within: 24 * time.Hour}, + {Label: "7 days", Within: 7 * 24 * time.Hour}, + {Label: "30 days", Within: 30 * 24 * time.Hour}, +} + +// ListView renders tasks as a sortable, filterable table — an alternative to the +// kanban board. It keeps its own selection, sort, and filter state but reads the +// same task data the board does. +type ListView struct { + allTasks []*db.Task // tasks as provided (already passes the app-level "/" filter) + rows []*db.Task // filtered + sorted tasks actually displayed + + sortColIdx int // index into sortColumns + sortDesc bool // current sort direction + statusIdx int // index into statusFilterOptions + projectIdx int // index into the projects() slice (0 == All) + dateIdx int // index into dateFilterOptions + selectedRow int + scrollOff int + + width int + height int + + prInfo map[int64]*github.PRInfo + runningProcesses map[int64]bool + tasksNeedingInput map[int64]bool + blockedByDeps map[int64]int +} + +// NewListView creates a new list view. +func NewListView(width, height int) *ListView { + return &ListView{ + width: width, + height: height, + sortDesc: sortColumns[0].defaultDesc(), + prInfo: make(map[int64]*github.PRInfo), + runningProcesses: make(map[int64]bool), + tasksNeedingInput: make(map[int64]bool), + blockedByDeps: make(map[int64]int), + } +} + +// SetSize updates the list dimensions. +func (l *ListView) SetSize(width, height int) { + if l == nil { + return + } + l.width = width + l.height = height + l.ensureSelectedVisible() +} + +// SetTasks updates the tasks shown in the list, preserving the selected task by ID. +func (l *ListView) SetTasks(tasks []*db.Task) { + if l == nil { + return + } + var selectedID int64 + if t := l.SelectedTask(); t != nil { + selectedID = t.ID + } + l.allTasks = tasks + l.rebuild() + if selectedID != 0 { + l.SelectTask(selectedID) + } +} + +// SetPRInfo updates the PR info for a task. +func (l *ListView) SetPRInfo(taskID int64, info *github.PRInfo) { + if l == nil { + return + } + if l.prInfo == nil { + l.prInfo = make(map[int64]*github.PRInfo) + } + l.prInfo[taskID] = info +} + +// SetRunningProcesses updates the map of tasks with running shell processes. +func (l *ListView) SetRunningProcesses(running map[int64]bool) { + if l == nil { + return + } + l.runningProcesses = running +} + +// SetTasksNeedingInput updates the map of tasks waiting for user input. +func (l *ListView) SetTasksNeedingInput(needsInput map[int64]bool) { + if l == nil { + return + } + l.tasksNeedingInput = needsInput +} + +// SetBlockedByDeps updates the map of tasks blocked by dependencies. +func (l *ListView) SetBlockedByDeps(blockedByDeps map[int64]int) { + if l == nil { + return + } + l.blockedByDeps = blockedByDeps +} + +// projects returns the list of project filter labels: "All" followed by the +// unique project names present in the current task set (sorted). +func (l *ListView) projects() []string { + seen := make(map[string]bool) + var names []string + for _, t := range l.allTasks { + if t.Project != "" && !seen[t.Project] { + seen[t.Project] = true + names = append(names, t.Project) + } + } + sort.Strings(names) + return append([]string{"All"}, names...) +} + +// rebuild applies the active filters and sort to produce the visible rows. +func (l *ListView) rebuild() { + projectNames := l.projects() + if l.projectIdx >= len(projectNames) { + l.projectIdx = 0 + } + statusOpt := statusFilterOptions[l.statusIdx] + dateOpt := dateFilterOptions[l.dateIdx] + now := time.Now() + + var rows []*db.Task + for _, t := range l.allTasks { + if !statusMatches(t, statusOpt) { + continue + } + if l.projectIdx > 0 && t.Project != projectNames[l.projectIdx] { + continue + } + if dateOpt.Within > 0 { + if t.UpdatedAt.IsZero() || now.Sub(t.UpdatedAt.Time) > dateOpt.Within { + continue + } + } + rows = append(rows, t) + } + + l.sortRows(rows) + l.rows = rows + l.clampSelection() + l.ensureSelectedVisible() +} + +// statusMatches reports whether a task matches a status filter option. +func statusMatches(t *db.Task, opt statusFilterOption) bool { + if len(opt.Statuses) == 0 { + return true + } + for _, s := range opt.Statuses { + if t.Status == s { + return true + } + } + return false +} + +// sortRows sorts the given rows by the active column and direction. Pinned tasks +// always float to the top regardless of sort, mirroring the board's behaviour. +func (l *ListView) sortRows(rows []*db.Task) { + col := sortColumns[l.sortColIdx] + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + if a.Pinned != b.Pinned { + return a.Pinned // pinned first + } + less := lessByColumn(a, b, col) + if l.sortDesc { + return !less + } + return less + }) +} + +// lessByColumn reports whether task a sorts before task b for the given column +// in ascending order. +func lessByColumn(a, b *db.Task, col SortColumn) bool { + switch col { + case SortByID: + return a.ID < b.ID + case SortByStatus: + ra, rb := statusRank(a.Status), statusRank(b.Status) + if ra != rb { + return ra < rb + } + return a.ID < b.ID + case SortByTitle: + ta, tb := strings.ToLower(a.Title), strings.ToLower(b.Title) + if ta != tb { + return ta < tb + } + return a.ID < b.ID + case SortByProject: + pa, pb := strings.ToLower(a.Project), strings.ToLower(b.Project) + if pa != pb { + return pa < pb + } + return a.ID < b.ID + case SortByCreated: + if !a.CreatedAt.Time.Equal(b.CreatedAt.Time) { + return a.CreatedAt.Time.Before(b.CreatedAt.Time) + } + return a.ID < b.ID + case SortByUpdated: + fallthrough + default: + if !a.UpdatedAt.Time.Equal(b.UpdatedAt.Time) { + return a.UpdatedAt.Time.Before(b.UpdatedAt.Time) + } + return a.ID < b.ID + } +} + +// --- Navigation ----------------------------------------------------------- + +// MoveUp moves the selection up one row, wrapping to the bottom. +func (l *ListView) MoveUp() { + if len(l.rows) == 0 { + return + } + if l.selectedRow > 0 { + l.selectedRow-- + } else { + l.selectedRow = len(l.rows) - 1 + } + l.ensureSelectedVisible() +} + +// MoveDown moves the selection down one row, wrapping to the top. +func (l *ListView) MoveDown() { + if len(l.rows) == 0 { + return + } + if l.selectedRow < len(l.rows)-1 { + l.selectedRow++ + } else { + l.selectedRow = 0 + } + l.ensureSelectedVisible() +} + +// NextSortColumn advances to the next sortable column (←/→), resetting the +// direction to that column's natural default. +func (l *ListView) NextSortColumn() { + l.sortColIdx = (l.sortColIdx + 1) % len(sortColumns) + l.sortDesc = sortColumns[l.sortColIdx].defaultDesc() + l.rebuild() +} + +// PrevSortColumn moves to the previous sortable column. +func (l *ListView) PrevSortColumn() { + l.sortColIdx = (l.sortColIdx - 1 + len(sortColumns)) % len(sortColumns) + l.sortDesc = sortColumns[l.sortColIdx].defaultDesc() + l.rebuild() +} + +// ToggleSortDirection flips between ascending and descending order. +func (l *ListView) ToggleSortDirection() { + l.sortDesc = !l.sortDesc + l.rebuild() +} + +// CycleStatusFilter advances the status filter by dir (+1 / -1). +func (l *ListView) CycleStatusFilter(dir int) { + n := len(statusFilterOptions) + l.statusIdx = (l.statusIdx + dir + n) % n + l.rebuild() +} + +// CycleProjectFilter advances the project filter by dir (+1 / -1). +func (l *ListView) CycleProjectFilter(dir int) { + n := len(l.projects()) + if n == 0 { + return + } + l.projectIdx = (l.projectIdx + dir + n) % n + l.rebuild() +} + +// CycleDateFilter advances the "updated within" filter by dir (+1 / -1). +func (l *ListView) CycleDateFilter(dir int) { + n := len(dateFilterOptions) + l.dateIdx = (l.dateIdx + dir + n) % n + l.rebuild() +} + +// SelectedTask returns the currently selected task, or nil. +func (l *ListView) SelectedTask() *db.Task { + if l.selectedRow < 0 || l.selectedRow >= len(l.rows) { + return nil + } + return l.rows[l.selectedRow] +} + +// SelectTask selects the row for the given task ID, returning true if found. +func (l *ListView) SelectTask(id int64) bool { + for i, t := range l.rows { + if t.ID == id { + l.selectedRow = i + l.ensureSelectedVisible() + return true + } + } + return false +} + +// IsEmpty reports whether there are no visible rows. +func (l *ListView) IsEmpty() bool { + return len(l.rows) == 0 +} + +// SelectVisibleRow selects the nth (1-indexed) currently visible row and returns +// the task, or nil if there is no such row. +func (l *ListView) SelectVisibleRow(n int) *db.Task { + if n < 1 { + return nil + } + idx := l.scrollOff + n - 1 + if idx < 0 || idx >= len(l.rows) { + return nil + } + l.selectedRow = idx + l.ensureSelectedVisible() + return l.rows[idx] +} + +// HasPrevTask reports whether a row exists above the current selection. +func (l *ListView) HasPrevTask() bool { + return len(l.rows) > 1 && l.selectedRow > 0 +} + +// HasNextTask reports whether a row exists below the current selection. +func (l *ListView) HasNextTask() bool { + return len(l.rows) > 1 && l.selectedRow < len(l.rows)-1 +} + +func (l *ListView) clampSelection() { + if l.selectedRow >= len(l.rows) { + l.selectedRow = len(l.rows) - 1 + } + if l.selectedRow < 0 { + l.selectedRow = 0 + } +} + +// List layout constants. Each visible row gets a blank spacer line for breathing +// room (matching the board's card rhythm), so a row occupies listRowHeight lines. +const ( + listRowHeight = 2 // one content line + one blank spacer + listChromeLines = 6 // chips(2) + blank(1) + header content+rule(2) + blank(1) + listFooterLines = 1 // scroll indicator + listHPadding = 2 // left/right padding inside the list +) + +// visibleRowCount returns how many task rows fit in the current height. +func (l *ListView) visibleRowCount() int { + avail := l.height - listChromeLines - listFooterLines + if avail < listRowHeight { + return 1 + } + return avail / listRowHeight +} + +func (l *ListView) ensureSelectedVisible() { + capacity := l.visibleRowCount() + if l.selectedRow < l.scrollOff { + l.scrollOff = l.selectedRow + } + if l.selectedRow >= l.scrollOff+capacity { + l.scrollOff = l.selectedRow - capacity + 1 + } + maxOff := len(l.rows) - capacity + if maxOff < 0 { + maxOff = 0 + } + if l.scrollOff > maxOff { + l.scrollOff = maxOff + } + if l.scrollOff < 0 { + l.scrollOff = 0 + } +} + +// --- Rendering ------------------------------------------------------------ + +// listColumns describes the rendered table layout for a given width. +type listColumns struct { + id int + status int + project int + pr int + updated int + created int + title int // remaining flexible space +} + +// computeColumns returns column widths responsive to the available width. +func (l *ListView) computeColumns() listColumns { + w := l.width - 2*listHPadding // horizontal padding on both sides + c := listColumns{id: 5, status: 13, project: 12, pr: 4, updated: 9, created: 9} + + // Progressively drop optional columns on narrow terminals. + fixed := func() int { return c.id + c.status + c.project + c.pr + c.updated + c.created } + gaps := 7 // one space between each of the 7 columns + if w-fixed()-gaps < 16 { + c.created = 0 // drop Created + } + if w-c.id-c.status-c.project-c.pr-c.updated-c.created-6 < 16 { + c.project = 0 // drop Project + } + if w-c.id-c.status-c.pr-c.updated-c.created-5 < 14 { + c.pr = 0 // drop PR + } + // Title takes whatever remains. + used := c.id + c.status + c.project + c.pr + c.updated + c.created + visibleCols := 2 // id + title always present + for _, x := range []int{c.status, c.project, c.pr, c.updated, c.created} { + if x > 0 { + visibleCols++ + } + } + c.title = w - used - (visibleCols - 1) + if c.title < 10 { + c.title = 10 + } + return c +} + +// View renders the list view. +func (l *ListView) View() string { + if l.width < 30 || l.height < 8 { + return lipgloss.Place(l.width, l.height, lipgloss.Center, lipgloss.Center, "Terminal too small") + } + + cols := l.computeColumns() + var lines []string + lines = append(lines, l.renderFilterChips()) + lines = append(lines, "") // breathing room under the chips + lines = append(lines, l.renderColumnHeader(cols)) + lines = append(lines, "") // breathing room under the header rule + + capacity := l.visibleRowCount() + if len(l.rows) == 0 { + empty := lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true). + Width(l.width). + Align(lipgloss.Center). + MarginTop(1). + Render("No tasks match the current filters") + lines = append(lines, empty) + } else { + start := l.scrollOff + end := start + capacity + if end > len(l.rows) { + end = len(l.rows) + } + for i := start; i < end; i++ { + lines = append(lines, l.renderRow(l.rows[i], cols, i == l.selectedRow)) + lines = append(lines, "") // blank spacer between rows + } + // Scroll indicator + hiddenAbove := start + hiddenBelow := len(l.rows) - end + if hiddenAbove > 0 || hiddenBelow > 0 { + indicator := fmt.Sprintf("%s %d–%d of %d", IconArrowDown(), start+1, end, len(l.rows)) + lines = append(lines, lipgloss.NewStyle(). + Foreground(ColorMuted). + Italic(true). + Width(l.width). + Align(lipgloss.Center). + Render(indicator)) + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, lines...) + return lipgloss.NewStyle().Width(l.width).Render(content) +} + +// renderFilterChips renders the sort + filter status bar at the top of the list. +func (l *ListView) renderFilterChips() string { + chip := func(label, value string, active bool) string { + labelStyle := lipgloss.NewStyle().Foreground(ColorMuted) + valStyle := lipgloss.NewStyle().Bold(true) + if active { + valStyle = valStyle.Foreground(ColorPrimary) + } else { + valStyle = valStyle.Foreground(ColorSecondary) + } + return labelStyle.Render(label+": ") + valStyle.Render(value) + } + + arrow := IconArrowUp() + if l.sortDesc { + arrow = IconArrowDown() + } + sortVal := sortColumns[l.sortColIdx].label() + " " + arrow + + projectName := "All" + projects := l.projects() + if l.projectIdx < len(projects) { + projectName = projects[l.projectIdx] + } + + chips := []string{ + chip("Sort", sortVal, true), + chip("Status", statusFilterOptions[l.statusIdx].Label, l.statusIdx != 0), + chip("Project", projectName, l.projectIdx != 0), + chip("Updated", dateFilterOptions[l.dateIdx].Label, l.dateIdx != 0), + lipgloss.NewStyle().Foreground(ColorMuted).Render(fmt.Sprintf("(%d)", len(l.rows))), + } + chipLine := strings.Join(chips, lipgloss.NewStyle().Foreground(ColorMuted).Render(" • ")) + + hint := lipgloss.NewStyle().Foreground(ColorMuted).Italic(true).Render( + "←→ sort ⎵ reverse [ ] project { } status < > date v board") + + barStyle := lipgloss.NewStyle().Width(l.width).Padding(0, listHPadding) + return barStyle.Render(lipgloss.JoinVertical(lipgloss.Left, chipLine, hint)) +} + +// renderColumnHeader renders the underlined column header row, highlighting the +// active sort column. +func (l *ListView) renderColumnHeader(cols listColumns) string { + activeCol := sortColumns[l.sortColIdx] + arrow := IconArrowUp() + if l.sortDesc { + arrow = IconArrowDown() + } + + headerCell := func(text string, width int, sortKey SortColumn, sortable bool, right bool) string { + if width <= 0 { + return "" + } + style := lipgloss.NewStyle().Width(width).Bold(true) + label := text + if sortable && sortKey == activeCol { + style = style.Foreground(ColorPrimary) + label = text + arrow + } else { + style = style.Foreground(ColorMuted) + } + if right { + style = style.Align(lipgloss.Right) + } + return style.Render(truncate(label, width)) + } + + var cells []string + cells = append(cells, headerCell("#", cols.id, SortByID, true, false)) + cells = append(cells, headerCell("Status", cols.status, SortByStatus, true, false)) + cells = append(cells, headerCell("Title", cols.title, SortByTitle, true, false)) + if cols.project > 0 { + cells = append(cells, headerCell("Project", cols.project, SortByProject, true, false)) + } + if cols.pr > 0 { + cells = append(cells, headerCell("PR", cols.pr, 0, false, false)) + } + if cols.updated > 0 { + cells = append(cells, headerCell("Updated", cols.updated, SortByUpdated, true, true)) + } + if cols.created > 0 { + cells = append(cells, headerCell("Created", cols.created, SortByCreated, true, true)) + } + + row := joinCells(cells) + return lipgloss.NewStyle(). + Width(l.width). + Padding(0, listHPadding). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(ColorMuted). + Render(row) +} + +// renderRow renders a single task row. +func (l *ListView) renderRow(task *db.Task, cols listColumns, selected bool) string { + idStr := fmt.Sprintf("#%d", task.ID) + + statusIcon := StatusIcon(task.Status) + statusText := statusIcon + " " + statusLabel(task.Status) + + title := task.Title + if task.Pinned { + title = IconPin() + " " + title + } + if l.blockedByDeps[task.ID] > 0 { + title = "🔒 " + title + } + + projectStr := task.Project + + prStr := "" + if pr := l.prInfo[task.ID]; pr != nil { + prStr = PRStatusBadge(pr) + } + + // Activity indicator overrides the updated column for live tasks. + updatedStr := relativeTime(task.UpdatedAt.Time) + if l.runningProcesses[task.ID] { + updatedStr = "● " + updatedStr + } else if l.tasksNeedingInput[task.ID] { + updatedStr = "! " + updatedStr + } + createdStr := relativeTime(task.CreatedAt.Time) + + cell := func(text string, width int, right bool) string { + if width <= 0 { + return "" + } + style := lipgloss.NewStyle().Width(width) + if right { + style = style.Align(lipgloss.Right) + } + return style.Render(truncate(text, width)) + } + + // Column-specific colouring (skipped for selected rows, which invert). + idCell := cell(idStr, cols.id, false) + statusCell := cell(statusText, cols.status, false) + titleCell := cell(title, cols.title, false) + projectCell := cell(projectStr, cols.project, false) + prCell := cell(prStr, cols.pr, false) + updatedCell := cell(updatedStr, cols.updated, true) + createdCell := cell(createdStr, cols.created, true) + + if !selected { + statusCell = lipgloss.NewStyle().Width(cols.status).Foreground(StatusColor(task.Status)).Render(truncate(statusText, cols.status)) + idCell = lipgloss.NewStyle().Width(cols.id).Foreground(ColorMuted).Render(truncate(idStr, cols.id)) + if cols.project > 0 && projectStr != "" { + projectCell = lipgloss.NewStyle().Width(cols.project).Foreground(ProjectColor(task.Project)).Render(truncate(projectStr, cols.project)) + } + if cols.updated > 0 { + updatedCell = lipgloss.NewStyle().Width(cols.updated).Align(lipgloss.Right).Foreground(ColorMuted).Render(truncate(updatedStr, cols.updated)) + } + if cols.created > 0 { + createdCell = lipgloss.NewStyle().Width(cols.created).Align(lipgloss.Right).Foreground(ColorMuted).Render(truncate(createdStr, cols.created)) + } + } + + var cells []string + cells = append(cells, idCell, statusCell, titleCell) + if cols.project > 0 { + cells = append(cells, projectCell) + } + if cols.pr > 0 { + cells = append(cells, prCell) + } + if cols.updated > 0 { + cells = append(cells, updatedCell) + } + if cols.created > 0 { + cells = append(cells, createdCell) + } + + row := joinCells(cells) + + rowStyle := lipgloss.NewStyle().Width(l.width).Padding(0, listHPadding) + if selected { + cardBg, cardFg := GetThemeCardColors() + rowStyle = rowStyle.Background(cardBg).Foreground(cardFg).Bold(true) + } else if l.tasksNeedingInput[task.ID] { + rowStyle = rowStyle.Foreground(ColorWarning) + } + return rowStyle.Render(row) +} + +// HandleClick maps a click at (x, y) to a task row, updating the selection. +// Returns the clicked task, or nil if the click was not on a row. +func (l *ListView) HandleClick(x, y int) *db.Task { + // The chrome above the rows occupies listChromeLines; each visible row then + // occupies listRowHeight lines (content + spacer). + rowY := y - listChromeLines + if rowY < 0 { + return nil + } + displayed := rowY / listRowHeight + idx := l.scrollOff + displayed + if idx < 0 || idx >= len(l.rows) { + return nil + } + l.selectedRow = idx + l.ensureSelectedVisible() + return l.rows[idx] +} + +// debugState returns a snapshot of the list view's sort/filter state and visible +// rows for the debug "Text DOM" used by the QA harness. +func (l *ListView) debugState() *DebugList { + projectName := "All" + projects := l.projects() + if l.projectIdx < len(projects) { + projectName = projects[l.projectIdx] + } + d := &DebugList{ + Sort: sortColumns[l.sortColIdx].label(), + SortDesc: l.sortDesc, + StatusFilter: statusFilterOptions[l.statusIdx].Label, + ProjectFilter: projectName, + DateFilter: dateFilterOptions[l.dateIdx].Label, + } + for i, t := range l.rows { + d.Rows = append(d.Rows, DebugTask{ + ID: t.ID, + Title: t.Title, + Status: t.Status, + Project: t.Project, + Selected: i == l.selectedRow, + Pinned: t.Pinned, + }) + } + return d +} + +// --- helpers -------------------------------------------------------------- + +// joinCells joins rendered cells with a single space separator, skipping empties. +func joinCells(cells []string) string { + var nonEmpty []string + for _, c := range cells { + if c != "" { + nonEmpty = append(nonEmpty, c) + } + } + return strings.Join(nonEmpty, " ") +} + +// truncate shortens s to fit width display columns, adding an ellipsis. +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + if width == 1 { + return "…" + } + // Trim rune by rune until it fits (accounts for wide runes). + runes := []rune(s) + for len(runes) > 0 && lipgloss.Width(string(runes))+1 > width { + runes = runes[:len(runes)-1] + } + return string(runes) + "…" +} + +// statusLabel returns a human-readable label for a task status. +func statusLabel(status string) string { + switch status { + case db.StatusBacklog: + return "Backlog" + case db.StatusQueued: + return "Queued" + case db.StatusProcessing: + return "Running" + case db.StatusBlocked: + return "Blocked" + case db.StatusDone: + return "Done" + case db.StatusArchived: + return "Archived" + default: + return status + } +} + +// relativeTime formats a timestamp as a short relative string (e.g. "3h", "2d"). +func relativeTime(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + switch { + case d < 0: + return t.Format("Jan 2") + case d < time.Minute: + return "now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + default: + return t.Format("Jan 2") + } +} diff --git a/internal/ui/list_view_test.go b/internal/ui/list_view_test.go new file mode 100644 index 00000000..2d7da4f1 --- /dev/null +++ b/internal/ui/list_view_test.go @@ -0,0 +1,177 @@ +package ui + +import ( + "testing" + "time" + + "github.com/bborn/workflow/internal/db" +) + +func lt(t time.Time) db.LocalTime { return db.LocalTime{Time: t} } + +func makeListTasks() []*db.Task { + now := time.Now() + return []*db.Task{ + {ID: 1, Title: "Alpha", Status: db.StatusBacklog, Project: "proj-a", CreatedAt: lt(now.Add(-3 * time.Hour)), UpdatedAt: lt(now.Add(-3 * time.Hour))}, + {ID: 2, Title: "Bravo", Status: db.StatusDone, Project: "proj-b", CreatedAt: lt(now.Add(-2 * time.Hour)), UpdatedAt: lt(now.Add(-30 * time.Minute))}, + {ID: 3, Title: "Charlie", Status: db.StatusBlocked, Project: "proj-a", CreatedAt: lt(now.Add(-1 * time.Hour)), UpdatedAt: lt(now.Add(-1 * time.Minute))}, + {ID: 4, Title: "Delta", Status: db.StatusProcessing, Project: "proj-b", CreatedAt: lt(now.Add(-10 * time.Hour)), UpdatedAt: lt(now.Add(-5 * time.Hour))}, + } +} + +func rowIDs(l *ListView) []int64 { + ids := make([]int64, len(l.rows)) + for i, t := range l.rows { + ids[i] = t.ID + } + return ids +} + +func TestListViewDefaultSortUpdatedDesc(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(makeListTasks()) + + // Default sort is Updated descending: most recently updated first. + got := rowIDs(l) + want := []int64{3, 2, 1, 4} + for i := range want { + if got[i] != want[i] { + t.Fatalf("default sort = %v, want %v", got, want) + } + } +} + +func TestListViewSortByIDAndReverse(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(makeListTasks()) + + // Cycle to the ID column. Order in sortColumns: Updated, Created, ID, ... + l.NextSortColumn() // Created + l.NextSortColumn() // ID + if sortColumns[l.sortColIdx] != SortByID { + t.Fatalf("expected sort column ID, got %v", sortColumns[l.sortColIdx]) + } + // ID defaults to descending. + if got := rowIDs(l); got[0] != 4 || got[3] != 1 { + t.Fatalf("ID desc sort = %v, want [4 3 2 1]", got) + } + l.ToggleSortDirection() + if got := rowIDs(l); got[0] != 1 || got[3] != 4 { + t.Fatalf("ID asc sort = %v, want [1 2 3 4]", got) + } +} + +func TestListViewSortByTitle(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(makeListTasks()) + // Move to Title column. + for sortColumns[l.sortColIdx] != SortByTitle { + l.NextSortColumn() + } + got := rowIDs(l) + want := []int64{1, 2, 3, 4} // Alpha, Bravo, Charlie, Delta + for i := range want { + if got[i] != want[i] { + t.Fatalf("title sort = %v, want %v", got, want) + } + } +} + +func TestListViewStatusFilter(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(makeListTasks()) + + // Cycle status filter forward to "Backlog". + l.CycleStatusFilter(1) + if statusFilterOptions[l.statusIdx].Label != "Backlog" { + t.Fatalf("expected Backlog filter, got %q", statusFilterOptions[l.statusIdx].Label) + } + if got := rowIDs(l); len(got) != 1 || got[0] != 1 { + t.Fatalf("backlog filter rows = %v, want [1]", got) + } + + // "In Progress" should match both queued and processing. + l.CycleStatusFilter(1) // In Progress + if statusFilterOptions[l.statusIdx].Label != "In Progress" { + t.Fatalf("expected In Progress filter, got %q", statusFilterOptions[l.statusIdx].Label) + } + if got := rowIDs(l); len(got) != 1 || got[0] != 4 { + t.Fatalf("in-progress filter rows = %v, want [4]", got) + } +} + +func TestListViewProjectFilter(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(makeListTasks()) + + // projects(): All, proj-a, proj-b (sorted). Cycle to proj-a. + l.CycleProjectFilter(1) + if got := rowIDs(l); len(got) != 2 { + t.Fatalf("proj-a filter rows = %v, want 2 rows", got) + } + for _, id := range rowIDs(l) { + if id != 1 && id != 3 { + t.Fatalf("proj-a filter unexpectedly included task %d", id) + } + } +} + +func TestListViewSelectionPreservedAcrossSetTasks(t *testing.T) { + l := NewListView(120, 30) + tasks := makeListTasks() + l.SetTasks(tasks) + if !l.SelectTask(4) { + t.Fatal("expected to select task 4") + } + // Re-set tasks (e.g. a refresh) and ensure selection follows the task. + l.SetTasks(tasks) + if sel := l.SelectedTask(); sel == nil || sel.ID != 4 { + t.Fatalf("selection not preserved, got %v", sel) + } +} + +func TestListViewPinnedFloatToTop(t *testing.T) { + l := NewListView(120, 30) + tasks := makeListTasks() + tasks[3].Pinned = true // Delta (ID 4) pinned + l.SetTasks(tasks) + // Even under Updated-desc sort (where 4 would be last), pinned floats up. + if got := rowIDs(l); got[0] != 4 { + t.Fatalf("pinned task not floated to top: %v", got) + } +} + +func TestListViewNavigationWraps(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(makeListTasks()) + l.selectedRow = 0 + l.MoveUp() // wrap to bottom + if l.selectedRow != len(l.rows)-1 { + t.Fatalf("MoveUp from top should wrap, got row %d", l.selectedRow) + } + l.MoveDown() // wrap to top + if l.selectedRow != 0 { + t.Fatalf("MoveDown from bottom should wrap, got row %d", l.selectedRow) + } +} + +func TestListViewRendersWithoutPanic(t *testing.T) { + for _, w := range []int{40, 80, 120, 200} { + l := NewListView(w, 24) + l.SetTasks(makeListTasks()) + if out := l.View(); out == "" { + t.Fatalf("View() returned empty at width %d", w) + } + } +} + +func TestListViewEmptyState(t *testing.T) { + l := NewListView(120, 30) + l.SetTasks(nil) + if !l.IsEmpty() { + t.Fatal("expected empty list view") + } + if out := l.View(); out == "" { + t.Fatal("empty View() should still render filter chips") + } +}