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