From 7c488b2d831abcc95f6363f35f1d1998d7fe993f Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 4 Dec 2025 22:31:28 +0100 Subject: [PATCH] Add sorting, user assignment, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Add work item sorting by ID, Type, State (keys 1, 2, 3) - Add user assignment modal with team member filtering (key a) - Add parent work item title display in details panel - Preserve sort order and selection on panel reload UI improvements: - Remove zebra striping from work items list - Remove priority column from list and details - Align metadata fields in details panel - Add markdown rendering for descriptions (using glamour) - Add state colors: To Do (orange), In Progress (purple), Done (green), Testing (yellow) 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude --- internal/api/teams.go | 51 ++++ internal/api/workitems.go | 91 ++++++++ internal/models/team.go | 8 + internal/ui/app.go | 63 ++++- internal/ui/components/assignmodal.go | 321 ++++++++++++++++++++++++++ internal/ui/components/workitems.go | 135 ++++++++++- internal/ui/theme/keys.go | 25 +- 7 files changed, 682 insertions(+), 12 deletions(-) create mode 100644 internal/api/teams.go create mode 100644 internal/models/team.go create mode 100644 internal/ui/components/assignmodal.go diff --git a/internal/api/teams.go b/internal/api/teams.go new file mode 100644 index 0000000..ef9b018 --- /dev/null +++ b/internal/api/teams.go @@ -0,0 +1,51 @@ +package api + +import ( + "fmt" + + "github.com/samuelenocsson/devops-tui/internal/models" +) + +// teamMembersResponse represents the response from team members API +type teamMembersResponse struct { + Value []teamMemberItem `json:"value"` + Count int `json:"count"` +} + +type teamMemberItem struct { + Identity identityRef `json:"identity"` +} + +type identityRef struct { + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName"` + ID string `json:"id"` +} + +// GetTeamMembers fetches all members of the configured team +func (c *Client) GetTeamMembers() ([]models.TeamMember, error) { + // Azure DevOps API: GET https://dev.azure.com/{org}/_apis/projects/{project}/teams/{team}/members + url := fmt.Sprintf("https://dev.azure.com/%s/_apis/projects/%s/teams/%s/members?api-version=%s", + c.organization, c.project, c.team, apiVersion) + + resp, err := c.doRequest("GET", url, nil) + if err != nil { + return nil, err + } + + var apiResp teamMembersResponse + if err := decode(resp, &apiResp); err != nil { + return nil, err + } + + members := make([]models.TeamMember, 0, len(apiResp.Value)) + for _, item := range apiResp.Value { + members = append(members, models.TeamMember{ + ID: item.Identity.ID, + DisplayName: item.Identity.DisplayName, + UniqueName: item.Identity.UniqueName, + }) + } + + return members, nil +} diff --git a/internal/api/workitems.go b/internal/api/workitems.go index a86741e..65fc029 100644 --- a/internal/api/workitems.go +++ b/internal/api/workitems.go @@ -167,9 +167,60 @@ func (c *Client) GetWorkItems(ids []string) ([]models.WorkItem, error) { } } + // Fetch parent titles + c.populateParentTitles(allItems) + return allItems, nil } +// populateParentTitles fetches titles for all parent work items +func (c *Client) populateParentTitles(items []models.WorkItem) { + // Collect unique parent IDs + parentIDs := make(map[int]bool) + for _, item := range items { + if item.ParentID > 0 { + parentIDs[item.ParentID] = true + } + } + + if len(parentIDs) == 0 { + return + } + + // Convert to string slice + ids := make([]string, 0, len(parentIDs)) + for id := range parentIDs { + ids = append(ids, fmt.Sprintf("%d", id)) + } + + // Fetch parent work items (only need ID and Title) + endpoint := fmt.Sprintf("/wit/workitems?ids=%s&fields=System.Id,System.Title", strings.Join(ids, ",")) + resp, err := c.get(endpoint) + if err != nil { + return // Silently fail - parent titles are optional + } + + var apiResp workItemsResponse + if err := decode(resp, &apiResp); err != nil { + return + } + + // Build ID -> Title map + titleMap := make(map[int]string) + for _, item := range apiResp.Value { + titleMap[item.ID] = item.Fields.Title + } + + // Update items with parent titles + for i := range items { + if items[i].ParentID > 0 { + if title, ok := titleMap[items[i].ParentID]; ok { + items[i].ParentTitle = title + } + } + } +} + // GetWorkItem fetches a single work item by ID func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) { fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate" @@ -186,6 +237,19 @@ func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) { } wi := c.convertWorkItem(item) + + // Fetch parent title if parent exists + if wi.ParentID > 0 { + parentEndpoint := fmt.Sprintf("/wit/workitems/%d?fields=System.Title", wi.ParentID) + parentResp, err := c.get(parentEndpoint) + if err == nil { + var parentItem workItemAPIItem + if decode(parentResp, &parentItem) == nil { + wi.ParentTitle = parentItem.Fields.Title + } + } + } + return &wi, nil } @@ -252,6 +316,33 @@ func (c *Client) UpdateWorkItemState(id int, newState string) error { return nil } +// AssignWorkItem assigns a work item to a user +// Pass empty string to unassign +func (c *Client) AssignWorkItem(id int, userEmail string) error { + // Azure DevOps uses JSON Patch format + patchDoc := []map[string]interface{}{ + { + "op": "add", + "path": "/fields/System.AssignedTo", + "value": userEmail, + }, + } + + bodyBytes, err := json.Marshal(patchDoc) + if err != nil { + return fmt.Errorf("marshaling patch document: %w", err) + } + + endpoint := fmt.Sprintf("/wit/workitems/%d", id) + resp, err := c.patch(endpoint, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + resp.Body.Close() + + return nil +} + // stripHTML removes HTML tags from a string func stripHTML(s string) string { // Simple HTML tag removal diff --git a/internal/models/team.go b/internal/models/team.go new file mode 100644 index 0000000..abb984c --- /dev/null +++ b/internal/models/team.go @@ -0,0 +1,8 @@ +package models + +// TeamMember represents a team member +type TeamMember struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName"` +} diff --git a/internal/ui/app.go b/internal/ui/app.go index d1b7664..876225d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -42,6 +42,7 @@ type App struct { helpPanel components.HelpPanel stateModal components.StateModal branchModal components.BranchModal + assignModal components.AssignModal // State activePanel Panel @@ -55,6 +56,7 @@ type App struct { areas []models.Area workItems []models.WorkItem statesByType map[string][]models.WorkItemStateInfo + teamMembers []models.TeamMember // Services client *api.Client @@ -84,6 +86,7 @@ func NewApp(client *api.Client) App { helpPanel: components.NewHelpPanel(keys, styles), stateModal: components.NewStateModal(styles, keys), branchModal: components.NewBranchModal(styles, keys), + assignModal: components.NewAssignModal(styles, keys), activePanel: PanelWorkItems, viewMode: ViewMain, loading: true, @@ -130,6 +133,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } + if a.assignModal.IsVisible() { + newModal, cmd := a.assignModal.Update(msg) + a.assignModal = newModal + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + // Global keys if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain { return a, tea.Quit @@ -195,6 +207,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Open assign modal (only when work items panel is active) + if key.Matches(msg, a.keys.Assign) && a.activePanel == PanelWorkItems { + if item := a.workItemsPanel.SelectedItem(); item != nil { + a.assignModal.SetItem(item) + a.assignModal.SetMembers(a.teamMembers) + a.assignModal.SetSize(a.width, a.height) + a.assignModal.SetVisible(true) + return a, nil + } + } + // Update active panel switch a.activePanel { case PanelFilter: @@ -215,6 +238,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.iterations = msg.iterations a.areas = msg.areas a.statesByType = msg.statesByType + a.teamMembers = msg.teamMembers a.stateModal.SetStatesByType(a.statesByType) filterState := models.NewFilterState(a.iterations, a.areas, a.statesByType) @@ -268,6 +292,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Modal was closed, nothing special to do a.stateModal.SetVisible(false) a.branchModal.SetVisible(false) + a.assignModal.SetVisible(false) case components.StateChangeRequestMsg: a.stateModal.SetVisible(false) @@ -289,6 +314,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case components.BranchCreateErrorMsg: a.err = msg.Err + + case components.AssignRequestMsg: + a.assignModal.SetVisible(false) + a.loading = true + return a, assignWorkItemCmd(a.client, msg.Item.ID, msg.UserEmail, msg.UserName, a.filterPanel.FilterState()) + + case assignedMsg: + a.loading = false + a.statusMsg = fmt.Sprintf("Assigned to %s", msg.userName) + // Refresh work items to show updated assignment + return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState()) } // Update selected item in details panel @@ -313,6 +349,11 @@ func (a App) View() string { return a.branchModal.View() } + // Render assign modal if visible + if a.assignModal.IsVisible() { + return a.assignModal.View() + } + // Render help overlay if visible if a.helpPanel.IsVisible() { _ = a.renderMainView() @@ -455,6 +496,7 @@ type dataLoadedMsg struct { iterations []models.Iteration areas []models.Area statesByType map[string][]models.WorkItemStateInfo + teamMembers []models.TeamMember } type workItemsLoadedMsg struct { @@ -469,6 +511,10 @@ type stateChangedMsg struct { newState string } +type assignedMsg struct { + userName string +} + // Commands func loadDataCmd(client *api.Client) tea.Cmd { @@ -486,7 +532,12 @@ func loadDataCmd(client *api.Client) tea.Cmd { // Non-fatal - we can still work with hardcoded states statesByType = make(map[string][]models.WorkItemStateInfo) } - return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType} + teamMembers, err := client.GetTeamMembers() + if err != nil { + // Non-fatal - we can still work without team members + teamMembers = []models.TeamMember{} + } + return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType, teamMembers: teamMembers} } } @@ -530,3 +581,13 @@ func createBranchCmd(branchName string) tea.Cmd { return components.BranchCreatedMsg{BranchName: branchName} } } + +func assignWorkItemCmd(client *api.Client, itemID int, userEmail, userName string, filterState *models.FilterState) tea.Cmd { + return func() tea.Msg { + err := client.AssignWorkItem(itemID, userEmail) + if err != nil { + return errMsg{err: err} + } + return assignedMsg{userName: userName} + } +} diff --git a/internal/ui/components/assignmodal.go b/internal/ui/components/assignmodal.go new file mode 100644 index 0000000..dc9762c --- /dev/null +++ b/internal/ui/components/assignmodal.go @@ -0,0 +1,321 @@ +package components + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/samuelenocsson/devops-tui/internal/models" + "github.com/samuelenocsson/devops-tui/internal/ui/theme" +) + +// AssignModal is a modal for assigning work items to users +type AssignModal struct { + visible bool + item *models.WorkItem + members []models.TeamMember + filtered []models.TeamMember + cursor int + styles theme.Styles + keys theme.KeyMap + width int + height int + filterInput textinput.Model + filterEnabled bool +} + +// NewAssignModal creates a new assign modal +func NewAssignModal(styles theme.Styles, keys theme.KeyMap) AssignModal { + ti := textinput.New() + ti.Placeholder = "Type to filter..." + ti.CharLimit = 50 + ti.Width = 30 + + return AssignModal{ + styles: styles, + keys: keys, + filterInput: ti, + } +} + +// Init initializes the modal +func (m AssignModal) Init() tea.Cmd { + return nil +} + +// Update handles messages +func (m AssignModal) Update(msg tea.Msg) (AssignModal, tea.Cmd) { + if !m.visible { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle filter input + if m.filterEnabled { + switch msg.String() { + case "esc": + if m.filterInput.Value() != "" { + m.filterInput.SetValue("") + m.applyFilter() + return m, nil + } + m.visible = false + return m, func() tea.Msg { return ModalClosedMsg{} } + case "enter": + if len(m.filtered) > 0 && m.item != nil { + selected := m.filtered[m.cursor] + return m, func() tea.Msg { + return AssignRequestMsg{ + Item: *m.item, + UserEmail: selected.UniqueName, + UserName: selected.DisplayName, + } + } + } + case "up": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "down": + if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + return m, nil + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + m.applyFilter() + return m, cmd + } + return m, nil + } + + switch { + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + case key.Matches(msg, m.keys.Select): + if m.item != nil && m.cursor < len(m.filtered) { + selected := m.filtered[m.cursor] + return m, func() tea.Msg { + return AssignRequestMsg{ + Item: *m.item, + UserEmail: selected.UniqueName, + UserName: selected.DisplayName, + } + } + } + case key.Matches(msg, m.keys.Back): + m.visible = false + return m, func() tea.Msg { return ModalClosedMsg{} } + case msg.String() == "/": + m.filterEnabled = true + m.filterInput.Focus() + return m, textinput.Blink + } + } + + return m, nil +} + +func (m *AssignModal) applyFilter() { + filter := strings.ToLower(m.filterInput.Value()) + if filter == "" { + m.filtered = m.members + } else { + m.filtered = make([]models.TeamMember, 0) + for _, member := range m.members { + if strings.Contains(strings.ToLower(member.DisplayName), filter) || + strings.Contains(strings.ToLower(member.UniqueName), filter) { + m.filtered = append(m.filtered, member) + } + } + } + // Reset cursor if out of bounds + if m.cursor >= len(m.filtered) { + m.cursor = 0 + } +} + +// View renders the modal +func (m AssignModal) View() string { + if !m.visible { + return "" + } + + // Modal dimensions + modalWidth := 50 + visibleItems := 8 + modalHeight := visibleItems + 10 + + // Build content + var b strings.Builder + + // Title + title := lipgloss.NewStyle().Bold(true).Render("Assign To") + if m.item != nil { + itemInfo := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9CA3AF")). + Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 35)) + b.WriteString(title + "\n") + b.WriteString(itemInfo + "\n\n") + } + + // Current assignee + if m.item != nil { + currentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA")) + assigned := m.item.AssignedTo + if assigned == "" { + assigned = "Unassigned" + } + b.WriteString(currentStyle.Render("Current: "+assigned) + "\n\n") + } + + // Filter input + if m.filterEnabled { + b.WriteString(m.filterInput.View() + "\n\n") + } else { + filterHint := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render("Press / to filter") + b.WriteString(filterHint + "\n\n") + } + + // Unassign option first + unassignCursor := " " + if m.cursor == 0 && len(m.filtered) == 0 { + unassignCursor = "▸ " + } + + // Member options + if len(m.filtered) == 0 { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(" No members found") + "\n") + } else { + // Calculate scroll offset + offset := 0 + if m.cursor >= visibleItems { + offset = m.cursor - visibleItems + 1 + } + + end := offset + visibleItems + if end > len(m.filtered) { + end = len(m.filtered) + } + + for i := offset; i < end; i++ { + member := m.filtered[i] + cursor := " " + if i == m.cursor { + cursor = "▸ " + } + + style := lipgloss.NewStyle() + if i == m.cursor { + style = style.Bold(true).Foreground(lipgloss.Color("#7C3AED")) + } + + // Highlight if this is the current assignee + if m.item != nil && member.DisplayName == m.item.AssignedTo { + style = style.Foreground(lipgloss.Color("#10B981")) + } + + // Show name, truncate if needed + name := truncateStr(member.DisplayName, modalWidth-10) + b.WriteString(cursor + style.Render(name) + "\n") + } + + // Show scroll indicator + if len(m.filtered) > visibleItems { + scrollInfo := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")). + Render(" (" + itoa(m.cursor+1) + "/" + itoa(len(m.filtered)) + ")") + b.WriteString(scrollInfo + "\n") + } + } + + _ = unassignCursor // Reserved for future unassign option + + // Help text + b.WriteString("\n") + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) + if m.filterEnabled { + b.WriteString(helpStyle.Render("Enter: confirm Esc: clear/close")) + } else { + b.WriteString(helpStyle.Render("Enter: confirm /: filter Esc: cancel")) + } + + // Modal style + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight). + Background(lipgloss.Color("#1F2937")) + + modal := modalStyle.Render(b.String()) + + // Center the modal + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) +} + +// SetVisible sets the visibility +func (m *AssignModal) SetVisible(visible bool) { + m.visible = visible + if visible { + m.cursor = 0 + m.filterInput.SetValue("") + m.filterEnabled = false + m.filterInput.Blur() + m.applyFilter() + // Try to set cursor to current assignee + if m.item != nil && m.item.AssignedTo != "" { + for i, member := range m.filtered { + if member.DisplayName == m.item.AssignedTo { + m.cursor = i + break + } + } + } + } +} + +// IsVisible returns whether the modal is visible +func (m *AssignModal) IsVisible() bool { + return m.visible +} + +// SetItem sets the work item to modify +func (m *AssignModal) SetItem(item *models.WorkItem) { + m.item = item +} + +// SetMembers sets the available team members +func (m *AssignModal) SetMembers(members []models.TeamMember) { + m.members = members + m.applyFilter() +} + +// SetSize sets the modal container size +func (m *AssignModal) SetSize(width, height int) { + m.width = width + m.height = height +} + +// AssignRequestMsg is sent when user confirms assignment +type AssignRequestMsg struct { + Item models.WorkItem + UserEmail string + UserName string +} + +// AssignedMsg is sent when assignment is complete +type AssignedMsg struct { + Item models.WorkItem +} diff --git a/internal/ui/components/workitems.go b/internal/ui/components/workitems.go index 2fa1c57..248c1b2 100644 --- a/internal/ui/components/workitems.go +++ b/internal/ui/components/workitems.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "sort" "strings" "github.com/charmbracelet/bubbles/key" @@ -11,6 +12,23 @@ import ( "github.com/samuelenocsson/devops-tui/internal/ui/theme" ) +// SortField represents the field to sort by +type SortField int + +const ( + SortByID SortField = iota + SortByState + SortByType +) + +// SortDirection represents the sort direction +type SortDirection int + +const ( + SortAsc SortDirection = iota + SortDesc +) + // Column definitions type column struct { title string @@ -21,15 +39,17 @@ type column struct { // WorkItemsPanel is the work items list component type WorkItemsPanel struct { - items []models.WorkItem - cursor int - styles theme.Styles - keys theme.KeyMap - width int - height int - focused bool - offset int // For scrolling - columns []column + items []models.WorkItem + cursor int + styles theme.Styles + keys theme.KeyMap + width int + height int + focused bool + offset int // For scrolling + columns []column + sortField SortField + sortDir SortDirection } // NewWorkItemsPanel creates a new work items panel @@ -78,6 +98,12 @@ func (w WorkItemsPanel) Update(msg tea.Msg) (WorkItemsPanel, tea.Cmd) { if w.SelectedItem() != nil { return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} } } + case key.Matches(msg, w.keys.SortByID): + w.toggleSort(SortByID) + case key.Matches(msg, w.keys.SortByState): + w.toggleSort(SortByState) + case key.Matches(msg, w.keys.SortByType): + w.toggleSort(SortByType) } } @@ -172,14 +198,37 @@ func (w *WorkItemsPanel) renderHeader(colWidths []int) string { Bold(true). Foreground(lipgloss.Color("#9CA3AF")) + sortedStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7C3AED")) + var parts []string for i, col := range w.columns { width := colWidths[i] title := col.title + + // Add sort indicator + isSorted := (col.title == "ID" && w.sortField == SortByID) || + (col.title == "STATE" && w.sortField == SortByState) || + (col.title == "TYPE" && w.sortField == SortByType) + + if isSorted { + arrow := "▲" + if w.sortDir == SortDesc { + arrow = "▼" + } + title = title + arrow + } + if len(title) > width { title = title[:width] } - parts = append(parts, headerStyle.Width(width).Render(title)) + + if isSorted { + parts = append(parts, sortedStyle.Width(width).Render(title)) + } else { + parts = append(parts, headerStyle.Width(width).Render(title)) + } } return " " + strings.Join(parts, " ") @@ -305,6 +354,46 @@ func (w *WorkItemsPanel) moveToBottom() { } } +func (w *WorkItemsPanel) toggleSort(field SortField) { + if w.sortField == field { + // Toggle direction if same field + if w.sortDir == SortAsc { + w.sortDir = SortDesc + } else { + w.sortDir = SortAsc + } + } else { + w.sortField = field + w.sortDir = SortAsc + } + w.sortItems() +} + +func (w *WorkItemsPanel) sortItems() { + if len(w.items) == 0 { + return + } + + sort.SliceStable(w.items, func(i, j int) bool { + var less bool + switch w.sortField { + case SortByID: + less = w.items[i].ID < w.items[j].ID + case SortByState: + less = string(w.items[i].State) < string(w.items[j].State) + case SortByType: + less = string(w.items[i].Type) < string(w.items[j].Type) + default: + less = w.items[i].ID < w.items[j].ID + } + + if w.sortDir == SortDesc { + return !less + } + return less + }) +} + // SetSize sets the size of the work items panel func (w *WorkItemsPanel) SetSize(width, height int) { w.width = width @@ -330,13 +419,30 @@ func (w *WorkItemsPanel) SetFocused(focused bool) { // SetItems sets the work items func (w *WorkItemsPanel) SetItems(items []models.WorkItem) { + // Remember currently selected item ID + var selectedID int + if w.cursor >= 0 && w.cursor < len(w.items) { + selectedID = w.items[w.cursor].ID + } + oldLen := len(w.items) w.items = items + // Re-apply current sort + w.sortItems() + // Only reset position if this is new data (not just a refresh) if oldLen == 0 && len(items) > 0 { w.cursor = 0 w.offset = 0 + } else if selectedID > 0 { + // Try to restore cursor to previously selected item + for i, item := range w.items { + if item.ID == selectedID { + w.cursor = i + break + } + } } // Clamp cursor to valid range @@ -346,6 +452,15 @@ func (w *WorkItemsPanel) SetItems(items []models.WorkItem) { if w.cursor < 0 { w.cursor = 0 } + + // Adjust offset to keep cursor visible + visible := w.visibleItemCount() + if w.cursor < w.offset { + w.offset = w.cursor + } + if w.cursor >= w.offset+visible { + w.offset = w.cursor - visible + 1 + } } // SelectedItem returns the currently selected work item diff --git a/internal/ui/theme/keys.go b/internal/ui/theme/keys.go index 809d19a..37e397c 100644 --- a/internal/ui/theme/keys.go +++ b/internal/ui/theme/keys.go @@ -25,6 +25,12 @@ type KeyMap struct { Quit key.Binding ChangeState key.Binding CreateBranch key.Binding + Assign key.Binding + + // Sorting + SortByID key.Binding + SortByState key.Binding + SortByType key.Binding } // DefaultKeyMap returns the default key bindings @@ -102,6 +108,22 @@ func DefaultKeyMap() KeyMap { key.WithKeys("b"), key.WithHelp("b", "create branch"), ), + Assign: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "assign"), + ), + SortByID: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "sort by ID"), + ), + SortByType: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "sort by type"), + ), + SortByState: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "sort by state"), + ), } } @@ -122,7 +144,8 @@ func (k KeyMap) FullHelp() [][]key.Binding { {k.Up, k.Down, k.Top, k.Bottom}, {k.NextPanel, k.PrevPanel}, {k.Select, k.Open, k.View}, - {k.ChangeState, k.CreateBranch}, + {k.ChangeState, k.CreateBranch, k.Assign}, + {k.SortByID, k.SortByType, k.SortByState}, {k.Search, k.Refresh}, {k.Help, k.Back, k.Quit}, }