diff --git a/internal/ui/app.go b/internal/ui/app.go index a2d2d6c0..b5419032 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -90,6 +90,9 @@ type KeyMap struct { // Jump to pinned/unpinned tasks JumpToPinned key.Binding JumpToUnpinned key.Binding + // Detail-view pinned quick-nav (hop between focused/pinned tasks) + PrevPinnedTask key.Binding + NextPinnedTask key.Binding // Column collapse CollapseBacklog key.Binding CollapseDone key.Binding @@ -253,6 +256,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("shift+down"), key.WithHelp(IconShiftDown(), "jump to unpinned"), ), + PrevPinnedTask: key.NewBinding( + key.WithKeys("["), + key.WithHelp("[", "prev pinned"), + ), + NextPinnedTask: key.NewBinding( + key.WithKeys("]"), + key.WithHelp("]", "next pinned"), + ), CollapseBacklog: key.NewBinding( key.WithKeys("["), key.WithHelp("[", "collapse backlog"), @@ -964,6 +975,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reapply filter if one is active m.applyFilter() + // Keep the detail view's pinned quick-nav in sync with the new task set + // (e.g. after a pin/unpin, which reloads tasks). + m.refreshDetailPinnedNav() m.kanban.SetHiddenDoneCount(msg.hiddenDoneCount) // Refresh running process indicators for all tasks running := executor.GetTasksWithRunningShellProcess() @@ -1044,6 +1058,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Set task position in column for display pos, total := m.kanban.GetTaskPosition() m.detailView.SetPosition(pos, total) + // Populate the pinned quick-nav bar (respects the active board filter) + m.refreshDetailPinnedNav() m.previousView = m.currentView m.currentView = ViewDetail // Start async pane setup if needed @@ -2223,6 +2239,70 @@ func (m *AppModel) resolveProjectAliases(query string) string { return result.String() } +// filteredPinnedTasks returns the pinned tasks that pass the board's currently +// active filter, ordered by status column then ID for stability. This is the set +// the detail view's quick-nav bar hops between — so a filtered board ("[projectX]") +// only surfaces its own pinned tasks, not every pin across the board. +func (m *AppModel) filteredPinnedTasks() []*db.Task { + queryLower := strings.ToLower(m.filterText) + if strings.Contains(queryLower, "[") { + queryLower = m.resolveProjectAliases(queryLower) + } + + var pinned []*db.Task + for _, task := range m.tasks { + if !task.Pinned { + continue + } + if queryLower != "" && scoreTaskForFilter(task, queryLower) < 0 { + continue + } + pinned = append(pinned, task) + } + + sort.SliceStable(pinned, func(i, j int) bool { + ri, rj := statusColumnRank(pinned[i].Status), statusColumnRank(pinned[j].Status) + if ri != rj { + return ri < rj + } + return pinned[i].ID < pinned[j].ID + }) + return pinned +} + +// statusColumnRank orders task statuses left-to-right the way the board columns +// read, so the pinned nav bar lists tasks in a familiar order. +func statusColumnRank(status string) int { + switch status { + case db.StatusBacklog: + return 0 + case db.StatusQueued: + return 1 + case db.StatusProcessing: + return 2 + case db.StatusBlocked: + return 3 + case db.StatusDone: + return 4 + default: + return 5 + } +} + +// refreshDetailPinnedNav rebuilds the detail view's pinned quick-nav bar from the +// current task set and filter. Safe to call when not in the detail view. +func (m *AppModel) refreshDetailPinnedNav() { + if m.detailView == nil || m.selectedTask == nil { + return + } + pinned := m.filteredPinnedTasks() + items := make([]PinnedNavItem, len(pinned)) + for i, t := range pinned { + items[i] = PinnedNavItem{ID: t.ID, Title: t.Title, Status: t.Status, Project: t.Project} + } + m.detailView.SetPinnedNav(items, m.selectedTask.ID) +} + // applyFilter filters the tasks based on current filter text using fuzzy matching. // Uses the same matching logic as the command palette (Ctrl+P) for consistency. func (m *AppModel) applyFilter() { @@ -2570,6 +2650,22 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Pinned quick-nav: hop between the pinned tasks shown in the detail view's + // top bar. [ / ] cycle prev/next; 1-9 jump to that pill directly. + if m.detailView != nil && m.detailView.HasPinnedNav() { + if key.Matches(keyMsg, m.keys.NextPinnedTask) { + return m.jumpToPinnedTask(m.detailView.PinnedNavNextID()) + } + if key.Matches(keyMsg, m.keys.PrevPinnedTask) { + return m.jumpToPinnedTask(m.detailView.PinnedNavPrevID()) + } + if s := keyMsg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' { + if id := m.detailView.PinnedNavIDAt(int(s[0] - '0')); id != 0 { + return m.jumpToPinnedTask(id) + } + } + } + if m.detailView != nil { var cmd tea.Cmd m.detailView, cmd = m.detailView.Update(msg) @@ -2579,6 +2675,29 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// jumpToPinnedTask switches the detail view to the given task, reusing the same +// cleanup/transition guard as prev/next navigation so we never fight over the +// executor pane. It selects the task in the kanban first so position display and +// subsequent arrow navigation stay consistent. +func (m *AppModel) jumpToPinnedTask(id int64) (tea.Model, tea.Cmd) { + if id == 0 { + return m, nil + } + if m.selectedTask != nil && m.selectedTask.ID == id { + return m, nil // already here + } + if m.taskTransitionInProgress { + return m, nil + } + m.taskTransitionInProgress = true + if m.detailView != nil { + m.detailView.CleanupWithoutSaving() + m.detailView = nil + } + m.kanban.SelectTask(id) + return m, m.loadTask(id) +} + func (m *AppModel) updateNewTaskForm(msg tea.Msg) (tea.Model, tea.Cmd) { // Pass all messages to the form (form handles ESC with confirmation prompt) model, cmd := m.newTaskForm.Update(msg) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 52a7f491..56ea11ae 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -115,6 +115,15 @@ type DetailModel struct { positionInColumn int totalInColumn int + // Pinned quick-nav: a compact pill bar at the top of the detail view for + // hopping between the tasks you're currently focused on (your pinned tasks, + // honouring the board's active filter). pinnedNavIndex is the position of the + // currently-viewed task within pinnedNav, or -1 when the current task isn't + // pinned. Populated by AppModel via SetPinnedNav whenever a task is opened or + // the task set changes. + pinnedNav []PinnedNavItem + pinnedNavIndex int + // Track joined tmux panes claudePaneID string // The Claude Code pane (middle-left) workdirPaneID string // The workdir shell pane (middle-right) @@ -776,16 +785,22 @@ func (m *DetailModel) spinnerTick() tea.Cmd { }) } +// headerHeight is the vertical space reserved for the box chrome around the +// viewport. The pinned quick-nav bar (rendered at the bottom of the box) adds +// one line and must be accounted for here or the viewport would overflow. +func (m *DetailModel) headerHeight() int { + h := 6 + if m.showPinnedNav() { + h++ + } + return h +} + func (m *DetailModel) initViewport() { - headerHeight := 6 footerHeight := 2 // If we have joined panes, we have less height (tmux split takes space) - vpHeight := m.height - headerHeight - footerHeight - if m.claudePaneID != "" || m.workdirPaneID != "" { - // The tmux split takes roughly half, but we don't control that here - // Just use full height - the TUI pane will be resized by tmux - } + vpHeight := m.height - m.headerHeight() - footerHeight m.viewport = viewport.New(m.width-4, vpHeight) m.setViewportContent() @@ -796,13 +811,20 @@ func (m *DetailModel) initViewport() { func (m *DetailModel) SetSize(width, height int) { m.width = width m.height = height - if m.ready { - headerHeight := 6 - footerHeight := 2 - m.viewport.Width = width - 4 - m.viewport.Height = height - headerHeight - footerHeight - m.setViewportContent() + m.reflowViewport() +} + +// reflowViewport recomputes the viewport dimensions from the current width, +// height, and header layout. Called on resize and whenever something that +// changes the header height (like the pinned nav bar appearing) is updated. +func (m *DetailModel) reflowViewport() { + if !m.ready { + return } + footerHeight := 2 + m.viewport.Width = m.width - 4 + m.viewport.Height = m.height - m.headerHeight() - footerHeight + m.setViewportContent() } // Update handles messages. @@ -2438,8 +2460,9 @@ func (m *DetailModel) View() string { header := m.renderHeader() help := m.renderHelp() + nav := m.renderPinnedNav() - sig := m.viewSignature(header, help) + sig := m.viewSignature(header, help, nav) if m.cachedViewOK && m.cachedViewSig == sig { return m.cachedView } @@ -2492,6 +2515,13 @@ func (m *DetailModel) View() string { boxContent = lipgloss.JoinVertical(lipgloss.Left, header, content, scrollIndicator) } + // Pinned quick-nav bar sits at the bottom of the box, just above the help + // footer, so the top stays clean. headerHeight() already reserves a line for + // it, so the viewport shrinks to make room. + if nav != "" { + boxContent = lipgloss.JoinVertical(lipgloss.Left, boxContent, nav) + } + renderedBox := box.Render(boxContent) // When shell pane is hidden, show a collapsed indicator on the right @@ -2536,7 +2566,7 @@ func (m *DetailModel) View() string { // colours) without enumerating each one. The remaining inputs are the View-level // state the header/help don't cover: the dangerous-mode banner, the bordered box, // and the viewport's content/scroll geometry. -func (m *DetailModel) viewSignature(header, help string) uint64 { +func (m *DetailModel) viewSignature(header, help, nav string) uint64 { h := newSigHasher() h.u64(StyleGeneration()) // theme / project colour changes h.int(m.width) @@ -2555,6 +2585,7 @@ func (m *DetailModel) viewSignature(header, help string) uint64 { h.int(m.viewport.VisibleLineCount()) h.str(header) h.str(help) + h.str(nav) // pinned quick-nav bar (rendered at the box bottom) return h.h } @@ -3115,6 +3146,11 @@ func (m *DetailModel) renderHelp() string { {IconArrowUp() + "/" + IconArrowDown(), "prev/next task", !hasNavigation}, } + // Pinned quick-nav hint (only when the bar is shown) + if m.showPinnedNav() { + keys = append(keys, helpKey{"[/]", "pinned", false}) + } + // Show scroll hint when content is scrollable if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() { keys = append(keys, helpKey{"j/k/wheel", "scroll", false}) diff --git a/internal/ui/detail_pinned_nav.go b/internal/ui/detail_pinned_nav.go new file mode 100644 index 00000000..483108ed --- /dev/null +++ b/internal/ui/detail_pinned_nav.go @@ -0,0 +1,179 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PinnedNavItem is a single entry in the detail view's pinned quick-nav bar. +// It carries only what the bar needs to render and to load the task on jump. +type PinnedNavItem struct { + ID int64 + Title string + Status string + Project string +} + +// SetPinnedNav updates the pinned quick-nav bar for this detail view. items is +// the ordered set of pinned tasks (already filtered to match the board's active +// filter); currentID is the task currently being viewed, used to highlight its +// pill and anchor "next/prev" navigation. Toggling the bar's visibility changes +// the header height, so the viewport is reflowed to match. +func (m *DetailModel) SetPinnedNav(items []PinnedNavItem, currentID int64) { + m.pinnedNav = items + m.pinnedNavIndex = -1 + for i, it := range items { + if it.ID == currentID { + m.pinnedNavIndex = i + break + } + } + m.reflowViewport() + // The header changed; drop the render cache so the bar paints immediately. + m.cachedViewOK = false +} + +// HasPinnedNav reports whether the pinned quick-nav bar is currently shown. +func (m *DetailModel) HasPinnedNav() bool { + return m.showPinnedNav() +} + +// showPinnedNav decides whether the bar is worth showing. We hide it when there +// is nowhere to hop to: no pinned tasks, or the only pinned task is the one +// already open. +func (m *DetailModel) showPinnedNav() bool { + n := len(m.pinnedNav) + if n == 0 { + return false + } + if n == 1 && m.pinnedNavIndex == 0 { + return false + } + return true +} + +// PinnedNavNextID returns the ID of the pinned task after the current one +// (wrapping around), or 0 if navigation isn't possible. +func (m *DetailModel) PinnedNavNextID() int64 { + n := len(m.pinnedNav) + if n == 0 { + return 0 + } + idx := m.pinnedNavIndex + if idx < 0 { + return m.pinnedNav[0].ID + } + return m.pinnedNav[(idx+1)%n].ID +} + +// PinnedNavPrevID returns the ID of the pinned task before the current one +// (wrapping around), or 0 if navigation isn't possible. +func (m *DetailModel) PinnedNavPrevID() int64 { + n := len(m.pinnedNav) + if n == 0 { + return 0 + } + idx := m.pinnedNavIndex + if idx < 0 { + return m.pinnedNav[n-1].ID + } + return m.pinnedNav[(idx-1+n)%n].ID +} + +// PinnedNavIDAt returns the ID of the nth pinned task (1-indexed), or 0 if there +// is no such task. Backs the 1-9 quick-jump keys. +func (m *DetailModel) PinnedNavIDAt(n int) int64 { + if n < 1 || n > len(m.pinnedNav) { + return 0 + } + return m.pinnedNav[n-1].ID +} + +// renderPinnedNav builds the single-line pill bar. It windows the pills around +// the current one so the active task is always visible even with many pins, and +// never exceeds the available width (which would wrap and break header sizing). +func (m *DetailModel) renderPinnedNav() string { + if !m.showPinnedNav() { + return "" + } + + label := lipgloss.NewStyle().Foreground(ColorMuted).Render(IconPin() + " ") + + currentStyle := lipgloss.NewStyle(). + Background(ColorPrimary). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + restStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#374151")). + Foreground(lipgloss.Color("#D1D5DB")) + dimStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#374151")). + Foreground(lipgloss.Color("#6B7280")) + moreStyle := lipgloss.NewStyle().Foreground(ColorMuted) + + n := len(m.pinnedNav) + pills := make([]string, n) + widths := make([]int, n) + for i, it := range m.pinnedNav { + text := fmt.Sprintf(" #%d %s ", it.ID, truncateRunes(it.Title, 18)) + if i < 9 { + // Prefix with the 1-9 quick-jump number. + text = fmt.Sprintf(" %d·#%d %s ", i+1, it.ID, truncateRunes(it.Title, 18)) + } + var style lipgloss.Style + switch { + case i == m.pinnedNavIndex: + style = currentStyle + case m.focused: + style = restStyle + default: + style = dimStyle + } + pills[i] = style.Render(text) + widths[i] = lipgloss.Width(pills[i]) + } + + // Budget for pills after the label, leaving room for edge "…" markers. + avail := m.width - lipgloss.Width(label) - 6 + if avail < 10 { + avail = 10 + } + + // Window outward from the current pill (or the first pill when the current + // task isn't pinned) until adding another would overflow. + anchor := m.pinnedNavIndex + if anchor < 0 { + anchor = 0 + } + start, end := anchor, anchor+1 + used := widths[anchor] + for { + grew := false + if end < n && used+1+widths[end] <= avail { + used += 1 + widths[end] + end++ + grew = true + } + if start > 0 && used+1+widths[start-1] <= avail { + used += 1 + widths[start-1] + start-- + grew = true + } + if !grew { + break + } + } + + var b strings.Builder + b.WriteString(label) + if start > 0 { + b.WriteString(moreStyle.Render(fmt.Sprintf("‹%d ", start))) + } + b.WriteString(strings.Join(pills[start:end], " ")) + if end < n { + b.WriteString(moreStyle.Render(fmt.Sprintf(" %d›", n-end))) + } + return b.String() +} diff --git a/internal/ui/detail_pinned_nav_test.go b/internal/ui/detail_pinned_nav_test.go new file mode 100644 index 00000000..af811f49 --- /dev/null +++ b/internal/ui/detail_pinned_nav_test.go @@ -0,0 +1,131 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +// newNavDetailModel builds a minimal, tmux-free DetailModel suitable for +// exercising the pinned quick-nav logic in isolation. +func newNavDetailModel(t *testing.T) *DetailModel { + t.Helper() + t.Setenv("TMUX", "") // no tmux: NewDetailModel returns without pane setup + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + task := &db.Task{ID: 1, Title: "Current", Status: db.StatusProcessing, Body: "body"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("create task: %v", err) + } + m, _ := NewDetailModel(task, database, nil, 160, 50, false) + return m +} + +func TestPinnedNavVisibility(t *testing.T) { + m := newNavDetailModel(t) + + // No pins: bar hidden. + if m.HasPinnedNav() { + t.Error("expected no pinned nav with zero items") + } + + // A single pin that is the current task: nothing to hop to, so hidden. + m.SetPinnedNav([]PinnedNavItem{{ID: 1, Title: "Current"}}, 1) + if m.HasPinnedNav() { + t.Error("expected no pinned nav when the only pin is the current task") + } + + // A single pin that is a *different* task: worth showing. + m.SetPinnedNav([]PinnedNavItem{{ID: 2, Title: "Other"}}, 1) + if !m.HasPinnedNav() { + t.Error("expected pinned nav when there's a different pinned task to jump to") + } + + // Multiple pins including the current one: shown. + m.SetPinnedNav([]PinnedNavItem{{ID: 1, Title: "Current"}, {ID: 2, Title: "Other"}}, 1) + if !m.HasPinnedNav() { + t.Error("expected pinned nav with multiple pinned tasks") + } +} + +func TestPinnedNavNextPrevWrap(t *testing.T) { + m := newNavDetailModel(t) + items := []PinnedNavItem{{ID: 10, Title: "a"}, {ID: 20, Title: "b"}, {ID: 30, Title: "c"}} + + // Current is the middle item. + m.SetPinnedNav(items, 20) + if got := m.PinnedNavNextID(); got != 30 { + t.Errorf("next from middle: got %d, want 30", got) + } + if got := m.PinnedNavPrevID(); got != 10 { + t.Errorf("prev from middle: got %d, want 10", got) + } + + // Wrap around from the ends. + m.SetPinnedNav(items, 30) + if got := m.PinnedNavNextID(); got != 10 { + t.Errorf("next should wrap to first: got %d, want 10", got) + } + m.SetPinnedNav(items, 10) + if got := m.PinnedNavPrevID(); got != 30 { + t.Errorf("prev should wrap to last: got %d, want 30", got) + } + + // Current task not in the pinned set (viewing an unpinned task): next/prev + // still land on the ends of the list. + m.SetPinnedNav(items, 999) + if got := m.PinnedNavNextID(); got != 10 { + t.Errorf("next with no current index: got %d, want 10", got) + } + if got := m.PinnedNavPrevID(); got != 30 { + t.Errorf("prev with no current index: got %d, want 30", got) + } +} + +func TestPinnedNavIDAt(t *testing.T) { + m := newNavDetailModel(t) + items := []PinnedNavItem{{ID: 10}, {ID: 20}, {ID: 30}} + m.SetPinnedNav(items, 10) + + if got := m.PinnedNavIDAt(1); got != 10 { + t.Errorf("PinnedNavIDAt(1)=%d, want 10", got) + } + if got := m.PinnedNavIDAt(3); got != 30 { + t.Errorf("PinnedNavIDAt(3)=%d, want 30", got) + } + // Out of range returns 0 (no-op). + if got := m.PinnedNavIDAt(0); got != 0 { + t.Errorf("PinnedNavIDAt(0)=%d, want 0", got) + } + if got := m.PinnedNavIDAt(4); got != 0 { + t.Errorf("PinnedNavIDAt(4)=%d, want 0", got) + } +} + +func TestPinnedNavRendersInView(t *testing.T) { + m := newNavDetailModel(t) + m.SetPinnedNav([]PinnedNavItem{ + {ID: 1, Title: "Current"}, + {ID: 42, Title: "Refactor login"}, + }, 1) + + // The bar renders at the bottom of the box (not the header), so it appears in + // the full view but not in the header block. + if strings.Contains(m.renderHeader(), "#42") { + t.Error("pinned nav should not be in the header block (it lives at the box bottom)") + } + if !strings.Contains(m.View(), "#42") { + t.Errorf("expected pinned nav bar to reference task #42 in the view, got:\n%s", m.View()) + } + // The bar adds a line, so the reserved chrome height must grow to keep the + // viewport clear. + if m.headerHeight() != 7 { + t.Errorf("headerHeight with nav = %d, want 7", m.headerHeight()) + } +} diff --git a/internal/ui/pinned_filter_test.go b/internal/ui/pinned_filter_test.go new file mode 100644 index 00000000..75bed951 --- /dev/null +++ b/internal/ui/pinned_filter_test.go @@ -0,0 +1,70 @@ +package ui + +import ( + "testing" + + "github.com/bborn/workflow/internal/db" +) + +// TestFilteredPinnedTasksRespectsFilter is the core ticket requirement: the +// detail view's pinned quick-nav must honour the board's active filter, so a +// board filtered to "[projectX]" surfaces only projectX's pinned tasks, not +// every pin across the board. +func TestFilteredPinnedTasksRespectsFilter(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer database.Close() + + m := &AppModel{db: database} + m.tasks = []*db.Task{ + {ID: 1, Title: "alpha", Project: "projectx", Status: db.StatusProcessing, Pinned: true}, + {ID: 2, Title: "beta", Project: "projectx", Status: db.StatusBacklog, Pinned: false}, // not pinned + {ID: 3, Title: "gamma", Project: "other", Status: db.StatusBlocked, Pinned: true}, // pinned, wrong project + {ID: 4, Title: "delta", Project: "projectx", Status: db.StatusBlocked, Pinned: true}, + } + + // No filter: every pinned task, ordered by status column then ID. + // Statuses: #1 processing(2); #3 & #4 both blocked(3), so ID-ascending -> [1,3,4] + got := ids(m.filteredPinnedTasks()) + want := []int64{1, 3, 4} + if !equalIDs(got, want) { + t.Errorf("no filter: got %v, want %v", got, want) + } + + // Filter to projectx via bracket syntax: only pinned projectx tasks. + m.filterText = "[projectx]" + got = ids(m.filteredPinnedTasks()) + want = []int64{1, 4} + if !equalIDs(got, want) { + t.Errorf("[projectx] filter: got %v, want %v (should exclude pinned task from 'other')", got, want) + } + + // Filter to a project with no pinned tasks: empty result. + m.filterText = "[other] gamma" + got = ids(m.filteredPinnedTasks()) + if len(got) != 1 || got[0] != 3 { + t.Errorf("[other] gamma filter: got %v, want [3]", got) + } +} + +func ids(tasks []*db.Task) []int64 { + out := make([]int64, len(tasks)) + for i, t := range tasks { + out[i] = t.ID + } + return out +} + +func equalIDs(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/ui/pinned_nav_integration_test.go b/internal/ui/pinned_nav_integration_test.go new file mode 100644 index 00000000..efbb489f --- /dev/null +++ b/internal/ui/pinned_nav_integration_test.go @@ -0,0 +1,81 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/config" + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/executor" +) + +// TestUpdateDetailPinnedJump drives the real detail-view key handler: with the +// pinned quick-nav bar populated, pressing "]" must start a task transition to +// the next pinned task (reusing the same guard as prev/next navigation), and a +// digit must jump directly to that pill. +func TestUpdateDetailPinnedJump(t *testing.T) { + t.Setenv("TMUX", "") // keep DetailModel tmux-free + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer database.Close() + + exec := executor.New(database, &config.Config{}) + + current := &db.Task{Title: "current", Status: db.StatusProcessing, Pinned: true} + other := &db.Task{Title: "other", Status: db.StatusProcessing, Pinned: true} + for _, tk := range []*db.Task{current, other} { + if err := database.CreateTask(tk); err != nil { + t.Fatalf("create task: %v", err) + } + } + + detail, _ := NewDetailModel(current, database, exec, 120, 40, false) + detail.SetPinnedNav([]PinnedNavItem{ + {ID: current.ID, Title: "current"}, + {ID: other.ID, Title: "other"}, + }, current.ID) + + kanban := NewKanbanBoard(120, 40) + kanban.SetTasks([]*db.Task{current, other}) + + m := &AppModel{ + width: 120, + height: 40, + currentView: ViewDetail, + db: database, + executor: exec, + keys: DefaultKeyMap(), + kanban: kanban, + detailView: detail, + selectedTask: current, + lastViewedAt: map[int64]time.Time{}, + } + + // Press "]" -> transition to next pinned task should begin. + model, cmd := m.updateDetail(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("]")}) + am := model.(*AppModel) + if !am.taskTransitionInProgress { + t.Fatal(`pressing "]" should begin a task transition to the next pinned task`) + } + if cmd == nil { + t.Fatal(`pressing "]" should return a loadTask command`) + } + + // Reset and try a direct digit jump to pill 2 (the "other" task). + am.taskTransitionInProgress = false + am.detailView, _ = NewDetailModel(current, database, exec, 120, 40, false) + am.detailView.SetPinnedNav([]PinnedNavItem{ + {ID: current.ID, Title: "current"}, + {ID: other.ID, Title: "other"}, + }, current.ID) + + _, cmd = am.updateDetail(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + if cmd == nil || !am.taskTransitionInProgress { + t.Fatal(`pressing "2" should jump directly to the second pinned task`) + } +} diff --git a/parity-ignore.json b/parity-ignore.json index 0967ef42..de8be5a4 100644 --- a/parity-ignore.json +++ b/parity-ignore.json @@ -1 +1,4 @@ -{} +{ + "PrevPinnedTask": "TUI-only keyboard affordance ('[') for cycling to the previous pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there.", + "NextPinnedTask": "TUI-only keyboard affordance (']') for cycling to the next pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there." +}