diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go index 28cb441..c413784 100644 --- a/pkg/tui/components/helpbar.go +++ b/pkg/tui/components/helpbar.go @@ -126,7 +126,8 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "↓/j", Desc: "down"}, {Key: "pgup/pgdn", Desc: "page"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "c", Desc: "comment"}, + {Key: "e/d/s/p/t/A", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "S", Desc: "submit"}, {Key: "q", Desc: "quit"}, @@ -137,11 +138,13 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "enter", LeftDesc: "view issue", RightKey: "↓/j", RightDesc: "down"}, {LeftKey: "pgup", LeftDesc: "page up", RightKey: "pgdn", RightDesc: "page down"}, {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, + {LeftKey: "c", LeftDesc: "add comment", RightKey: "", RightDesc: ""}, {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, - {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, - {LeftKey: "v", LeftDesc: "kanban", RightKey: "q", RightDesc: "quit"}, - {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "A", RightDesc: "change assignee"}, + {LeftKey: "x", LeftDesc: "delete issue", RightKey: "v", RightDesc: "kanban"}, + {LeftKey: "q", LeftDesc: "quit", RightKey: "S", RightDesc: "submit"}, + {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, }, } case ViewKanban: @@ -154,7 +157,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "h/l", Desc: "column"}, {Key: "←/→", Desc: "move"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "e/d/s/p/t/A", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "q", Desc: "quit"}, {Key: "S", Desc: "submit"}, @@ -168,9 +171,9 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, - {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, - {LeftKey: "q", LeftDesc: "quit", RightKey: "S", RightDesc: "submit"}, - {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "A", RightDesc: "change assignee"}, + {LeftKey: "x", LeftDesc: "delete issue", RightKey: "q", RightDesc: "quit"}, + {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, }, } default: diff --git a/pkg/tui/components/issue_detail.go b/pkg/tui/components/issue_detail.go index d01bde7..9d15b65 100644 --- a/pkg/tui/components/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -67,11 +67,15 @@ func (i *IssueDetail) refreshContent() { styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(PriorityCodeName(i.issue.Priority)), ) + assigneeRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Assignee:") + styles.ValueStyle.Render(i.issue.Assignee), + ) + descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) var parts []string - parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, assigneeRow, descLabel, descContent) // Comments section commentsLabel := styles.LabelStyle.Render("Comments:") diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index 2f237d4..ff920ef 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -54,7 +54,7 @@ func getTableColumns(width int) []tableColumn { {width: 20, label: "TITLE", key: "title"}, {width: 15, label: "STATUS", key: "status"}, } - default: + case width < 75: return []tableColumn{ {width: 12, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, @@ -62,6 +62,15 @@ func getTableColumns(width int) []tableColumn { {width: 10, label: "TYPE", key: "type"}, {width: 15, label: "PRIORITY", key: "priority"}, } + default: + return []tableColumn{ + {width: 12, label: "ID", key: "id"}, + {width: 20, label: "TITLE", key: "title"}, + {width: 15, label: "STATUS", key: "status"}, + {width: 11, label: "TYPE", key: "type"}, + {width: 14, label: "PRIORITY", key: "priority"}, + {width: 12, label: "ASSIGNEE", key: "assignee"}, + } } } @@ -99,11 +108,12 @@ func renderHeaders(cols []tableColumn) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } -// IssueInputs bundles the common text inputs used for issue title, creation, and description. +// IssueInputs bundles the common text inputs used for issue title, creation, description, and assignee. type IssueInputs struct { Title textinput.Model CreateTitle textinput.Model Description textarea.Model + Assignee textinput.Model } // NewIssueInputs creates initialized inputs for issue title, new issue title, and description. @@ -121,10 +131,15 @@ func NewIssueInputs() IssueInputs { descTa.SetWidth(56) descTa.SetHeight(8) + assigneeTi := textinput.New() + assigneeTi.Placeholder = "Assignee name..." + assigneeTi.CharLimit = 64 + return IssueInputs{ Title: ti, CreateTitle: createTi, Description: descTa, + Assignee: assigneeTi, } } @@ -414,6 +429,8 @@ func getColumnValue(col tableColumn, issue ListIssue) string { return string(issue.Issue.IssueType) case "priority": return PriorityCodeName(issue.Issue.Priority) + case "assignee": + return issue.Assignee default: return "" } diff --git a/pkg/tui/components/keymap.go b/pkg/tui/components/keymap.go index c75379a..89ce532 100644 --- a/pkg/tui/components/keymap.go +++ b/pkg/tui/components/keymap.go @@ -16,6 +16,7 @@ type CommonKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddIssue key.Binding DeleteIssue key.Binding } @@ -67,6 +68,10 @@ func DefaultCommonKeyMap() CommonKeyMap { key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddIssue: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add issue"), diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index 2171d73..c6f2e21 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -118,6 +118,22 @@ func RenderChoosePriority(width, height int, issueID string) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, priorityBox) } +func RenderEditAssignee(width, height int, inputView string) string { + if width < 5 || height < 5 { + return "" + } + editBoxWidth := modalBoxWidth(60, width) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), + inputView, + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) +} + func RenderChooseType(width, height int, issueID string) string { if width < 5 || height < 5 { return "" @@ -148,6 +164,7 @@ func RenderModals( choosingStatus bool, statusIssueID string, choosingPriority bool, priorityIssueID string, choosingType bool, typeIssueID string, + editingAssignee bool, assigneeInputView string, mainView string, ) string { if editingTitle { @@ -178,6 +195,10 @@ func RenderModals( return RenderChooseType(width, height, typeIssueID) } + if editingAssignee { + return RenderEditAssignee(width, height, assigneeInputView) + } + return mainView } diff --git a/pkg/tui/issues/operations.go b/pkg/tui/issues/operations.go index 01966fb..cf94913 100644 --- a/pkg/tui/issues/operations.go +++ b/pkg/tui/issues/operations.go @@ -15,6 +15,7 @@ type ( StatusUpdatedMsg struct{ IssueID string; Err error } PriorityUpdatedMsg struct{ IssueID string; Err error } TypeUpdatedMsg struct{ IssueID string; Err error } + AssigneeUpdatedMsg struct{ IssueID string; Err error } SelectIssueMsg struct{ IssueID string } CreatedMsg struct{ Issue *models.Issue; Err error } DeletedMsg struct{ IssueID string; Err error; PreviousIndex int } @@ -65,6 +66,15 @@ func UpdateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType } } +// UpdateIssueAssigneeCmd returns a command that updates an issue's assignee. +func UpdateIssueAssigneeCmd(app *app.App, issueID, assignee string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"assignee": assignee} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return AssigneeUpdatedMsg{IssueID: issueID, Err: err} + } +} + // CreateIssueCmd returns a command that creates a new issue. func CreateIssueCmd(app *app.App, title string) tea.Cmd { return func() tea.Msg { diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 105aa55..076b214 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -21,7 +21,7 @@ var ( ) const ( - ListViewRatio = 45 // Percentage of total width allocated to the list view + ListViewRatio = 52 // Percentage of total width allocated to the list view LabelWidth = 14 MarginBottomSmall = 1 ) diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index b2d830f..0793cb8 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -21,6 +21,7 @@ type DashboardKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddComment key.Binding AddIssue key.Binding DeleteIssue key.Binding @@ -56,6 +57,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddComment: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "add comment"), @@ -127,12 +132,18 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.startChooseType(selected) d.logAction("tui opened type picker") } + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + d.logAction("tui started editing assignee") + } case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startAddComment(selected) cmd = d.commentInput.Focus() } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() d.logAction("tui started creating issue") diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 84962d5..d386143 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -52,6 +52,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string addingComment bool // true while adding a comment commentInput textarea.Model commentIssueID string @@ -87,6 +90,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.titleInput = inputs.Title m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + m.assigneeInput = inputs.Assignee commentTa := textarea.New() commentTa.Placeholder = "Write your comment..." @@ -171,6 +175,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return components.ListenForValidation(m.feedbackChan) } @@ -178,7 +189,7 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType + m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 2ad7524..ca7ebf6 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -211,6 +211,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.AssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + m.logAction("tui failed to update issue assignee") + return m, nil + } + m.logAction("tui updated issue assignee") + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.SelectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) m.closedIssueList.SelectIssueID(msg.IssueID) @@ -444,6 +455,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + m.logAction("tui submitted assignee edit") + return m, issues.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) + } + if msg.String() == "esc" { + m.logAction("tui canceled assignee edit") + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingTitle { if msg.String() == "enter" { newTitle := m.titleInput.Value() diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index f32ac91..1612a36 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -61,6 +61,20 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + if m.editingAssignee { + editBoxWidth := min(60, m.width-4) + m.assigneeInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), + m.assigneeInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + if m.editingTitle { editBoxWidth := min(60, m.width-4) m.titleInput.Width = editBoxWidth - 2 diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index bcaa08c..db11527 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -86,7 +86,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.issueDetail.ScrollUp(1) case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() @@ -108,6 +108,11 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) } + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + } case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 6ead0ec..988438f 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -53,6 +53,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string feedbackChan chan models.ValidationFeedback quitChan chan bool submitChan chan<- struct{} @@ -89,6 +92,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.titleInput = inputs.Title m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + m.assigneeInput = inputs.Assignee if selected := m.todoList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) @@ -142,6 +146,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return components.ListenForValidation(m.feedbackChan) } @@ -149,7 +160,7 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType + m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index 8a0e040..cd6ae23 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -97,6 +97,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.AssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.SelectIssueMsg: m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) @@ -332,6 +341,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + return m, issues.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) + } + if msg.String() == "esc" { + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingTitle { if msg.String() == "enter" { newTitle := m.titleInput.Value() diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 34fb0da..0cff2c3 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -93,6 +93,8 @@ func (m *Model) View() string { m.priorityIssueID, m.choosingType, m.typeIssueID, + m.editingAssignee, + m.assigneeInput.View(), mainView, )