From 5fa030c982053e3a543d95019c040f5db7c21c5a Mon Sep 17 00:00:00 2001 From: Sarah Wolff Date: Tue, 7 Apr 2026 17:35:55 -0400 Subject: [PATCH] feat(views): add custom Linear views as sub-tab bar Add support for browsing custom Linear views in the TUI. When a team has saved views, a tab bar appears below the team bar showing "All Issues" plus each custom view. Use [ and ] to cycle between views. - Add GetCustomViews and GetCustomViewIssues API methods - Fetch views per team on init and team switch, cache in teamState - Render view tab bar conditionally (hidden when no views exist) - Lock filter/sort/project/label keys while a view is active - Show active view name in status bar and list title Closes #55 --- linear.go | 62 +++++++++++++++++++++++++ model.go | 1 + model_commands.go | 15 ++++++ model_list_actions.go | 25 ++++++++++ model_messages.go | 5 ++ model_state.go | 56 ++++++++++++++++------ model_update.go | 39 +++++++++++++++- model_view.go | 36 +++++++++++++- testdata/golden/list_demo_issues.txt | 1 + testdata/golden/list_empty_assigned.txt | 1 + 10 files changed, 224 insertions(+), 17 deletions(-) diff --git a/linear.go b/linear.go index f846a2f..9997a90 100644 --- a/linear.go +++ b/linear.go @@ -103,6 +103,12 @@ type Team struct { Key string `json:"key"` } +type CustomView struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` +} + func NewLinearClient(apiKey string) *LinearClient { return &LinearClient{ apiKey: apiKey, @@ -331,6 +337,62 @@ func (lc *LinearClient) GetIssues(teamID string, filter FilterMode, sort SortMod return lc.fetchFilteredIssues("", "", map[string]any{"teamID": teamID}, filter, sort, after) } +func (lc *LinearClient) GetCustomViews(teamID string) ([]CustomView, error) { + q := ` + query($teamID: ID!) { + customViews( + filter: { team: { id: { eq: $teamID } } } + first: 50 + ) { + nodes { id name icon } + } + }` + + var result struct { + CustomViews struct { + Nodes []CustomView `json:"nodes"` + } `json:"customViews"` + } + + err := lc.queryWithVars(q, map[string]any{"teamID": teamID}, &result) + return result.CustomViews.Nodes, err +} + +func (lc *LinearClient) GetCustomViewIssues(viewID string, after string) ([]Issue, PageInfo, error) { + afterClause := "" + afterVar := "" + if after != "" { + afterVar = ", $after: String" + afterClause = ", after: $after" + } + q := fmt.Sprintf(` + query($viewID: String!%s) { + customView(id: $viewID) { + issues(first: 50%s) { + nodes { %s } + pageInfo { hasNextPage endCursor } + } + } + }`, afterVar, afterClause, issueListFields) + + vars := map[string]any{"viewID": viewID} + if after != "" { + vars["after"] = after + } + + var result struct { + CustomView struct { + Issues struct { + Nodes []Issue `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` + } `json:"issues"` + } `json:"customView"` + } + + err := lc.queryWithVars(q, vars, &result) + return result.CustomView.Issues.Nodes, result.CustomView.Issues.PageInfo, err +} + func (lc *LinearClient) AddComment(issueID, body string) error { q := ` mutation($issueId: String!, $body: String!) { diff --git a/model.go b/model.go index f56d4cb..63545f3 100644 --- a/model.go +++ b/model.go @@ -139,6 +139,7 @@ func (m Model) Init() tea.Cmd { m.fetchWorktrees(), m.fetchViewer(), m.fetchProjects(), + m.fetchCustomViews(), m.detectBranchIssue(), } if m.useCmux { diff --git a/model_commands.go b/model_commands.go index ae03ba6..287e0db 100644 --- a/model_commands.go +++ b/model_commands.go @@ -9,9 +9,24 @@ import ( tea "charm.land/bubbletea/v2" ) +func (m Model) fetchCustomViews() tea.Cmd { + return func() tea.Msg { + client := NewLinearClient(m.cfg.LinearAPIKey) + views, err := client.GetCustomViews(m.cfg.TeamID) + return customViewsLoadedMsg{views: views, err: err} + } +} + func (m Model) fetchIssues() tea.Cmd { return func() tea.Msg { client := NewLinearClient(m.cfg.LinearAPIKey) + + if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) { + viewID := m.customViews[m.activeViewIdx-1].ID + issues, _, err := client.GetCustomViewIssues(viewID, "") + return issuesLoadedMsg{issues: issues, err: err} + } + hasProject := m.projectFilter != nil hasLabel := m.labelFilter != nil diff --git a/model_list_actions.go b/model_list_actions.go index 5c1c418..13acf59 100644 --- a/model_list_actions.go +++ b/model_list_actions.go @@ -10,6 +10,31 @@ import ( tea "charm.land/bubbletea/v2" ) +func (m *Model) cycleViewRight() (tea.Model, tea.Cmd) { + if len(m.customViews) == 0 { + return m, nil + } + m.activeViewIdx = (m.activeViewIdx + 1) % (len(m.customViews) + 1) + m.updateListTitle() + m.loading = true + m.loadingLabel = "Loading..." + return m, tea.Batch(m.fetchIssues(), m.spinner.Tick) +} + +func (m *Model) cycleViewLeft() (tea.Model, tea.Cmd) { + if len(m.customViews) == 0 { + return m, nil + } + m.activeViewIdx-- + if m.activeViewIdx < 0 { + m.activeViewIdx = len(m.customViews) + } + m.updateListTitle() + m.loading = true + m.loadingLabel = "Loading..." + return m, tea.Batch(m.fetchIssues(), m.spinner.Tick) +} + func (m *Model) cycleFilter() (tea.Model, tea.Cmd) { m.filter = m.filter.Next() m.updateListTitle() diff --git a/model_messages.go b/model_messages.go index 2392739..54a7181 100644 --- a/model_messages.go +++ b/model_messages.go @@ -124,3 +124,8 @@ type worktreeListLoadedMsg struct { worktrees []Worktree err error } + +type customViewsLoadedMsg struct { + views []CustomView + err error +} diff --git a/model_state.go b/model_state.go index addbad5..1238257 100644 --- a/model_state.go +++ b/model_state.go @@ -60,6 +60,8 @@ type teamState struct { labelFilter *string labelName string listIndex int + customViews []CustomView + activeViewIdx int } type Model struct { @@ -124,6 +126,9 @@ type Model struct { stateForm *huh.Form stateIssue *Issue + customViews []CustomView + activeViewIdx int // 0 = "All Issues", 1+ = custom view index + filterForm *huh.Form sortForm *huh.Form @@ -277,19 +282,32 @@ func (m *Model) rebuildList() { m.list.SetItems(items) } +func (m Model) activeViewName() string { + if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) { + return m.customViews[m.activeViewIdx-1].Name + } + return "" +} + func (m Model) buildStatusLine() string { parts := []string{} scope := m.cfg.TeamKey - if m.projectName != "" { - scope += " > " + m.projectName - } - if m.labelName != "" { - scope += " > label:" + m.labelName + if name := m.activeViewName(); name != "" { + scope += " > " + name + } else { + if m.projectName != "" { + scope += " > " + m.projectName + } + if m.labelName != "" { + scope += " > label:" + m.labelName + } } parts = append(parts, scope) parts = append(parts, fmt.Sprintf("%d issues", len(m.issues))) - parts = append(parts, m.filter.String()) - parts = append(parts, m.sortMode.String()) + if m.activeViewIdx == 0 { + parts = append(parts, m.filter.String()) + parts = append(parts, m.sortMode.String()) + } if m.useCmux && m.paneManager != nil { parts = append(parts, fmt.Sprintf("slots: %d/%d", m.paneManager.ActiveCount(), m.cfg.MaxSlots)) } @@ -301,13 +319,17 @@ func (m *Model) updateListTitle() { if len(m.cfg.Teams) <= 1 { parts = append(parts, m.cfg.TeamKey) } - if m.projectName != "" { - parts = append(parts, m.projectName) - } - if m.labelName != "" { - parts = append(parts, "label:"+m.labelName) + if name := m.activeViewName(); name != "" { + parts = append(parts, "["+name+"]") + } else { + if m.projectName != "" { + parts = append(parts, m.projectName) + } + if m.labelName != "" { + parts = append(parts, "label:"+m.labelName) + } + parts = append(parts, "["+m.filter.String()+"]") } - parts = append(parts, "["+m.filter.String()+"]") m.list.Title = strings.Join(parts, " > ") if m.list.Title == "" { m.list.Title = "Issues" @@ -326,6 +348,8 @@ func (m *Model) saveTeamState() { copy(labels, m.labels) states := make([]WorkflowState, len(m.workflowStates)) copy(states, m.workflowStates) + views := make([]CustomView, len(m.customViews)) + copy(views, m.customViews) m.teamCache[m.cfg.TeamKey] = &teamState{ issues: issues, projects: projects, @@ -337,6 +361,8 @@ func (m *Model) saveTeamState() { labelFilter: m.labelFilter, labelName: m.labelName, listIndex: m.list.Index(), + customViews: views, + activeViewIdx: m.activeViewIdx, } } @@ -354,6 +380,8 @@ func (m *Model) restoreTeamState() bool { m.projectName = ts.projectName m.labelFilter = ts.labelFilter m.labelName = ts.labelName + m.customViews = ts.customViews + m.activeViewIdx = ts.activeViewIdx m.rebuildList() m.list.Select(ts.listIndex) m.updateListTitle() @@ -378,6 +406,8 @@ func (m *Model) flushTeamState() { m.stateIssue = nil m.stateForm = nil m.filter = FilterAssigned + m.activeViewIdx = 0 + m.customViews = nil m.view = viewList m.list.SetItems(nil) m.updateListTitle() diff --git a/model_update.go b/model_update.go index 95be3cb..c300135 100644 --- a/model_update.go +++ b/model_update.go @@ -24,6 +24,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.cfg.Teams) > 1 { listHeight -= 2 } + if len(m.customViews) > 0 { + listHeight -= 2 + } m.list.SetSize(msg.Width-2, listHeight) m.linkList.SetSize(msg.Width-4, msg.Height-4) m.worktreeList.SetSize(msg.Width-4, msg.Height-4) @@ -265,6 +268,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case customViewsLoadedMsg: + if msg.err == nil { + m.customViews = msg.views + } + return m, nil + case teamSwitchedMsg: if msg.err != nil { m.statusMsg = fmt.Sprintf("Team switch error: %v", msg.err) @@ -279,7 +288,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.loading = true m.loadingLabel = fmt.Sprintf("Loading %s...", m.cfg.TeamKey) - return m, tea.Batch(m.fetchIssues(), m.fetchProjects(), m.fetchWorkflowStates(), m.spinner.Tick) + return m, tea.Batch(m.fetchIssues(), m.fetchProjects(), m.fetchWorkflowStates(), m.fetchCustomViews(), m.spinner.Tick) case setupCompleteMsg: m.cfg = msg.cfg @@ -290,7 +299,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keys.TeamSwitch.SetEnabled(len(m.cfg.Teams) > 1) m.updateListTitle() m.recreatePaneManagerIfNeeded() - cmds := []tea.Cmd{m.fetchIssues(), m.fetchWorktrees(), m.fetchViewer(), m.fetchProjects()} + cmds := []tea.Cmd{m.fetchIssues(), m.fetchWorktrees(), m.fetchViewer(), m.fetchProjects(), m.fetchCustomViews()} if m.useCmux { cmds = append(cmds, m.startStatusPoll()) } @@ -481,10 +490,24 @@ func (m *Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("]"))): + return m.cycleViewRight() + + case key.Matches(msg, key.NewBinding(key.WithKeys("["))): + return m.cycleViewLeft() + case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): + if m.activeViewIdx > 0 { + m.statusMsg = "Switch to All Issues first ([/])" + return m, nil + } return m.cycleFilter() case key.Matches(msg, key.NewBinding(key.WithKeys("f"))): + if m.activeViewIdx > 0 { + m.statusMsg = "Switch to All Issues first ([/])" + return m, nil + } return m, m.showFilterPicker() case key.Matches(msg, key.NewBinding(key.WithKeys("r"))): @@ -529,12 +552,24 @@ func (m *Model) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, m.buildSettingsForm() case key.Matches(msg, key.NewBinding(key.WithKeys("o"))): + if m.activeViewIdx > 0 { + m.statusMsg = "Switch to All Issues first ([/])" + return m, nil + } return m, m.showSortPicker() case key.Matches(msg, key.NewBinding(key.WithKeys("p"))): + if m.activeViewIdx > 0 { + m.statusMsg = "Switch to All Issues first ([/])" + return m, nil + } return m, m.showProjectPicker() case key.Matches(msg, key.NewBinding(key.WithKeys("L"))): + if m.activeViewIdx > 0 { + m.statusMsg = "Switch to All Issues first ([/])" + return m, nil + } return m, m.showLabelPicker() case key.Matches(msg, key.NewBinding(key.WithKeys("?"))): diff --git a/model_view.go b/model_view.go index 9054b90..4c49004 100644 --- a/model_view.go +++ b/model_view.go @@ -78,12 +78,41 @@ func (m Model) renderTeamTabBar() string { return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) } +func (m Model) renderViewTabBar() string { + if len(m.customViews) == 0 { + return "" + } + var tabs []string + label := "All Issues" + if m.activeViewIdx == 0 { + tabs = append(tabs, activeTabStyle.Render(label)) + } else { + tabs = append(tabs, inactiveTabStyle.Render(label)) + } + for i, v := range m.customViews { + name := v.Name + if v.Icon != "" { + name = v.Icon + " " + name + } + if m.activeViewIdx == i+1 { + tabs = append(tabs, activeTabStyle.Render(name)) + } else { + tabs = append(tabs, inactiveTabStyle.Render(name)) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + func (m Model) viewList() string { slotBar := m.renderSlotBar() teamBar := m.renderTeamTabBar() if teamBar != "" { teamBar += "\n" } + viewBar := m.renderViewTabBar() + if viewBar != "" { + viewBar += "\n" + } content := m.list.View() if m.loading { @@ -93,7 +122,7 @@ func (m Model) viewList() string { Padding(1, 3). Render(m.spinner.View() + " Loading issues...") overlay := lipgloss.Place(m.width, m.height-4, lipgloss.Center, lipgloss.Center, loadingBox) - return appStyle.Render(lipgloss.JoinVertical(lipgloss.Left, slotBar, teamBar, overlay)) + return appStyle.Render(lipgloss.JoinVertical(lipgloss.Left, slotBar, teamBar, viewBar, overlay)) } if len(m.issues) == 0 { @@ -128,13 +157,16 @@ func (m Model) viewList() string { row1 = "enter:detail c:claude p:project L:labels w:worktrees" row2 = "f:filter o:sort s:settings(+teams) ?:help" } + if len(m.customViews) > 0 { + row2 += " [/]:views" + } shortcutText := row1 + " " + row2 if lipgloss.Width(shortcutText)+2 > m.width { shortcutText = row1 + "\n" + row2 } shortcuts := statusBarStyle.Render(shortcutText) base := appStyle.Render( - lipgloss.JoinVertical(lipgloss.Left, slotBar, teamBar, content, status, legend, shortcuts), + lipgloss.JoinVertical(lipgloss.Left, slotBar, teamBar, viewBar, content, status, legend, shortcuts), ) if m.showHelp { diff --git a/testdata/golden/list_demo_issues.txt b/testdata/golden/list_demo_issues.txt index e8d01ce..c5dd64e 100644 --- a/testdata/golden/list_demo_issues.txt +++ b/testdata/golden/list_demo_issues.txt @@ -1,5 +1,6 @@ + ENG > [Assigned to me] │ ● ▲ ENG-142 Add rate limiting to public API endpoints diff --git a/testdata/golden/list_empty_assigned.txt b/testdata/golden/list_empty_assigned.txt index 612fea1..b036434 100644 --- a/testdata/golden/list_empty_assigned.txt +++ b/testdata/golden/list_empty_assigned.txt @@ -16,6 +16,7 @@ + No issues assigned to you in TST. Press tab or f to see all issues.