Skip to content

Commit 5549fe4

Browse files
julianknutsenclaude
andcommitted
Add TUI Phase 2C: pending state deltas with branch-as-transition model
Make pending state deltas a first-class TUI concept. The browse view shows * suffix on items with active branches, and the detail view displays a Pending line (e.g. "open → claimed") showing the delta between main and branch status. Users can discard branches to abandon pending changes. Apply (merge to local main) is available in wild-west mode but suppressed in PR mode where deltas resolve via upstream PR. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3fcdc36 commit 5549fe4

12 files changed

Lines changed: 657 additions & 14 deletions

File tree

internal/commons/dolt.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,19 @@ func DeleteBranch(dbDir, branch string) error {
234234
return doltSQLScript(dbDir, fmt.Sprintf("CALL DOLT_BRANCH('-D', '%s');", escaped))
235235
}
236236

237+
// DeleteRemoteBranch deletes a branch on a named remote using refspec syntax.
238+
func DeleteRemoteBranch(dbDir, remote, branch string) error {
239+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
240+
defer cancel()
241+
cmd := exec.CommandContext(ctx, "dolt", "push", remote, ":"+branch)
242+
cmd.Dir = dbDir
243+
output, err := cmd.CombinedOutput()
244+
if err != nil {
245+
return fmt.Errorf("dolt push %s :%s: %w (%s)", remote, branch, err, strings.TrimSpace(string(output)))
246+
}
247+
return nil
248+
}
249+
237250
// EnsureGitHubRemote adds a "github" Dolt remote pointing to the rig's
238251
// GitHub fork (e.g. https://github.com/alice-dev/wl-commons.git).
239252
// Idempotent: if "github" remote already exists, no-op.

internal/commons/lifecycle.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,18 @@ func TransitionRequiresInput(t Transition) string {
191191
}
192192
}
193193

194+
// DeltaLabel returns a human-readable label for a state delta.
195+
// Single-hop deltas map to transition names: "claim", "done", "reject".
196+
// Multi-hop or unrecognized deltas return "changes".
197+
func DeltaLabel(mainStatus, branchStatus string) string {
198+
for _, rule := range transitionRules {
199+
if rule.from == mainStatus && rule.to == branchStatus {
200+
return rule.name
201+
}
202+
}
203+
return "changes"
204+
}
205+
194206
// AvailableTransitions returns transitions valid for item that actor can perform.
195207
func AvailableTransitions(item *WantedItem, actor string) []Transition {
196208
if item == nil {

internal/commons/lifecycle_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,34 @@ func TestAvailableTransitions(t *testing.T) {
198198
}
199199
}
200200

201+
func TestDeltaLabel(t *testing.T) {
202+
tests := []struct {
203+
name string
204+
mainStatus string
205+
branchStatus string
206+
want string
207+
}{
208+
{"claim", "open", "claimed", "claim"},
209+
{"unclaim", "claimed", "open", "unclaim"},
210+
{"done", "claimed", "in_review", "done"},
211+
{"accept", "in_review", "completed", "accept"},
212+
{"reject", "in_review", "claimed", "reject"},
213+
{"delete", "open", "withdrawn", "delete"},
214+
{"multi-hop open to in_review", "open", "in_review", "changes"},
215+
{"multi-hop open to completed", "open", "completed", "changes"},
216+
{"same status", "open", "open", "update"},
217+
{"unrecognized", "completed", "open", "changes"},
218+
}
219+
for _, tc := range tests {
220+
t.Run(tc.name, func(t *testing.T) {
221+
got := DeltaLabel(tc.mainStatus, tc.branchStatus)
222+
if got != tc.want {
223+
t.Errorf("DeltaLabel(%q, %q) = %q, want %q", tc.mainStatus, tc.branchStatus, got, tc.want)
224+
}
225+
})
226+
}
227+
}
228+
201229
func TestResolvePushTarget_WildWest(t *testing.T) {
202230
loc := &ItemLocation{
203231
LocalStatus: "claimed",

internal/commons/queries.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,16 +309,21 @@ func QueryMyDashboard(dbDir, handle string) (*DashboardData, error) {
309309
}
310310

311311
// BrowseWantedBranchAware wraps BrowseWanted with branch overlay in PR mode.
312-
func BrowseWantedBranchAware(dbDir, mode, rigHandle string, f BrowseFilter) ([]WantedSummary, error) {
312+
// Returns the items and a map of wanted IDs that have active branches.
313+
func BrowseWantedBranchAware(dbDir, mode, rigHandle string, f BrowseFilter) ([]WantedSummary, map[string]bool, error) {
313314
items, err := BrowseWanted(dbDir, f)
314315
if err != nil {
315-
return nil, err
316+
return nil, nil, err
316317
}
318+
branchIDs := make(map[string]bool)
317319
if mode == "pr" {
318320
overrides := DetectBranchOverrides(dbDir, rigHandle)
321+
for _, o := range overrides {
322+
branchIDs[o.WantedID] = true
323+
}
319324
items = ApplyBranchOverrides(dbDir, items, overrides, f.Status)
320325
}
321-
return items, nil
326+
return items, branchIDs, nil
322327
}
323328

324329
// QueryFullDetail fetches a wanted item with all related records (completion, stamp).

internal/tui/browse.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
type browseModel struct {
1616
items []commons.WantedSummary
17+
branchIDs map[string]bool // wanted IDs with pending branch deltas
1718
cursor int
1819
statusIdx int // index into statusCycle
1920
typeIdx int // index into typeCycle
@@ -75,6 +76,7 @@ func (m *browseModel) setData(msg browseDataMsg) {
7576
m.loading = false
7677
m.err = msg.err
7778
m.items = msg.items
79+
m.branchIDs = msg.branchIDs
7880
if m.cursor >= len(m.items) {
7981
m.cursor = max(0, len(m.items)-1)
8082
}
@@ -324,6 +326,9 @@ func (m browseModel) view() string {
324326
}
325327
pri := colorizePriority(item.Priority)
326328
status := colorizeStatus(item.Status)
329+
if m.branchIDs[item.ID] {
330+
status += "*"
331+
}
327332
var line string
328333
if wide {
329334
line = fmt.Sprintf(" %-12s %-30s %-10s %-8s %-4s %-10s %-12s",

internal/tui/browse_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,38 @@ func TestBrowseFilter_ProjectFilter_EmptyWhenUnset(t *testing.T) {
344344
t.Errorf("filter Project should be empty, got %q", f.Project)
345345
}
346346
}
347+
348+
func TestBrowseView_BranchIndicator(t *testing.T) {
349+
m := newBrowseModel()
350+
m.loading = false
351+
m.width = 80
352+
m.height = 24
353+
m.items = []commons.WantedSummary{
354+
{ID: "w-abc123", Title: "Has branch", Status: "claimed", Priority: 1, Project: "proj", Type: "bug"},
355+
{ID: "w-def456", Title: "No branch", Status: "open", Priority: 2, Project: "proj", Type: "bug"},
356+
}
357+
m.branchIDs = map[string]bool{"w-abc123": true}
358+
359+
v := m.view()
360+
// The item with a branch should have * after its status.
361+
if !strings.Contains(v, "claimed*") {
362+
t.Errorf("view should contain 'claimed*' for branched item, got:\n%s", v)
363+
}
364+
// The item without a branch should not have *.
365+
// "open" should appear without * (we check it doesn't have "open*").
366+
if strings.Contains(v, "open*") {
367+
t.Errorf("view should NOT contain 'open*' for non-branched item, got:\n%s", v)
368+
}
369+
}
370+
371+
func TestBrowseSetData_StoresBranchIDs(t *testing.T) {
372+
m := newBrowseModel()
373+
branchIDs := map[string]bool{"w-abc123": true}
374+
m.setData(browseDataMsg{
375+
items: []commons.WantedSummary{{ID: "w-abc123", Status: "claimed"}},
376+
branchIDs: branchIDs,
377+
})
378+
if !m.branchIDs["w-abc123"] {
379+
t.Error("branchIDs should contain w-abc123")
380+
}
381+
}

internal/tui/detail.go

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ type confirmAction struct {
1717
label string
1818
}
1919

20+
// deltaConfirmAction holds state while waiting for the user to confirm a delta action.
21+
type deltaConfirmAction struct {
22+
action branchDeltaAction
23+
label string
24+
}
25+
2026
type detailModel struct {
2127
item *commons.WantedItem
2228
completion *commons.CompletionRecord
@@ -31,10 +37,12 @@ type detailModel struct {
3137
dbDir string
3238
rigHandle string
3339
mode string
34-
branch string // non-empty when showing branch state
35-
confirming *confirmAction // non-nil → showing confirmation prompt
36-
executing bool // true → showing spinner
37-
executingLabel string // e.g. "Claiming..."
40+
branch string // non-empty when showing branch state
41+
mainStatus string // status on main when showing branch state
42+
confirming *confirmAction // non-nil → showing confirmation prompt
43+
deltaConfirm *deltaConfirmAction // non-nil → showing delta confirmation prompt
44+
executing bool // true → showing spinner
45+
executingLabel string // e.g. "Claiming..."
3846
spinner spinner.Model
3947
result string // brief success/error message
4048
}
@@ -70,8 +78,10 @@ func (m *detailModel) setData(msg detailDataMsg) {
7078
m.completion = msg.completion
7179
m.stamp = msg.stamp
7280
m.branch = msg.branch
81+
m.mainStatus = msg.mainStatus
7382
// Clear mutation state so stale results don't mask action hints.
7483
m.confirming = nil
84+
m.deltaConfirm = nil
7585
m.executing = false
7686
m.executingLabel = ""
7787
m.result = ""
@@ -113,6 +123,22 @@ func (m detailModel) update(msg bubbletea.Msg) (detailModel, bubbletea.Cmd) {
113123
return m, nil
114124
}
115125

126+
// Delta confirmation prompt active: handle y/n/esc only.
127+
if m.deltaConfirm != nil {
128+
switch {
129+
case key.Matches(msg, keys.Confirm):
130+
a := m.deltaConfirm.action
131+
m.deltaConfirm = nil
132+
return m, func() bubbletea.Msg {
133+
return deltaConfirmedMsg{action: a}
134+
}
135+
case key.Matches(msg, keys.Cancel), key.Matches(msg, keys.Back):
136+
m.deltaConfirm = nil
137+
return m, nil
138+
}
139+
return m, nil
140+
}
141+
116142
// Normal key handling.
117143
switch {
118144
case key.Matches(msg, keys.Back):
@@ -139,6 +165,12 @@ func (m detailModel) update(msg bubbletea.Msg) (detailModel, bubbletea.Cmd) {
139165
return m.tryAction(commons.TransitionClose)
140166
case key.Matches(msg, keys.Delete):
141167
return m.tryAction(commons.TransitionDelete)
168+
169+
// Delta resolution keys.
170+
case key.Matches(msg, keys.Apply):
171+
return m.tryDelta(deltaApply)
172+
case key.Matches(msg, keys.Discard):
173+
return m.tryDelta(deltaDiscard)
142174
}
143175
}
144176

@@ -187,6 +219,28 @@ func (m detailModel) tryTextAction(t commons.Transition) (detailModel, bubbletea
187219
return m, nil
188220
}
189221

222+
// tryDelta validates that a branch exists, computes a label, and returns a deltaRequestMsg.
223+
func (m detailModel) tryDelta(action branchDeltaAction) (detailModel, bubbletea.Cmd) {
224+
if m.branch == "" || m.item == nil {
225+
return m, nil
226+
}
227+
// Apply is not available in PR mode — deltas resolve via upstream PR merge.
228+
if action == deltaApply && m.mode == "pr" {
229+
return m, nil
230+
}
231+
delta := commons.DeltaLabel(m.mainStatus, m.item.Status)
232+
var label string
233+
switch action {
234+
case deltaApply:
235+
label = fmt.Sprintf("Apply %s to main? Pushes to origin. [y/n]", delta)
236+
case deltaDiscard:
237+
label = fmt.Sprintf("Discard %s? Reverts to %s. Deletes local + remote branch. [y/n]", delta, m.mainStatus)
238+
}
239+
return m, func() bubbletea.Msg {
240+
return deltaRequestMsg{action: action, label: label}
241+
}
242+
}
243+
190244
func (m detailModel) view() string {
191245
if m.loading {
192246
return styleDim.Render(" Loading...")
@@ -207,6 +261,9 @@ func (m detailModel) renderContent() string {
207261
var b strings.Builder
208262

209263
b.WriteString(fmt.Sprintf("\n Status: %s\n", colorizeStatus(item.Status)))
264+
if m.branch != "" && m.mainStatus != "" && m.mainStatus != item.Status {
265+
b.WriteString(fmt.Sprintf(" Pending: %s → %s\n", m.mainStatus, item.Status))
266+
}
210267
if m.branch != "" {
211268
b.WriteString(styleDim.Render(fmt.Sprintf(" Branch: %s\n", m.branch)))
212269
}
@@ -278,6 +335,8 @@ func (m detailModel) renderContent() string {
278335
case m.confirming != nil:
279336
b.WriteString(styleConfirm.Render(fmt.Sprintf(
280337
" %s Pushes to upstream. [y/n]", m.confirming.label)))
338+
case m.deltaConfirm != nil:
339+
b.WriteString(styleConfirm.Render(fmt.Sprintf(" %s", m.deltaConfirm.label)))
281340
case m.executing:
282341
b.WriteString(fmt.Sprintf(" %s %s", m.spinner.View(), m.executingLabel))
283342
case m.result != "":
@@ -308,9 +367,6 @@ func (m detailModel) actionHints() string {
308367
return ""
309368
}
310369
available := commons.AvailableTransitions(m.item, m.rigHandle)
311-
if len(available) == 0 {
312-
return " (no actions available)"
313-
}
314370
var hints []string
315371
for _, t := range available {
316372
k := transitionKeyHint[t]
@@ -321,5 +377,24 @@ func (m detailModel) actionHints() string {
321377
}
322378
hints = append(hints, hint)
323379
}
380+
381+
// Delta actions: only when a branch exists with a pending delta.
382+
if m.branch != "" && m.mainStatus != "" && m.mainStatus != m.item.Status {
383+
delta := commons.DeltaLabel(m.mainStatus, m.item.Status)
384+
var deltaHints []string
385+
if m.mode != "pr" {
386+
// Apply only in wild-west mode — PR mode resolves via upstream PR.
387+
deltaHints = append(deltaHints, fmt.Sprintf("M:apply %s", delta))
388+
}
389+
deltaHints = append(deltaHints, fmt.Sprintf("b:discard (→ %s)", m.mainStatus))
390+
if len(hints) > 0 {
391+
hints = append(hints, "|")
392+
}
393+
hints = append(hints, deltaHints...)
394+
}
395+
396+
if len(hints) == 0 {
397+
return " (no actions available)"
398+
}
324399
return " Actions: " + strings.Join(hints, " ")
325400
}

0 commit comments

Comments
 (0)