Skip to content

Commit 739f8ff

Browse files
committed
feat(flowcli): dependency picker with add/remove and confirm
1 parent 96d6563 commit 739f8ff

7 files changed

Lines changed: 649 additions & 8 deletions

File tree

flowcli/internal/ui/dep.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package ui
2+
3+
import (
4+
"strings"
5+
6+
tea "github.com/charmbracelet/bubbletea"
7+
8+
"flowcli/internal/store"
9+
)
10+
11+
// depCand is one candidate blocker in the dependency-add search: cycle is true
12+
// when adding it would close a loop (the current node already reaches it).
13+
type depCand struct {
14+
node store.Node
15+
cycle bool
16+
}
17+
18+
// depRows returns the dependency edges touching a node, ordered blocked-by
19+
// first (X ← B) then blocks (X → D). The detail deps cursor indexes into this
20+
// flat list.
21+
func (m Model) depRows(id string) []store.Dependency {
22+
var out []store.Dependency
23+
for _, d := range m.deps {
24+
if d.BlockedID == id {
25+
out = append(out, d)
26+
}
27+
}
28+
for _, d := range m.deps {
29+
if d.BlockerID == id {
30+
out = append(out, d)
31+
}
32+
}
33+
return out
34+
}
35+
36+
// depRowLabel resolves how a dependency edge relates to the node: whether the
37+
// other end blocks the node or is blocked by it.
38+
func depRowLabel(d store.Dependency, id string) (dir, other string) {
39+
if d.BlockedID == id {
40+
return "blocked by", d.BlockerID
41+
}
42+
return "blocks", d.BlockedID
43+
}
44+
45+
// depRemoveLabel is the far-side node of an edge being removed, shown alone in
46+
// the remove-confirm dialog: the host node already names itself in the detail
47+
// header, so only the other endpoint is repeated here.
48+
func depRemoveLabel(m Model, d store.Dependency) string {
49+
_, other := depRowLabel(d, m.selectedID)
50+
if n, ok := m.byID[other]; ok {
51+
return other + " " + n.Title
52+
}
53+
return other
54+
}
55+
56+
// reaches reports whether start can reach target by following blocks edges.
57+
func (m Model) reaches(start, target string) bool {
58+
seen := map[string]bool{start: true}
59+
stack := []string{start}
60+
for len(stack) > 0 {
61+
cur := stack[len(stack)-1]
62+
stack = stack[:len(stack)-1]
63+
for _, next := range m.blocks[cur] {
64+
if next == target {
65+
return true
66+
}
67+
if !seen[next] {
68+
seen[next] = true
69+
stack = append(stack, next)
70+
}
71+
}
72+
}
73+
return false
74+
}
75+
76+
// buildDepCands lists project nodes as candidate blockers for id: id itself
77+
// and its existing direct blockers are excluded; nodes the current node
78+
// already reaches (adding them would close a cycle) are marked so the
79+
// renderer can dim and skip them.
80+
func (m *Model) buildDepCands(id string) {
81+
q := strings.ToLower(strings.TrimSpace(m.depQuery))
82+
m.depCands = m.depCands[:0]
83+
for _, n := range m.nodes {
84+
if n.ProjectID != m.projectID || n.ID == id {
85+
continue
86+
}
87+
dup := false
88+
for _, d := range m.deps {
89+
if d.BlockedID == id && d.BlockerID == n.ID {
90+
dup = true
91+
break
92+
}
93+
}
94+
if dup {
95+
continue
96+
}
97+
if q != "" && !strings.Contains(strings.ToLower(n.ID+" "+n.Title), q) {
98+
continue
99+
}
100+
m.depCands = append(m.depCands, depCand{node: n, cycle: m.reaches(id, n.ID)})
101+
}
102+
// land on the first addable (non-cycle) candidate
103+
for i, c := range m.depCands {
104+
if !c.cycle {
105+
m.depIdx = i
106+
break
107+
}
108+
}
109+
if m.depIdx >= m.depScroll+depVisible {
110+
m.depScroll = m.depIdx - depVisible + 1
111+
}
112+
}
113+
114+
// depSelect moves the cursor to the nearest addable (non-cycle) candidate in
115+
// dir (+1/-1), staying put when no addable candidate lies in that direction.
116+
func (m *Model) depSelect(dir int) {
117+
n := len(m.depCands)
118+
if n == 0 {
119+
return
120+
}
121+
for i := 1; i < n; i++ {
122+
j := m.depIdx + dir*i
123+
if j < 0 || j >= n {
124+
break
125+
}
126+
if !m.depCands[j].cycle {
127+
m.depIdx = j
128+
return
129+
}
130+
}
131+
}
132+
133+
// depVisible is how many candidate rows the dependency-add dialog shows.
134+
const depVisible = 6
135+
136+
// updateDepAdd is the search dialog for adding a dependency: type to filter
137+
// candidate blockers (title/id), up/down to move (cycle candidates are
138+
// skipped), enter to add, esc to cancel.
139+
func (m Model) updateDepAdd(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
140+
id := m.selectedID
141+
switch msg.String() {
142+
case "esc":
143+
m.overlay = OverlayNone
144+
m.input.Blur()
145+
return m, nil
146+
case "down", "ctrl+n":
147+
m.depSelect(1)
148+
if m.depIdx >= m.depScroll+depVisible {
149+
m.depScroll = m.depIdx - depVisible + 1
150+
}
151+
return m, nil
152+
case "up", "ctrl+p":
153+
m.depSelect(-1)
154+
if m.depIdx < m.depScroll {
155+
m.depScroll = m.depIdx
156+
}
157+
return m, nil
158+
case "enter":
159+
if m.depIdx < len(m.depCands) && !m.depCands[m.depIdx].cycle {
160+
cand := m.depCands[m.depIdx].node.ID
161+
m.overlay = OverlayNone
162+
m.input.Blur()
163+
return m, m.addDep(cand, id)
164+
}
165+
return m, nil
166+
}
167+
var cmd tea.Cmd
168+
m.input, cmd = m.input.Update(msg)
169+
m.depQuery = m.input.Value()
170+
m.buildDepCands(id)
171+
m.depIdx = 0
172+
m.depScroll = 0
173+
return m, cmd
174+
}
175+
176+
// addDep records blocker → blocked and refreshes.
177+
func (m Model) addDep(blocker, blocked string) tea.Cmd {
178+
return func() tea.Msg {
179+
if err := m.store.AddDependency(m.ctx, blocker, blocked); err != nil {
180+
return refreshedMsg{err: err}
181+
}
182+
return m.refresh()
183+
}
184+
}
185+
186+
// removeDep drops the blocker → blocked edge and refreshes.
187+
func (m Model) removeDep(blocker, blocked string) tea.Cmd {
188+
return func() tea.Msg {
189+
if err := m.store.RemoveDependency(m.ctx, blocker, blocked); err != nil {
190+
return refreshedMsg{err: err}
191+
}
192+
return m.refresh()
193+
}
194+
}
195+
196+
// updateDepRemove is the remove-edge confirm dialog: esc cancels (clears the
197+
// pending edge), y/enter removes it.
198+
func (m Model) updateDepRemove(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
199+
switch msg.String() {
200+
case "esc", "n", "q":
201+
m.depRemove = nil
202+
m.overlay = OverlayNone
203+
return m, nil
204+
case "y", "enter":
205+
d := m.depRemove
206+
m.depRemove = nil
207+
m.overlay = OverlayNone
208+
if d == nil {
209+
return m, nil
210+
}
211+
m.depFocus = false
212+
return m, m.removeDep(d.BlockerID, d.BlockedID)
213+
}
214+
return m, nil
215+
}

0 commit comments

Comments
 (0)