Skip to content

Commit fc1bdd9

Browse files
bricefclaude
andcommitted
Add task edit form with dependency and attachment management
- Full edit form accessible via 'e' from task detail view - Editable fields: title, description, priority, assignee, tags, due date - Priority and assignee use consistent styled rendering across all views - Dependencies: list/add/remove with searchable cross-board task selector - Attachments: list/add/remove with ref type cycling (url, file, git_*) - Reusable cycleSelector component for priority, dep type, and ref type - Comment prompt on transition/assign with auto-generated summary line - Richer audit detail rendering showing field-level changes - Styled priority and @me assignee in task detail view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5f7395b commit fc1bdd9

4 files changed

Lines changed: 1113 additions & 21 deletions

File tree

internal/tui/app.go

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ type Model struct {
7878
listView listViewModel
7979
workflowView workflowViewModel
8080
detail *detailModel // non-nil when detail overlay is open
81+
edit *editModel // non-nil when edit overlay is open
8182
transition *transitionModel // non-nil when transition overlay is open
8283
assign *assignModel // non-nil when assign overlay is open
8384
}
@@ -296,7 +297,11 @@ func (m *Model) openDetail() tea.Cmd {
296297
return nil
297298
}
298299

299-
m.detail = &detailModel{loading: true}
300+
currentName := ""
301+
if m.currentUser != nil {
302+
currentName = m.currentUser.Name
303+
}
304+
m.detail = &detailModel{loading: true, currentUserName: currentName}
300305
return fetchTaskDetail(m.client, boardSlug, num)
301306
}
302307

@@ -467,6 +472,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
467472
}
468473
}
469474

475+
// When edit overlay is open, delegate all keys to it.
476+
if m.edit != nil {
477+
if msg.String() == "ctrl+c" {
478+
return m, tea.Quit
479+
}
480+
closed, cmd := m.edit.update(msg, m.client)
481+
if closed {
482+
m.edit = nil
483+
}
484+
return m, cmd
485+
}
486+
470487
// When assign or transition overlays are open, delegate all keys
471488
// to the overlay (except ctrl+c) so the search filter works.
472489
if m.assign != nil {
@@ -500,6 +517,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
500517
case "esc", "backspace":
501518
if m.detail != nil {
502519
m.detail = nil
520+
m.edit = nil
503521
return m, nil
504522
}
505523
if m.view == viewBoard || m.view == viewMyTasks {
@@ -529,6 +547,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
529547
if (m.view == viewBoard || m.view == viewMyTasks) && m.assign == nil && m.transition == nil {
530548
return m, m.openAssignFromContext()
531549
}
550+
case "e":
551+
if m.detail != nil && m.detail.data != nil && !m.detail.commenting {
552+
data := m.detail.data
553+
em, cmd := newEdit(m.client, data.task.BoardSlug, data.task, m.currentUser, data.dependencies, data.attachments)
554+
m.edit = em
555+
return m, cmd
556+
}
532557
case "c":
533558
if m.detail != nil && m.detail.data != nil && !m.detail.commenting {
534559
m.detail.startComment()
@@ -598,6 +623,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
598623
m.myTasks.rebuild()
599624
return m, nil
600625

626+
case depAdded, depRemoved, depTasksLoaded, attachAdded, attachRemoved:
627+
if m.edit != nil {
628+
m.edit.update(msg, m.client)
629+
}
630+
return m, nil
631+
632+
case editResult:
633+
if m.edit != nil {
634+
if msg.err != nil {
635+
m.edit.err = msg.err.Error()
636+
return m, nil
637+
}
638+
m.edit = nil
639+
// Refetch detail to show updated data.
640+
if m.detail != nil {
641+
m.detail.loading = true
642+
m.detail.invalidateContent()
643+
return m, fetchTaskDetail(m.client, msg.task.BoardSlug, msg.task.Num)
644+
}
645+
}
646+
return m, nil
647+
601648
case boardSelected:
602649
m.activeBoard = &msg.board
603650
m.view = viewBoard
@@ -620,7 +667,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
620667
}
621668
return m, nil
622669

623-
case actorsLoaded, assignResult:
670+
case actorsLoaded:
671+
if m.assign != nil {
672+
_, cmd := m.assign.update(msg, m.client)
673+
return m, cmd
674+
}
675+
if m.edit != nil {
676+
m.edit.update(msg, m.client)
677+
}
678+
return m, nil
679+
680+
case assignResult:
624681
if m.assign != nil {
625682
closed, cmd := m.assign.update(msg, m.client)
626683
if closed {
@@ -629,8 +686,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
629686
return m, cmd
630687
}
631688
// "Take" action result (no overlay open).
632-
if result, ok := msg.(assignResult); ok && result.err != nil {
633-
m.lastError = result.err.Error()
689+
if msg.err != nil {
690+
m.lastError = msg.err.Error()
634691
}
635692
return m, nil
636693

@@ -807,7 +864,10 @@ func (m Model) myTasksView() string {
807864
b.WriteString("\n") // no tabs
808865

809866
// Content.
810-
if m.detail != nil && m.assign == nil && m.transition == nil {
867+
if m.edit != nil {
868+
m.viewport.SetContent(m.edit.view(m.viewport.Width))
869+
b.WriteString(m.viewport.View())
870+
} else if m.detail != nil && m.assign == nil && m.transition == nil {
811871
b.WriteString(m.detail.view(m.viewport.Width, m.viewport.Height))
812872
} else {
813873
var content string
@@ -866,10 +926,10 @@ func (m Model) boardView() string {
866926
}
867927

868928
// Tab content rendered into the viewport.
869-
// The workflow tab has its own viewport for independent scrolling.
870-
// Views with their own viewport render directly.
871-
// Others go through the shared viewport.
872-
if m.detail != nil && m.assign == nil && m.transition == nil {
929+
if m.edit != nil {
930+
m.viewport.SetContent(m.edit.view(m.viewport.Width))
931+
b.WriteString(m.viewport.View())
932+
} else if m.detail != nil && m.assign == nil && m.transition == nil {
873933
b.WriteString(m.detail.view(m.viewport.Width, m.viewport.Height))
874934
} else if m.activeTab == tabWorkflow && m.detail == nil && m.transition == nil && m.assign == nil {
875935
b.WriteString(m.workflowView.view(m.viewport.Width, m.viewport.Height))

internal/tui/detail.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ type commentPosted struct {
9393

9494
// detailModel is the task detail overlay.
9595
type detailModel struct {
96-
data *taskDetailData
97-
loading bool
98-
err error
99-
vp viewport.Model
100-
content string // cached rendered content
96+
data *taskDetailData
97+
loading bool
98+
err error
99+
vp viewport.Model
100+
content string // cached rendered content
101+
currentUserName string
101102
// Comment input
102103
commenting bool
103104
input textarea.Model
@@ -217,12 +218,21 @@ func (m detailModel) render(width, height int) string {
217218

218219
// Metadata fields.
219220
b.WriteString(field("State", t.State))
220-
b.WriteString(field("Priority", string(t.Priority)))
221-
assignee := "unassigned"
222-
if t.Assignee != nil {
223-
assignee = *t.Assignee
221+
prioStyled := string(t.Priority)
222+
if s, ok := priorityStyle[t.Priority]; ok {
223+
prioStyled = s.Render(prioStyled)
224+
}
225+
b.WriteString(styledField("Priority", prioStyled))
226+
var assigneeStyled string
227+
switch {
228+
case t.Assignee == nil:
229+
assigneeStyled = dimStyle.Render("unassigned")
230+
case m.currentUserName != "" && *t.Assignee == m.currentUserName:
231+
assigneeStyled = meStyle.Render("@me")
232+
default:
233+
assigneeStyled = *t.Assignee
224234
}
225-
b.WriteString(field("Assignee", assignee))
235+
b.WriteString(styledField("Assignee", assigneeStyled))
226236
if len(t.Tags) > 0 {
227237
b.WriteString(field("Tags", strings.Join(t.Tags, ", ")))
228238
}
@@ -342,6 +352,11 @@ func field(label, value string) string {
342352
return fmt.Sprintf("%s %s\n", detailFieldLabel.Render(label+":"), detailFieldValue.Render(value))
343353
}
344354

355+
// styledField renders a label with a pre-styled value (already contains ANSI).
356+
func styledField(label, styledValue string) string {
357+
return fmt.Sprintf("%s %s\n", detailFieldLabel.Render(label+":"), styledValue)
358+
}
359+
345360
// wrapLine word-wraps a single line to maxWidth, joining continuation lines
346361
// with the given indent prefix.
347362
func wrapLine(line string, maxWidth int, indent string) string {

0 commit comments

Comments
 (0)