Skip to content
This repository was archived by the owner on Jul 3, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions linear.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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!) {
Expand Down
1 change: 1 addition & 0 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ func (m Model) Init() tea.Cmd {
m.fetchWorktrees(),
m.fetchViewer(),
m.fetchProjects(),
m.fetchCustomViews(),
m.detectBranchIssue(),
}
if m.useCmux {
Expand Down
15 changes: 15 additions & 0 deletions model_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Comment on lines +24 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Custom view issues are truncated at 50 items due to missing pagination.

GetCustomViewIssues returns PageInfo with hasNextPage and endCursor, but this code discards the pagination info and never fetches subsequent pages. Users with more than 50 issues in a custom view will see an incomplete list.

The existing issue fetching methods also lack pagination, so this may be an accepted limitation. However, custom views often contain curated issue sets that could exceed 50 items.

Consider adding pagination loop or documenting the limitation

If pagination is needed:

 		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}
+			var allIssues []Issue
+			var after string
+			for {
+				issues, pageInfo, err := client.GetCustomViewIssues(viewID, after)
+				if err != nil {
+					return issuesLoadedMsg{err: err}
+				}
+				allIssues = append(allIssues, issues...)
+				if !pageInfo.HasNextPage {
+					break
+				}
+				after = pageInfo.EndCursor
+			}
+			return issuesLoadedMsg{issues: allIssues}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}
}
if m.activeViewIdx > 0 && m.activeViewIdx-1 < len(m.customViews) {
viewID := m.customViews[m.activeViewIdx-1].ID
var allIssues []Issue
var after string
for {
issues, pageInfo, err := client.GetCustomViewIssues(viewID, after)
if err != nil {
return issuesLoadedMsg{err: err}
}
allIssues = append(allIssues, issues...)
if !pageInfo.HasNextPage {
break
}
after = pageInfo.EndCursor
}
return issuesLoadedMsg{issues: allIssues}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model_commands.go` around lines 24 - 28, The current block that loads custom
view issues calls client.GetCustomViewIssues(viewID, "") once and ignores
PageInfo, truncating results at the first page; modify the logic in the handler
that uses m.activeViewIdx and m.customViews to iterate pagination: call
GetCustomViewIssues(viewID, cursor) in a loop, append each page's issues to a
single slice, use PageInfo.endCursor to advance and PageInfo.hasNextPage to
stop, and then return a single issuesLoadedMsg{issues: allIssues, err: err}
(preserving error handling) so users receive the full issue set.


hasProject := m.projectFilter != nil
hasLabel := m.labelFilter != nil

Expand Down
25 changes: 25 additions & 0 deletions model_list_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions model_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,8 @@ type worktreeListLoadedMsg struct {
worktrees []Worktree
err error
}

type customViewsLoadedMsg struct {
views []CustomView
err error
}
56 changes: 43 additions & 13 deletions model_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ type teamState struct {
labelFilter *string
labelName string
listIndex int
customViews []CustomView
activeViewIdx int
}

type Model struct {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
}
Expand All @@ -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"
Expand All @@ -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,
Expand All @@ -337,6 +361,8 @@ func (m *Model) saveTeamState() {
labelFilter: m.labelFilter,
labelName: m.labelName,
listIndex: m.list.Index(),
customViews: views,
activeViewIdx: m.activeViewIdx,
}
}

Expand All @@ -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()
Expand All @@ -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()
Expand Down
39 changes: 37 additions & 2 deletions model_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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())
}
Expand Down Expand Up @@ -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"))):
Expand Down Expand Up @@ -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("?"))):
Expand Down
Loading