Skip to content

Commit b402b62

Browse files
committed
fix: add proper tree continuation lines in list and TUI views
- Add vertical continuation lines (│) for nested beans in tree display - Fix ancestry tracking to not draw lines for root-level items - Manually calculate visual width for Unicode box-drawing characters (lipgloss Width() doesn't handle multi-byte chars correctly) - Align title with root-level bean IDs by adjusting TitleBar padding
1 parent 6f8de83 commit b402b62

File tree

5 files changed

+70
-28
lines changed

5 files changed

+70
-28
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
title: Fix missing outer connecting lines in tree display
3+
status: completed
4+
type: bug
5+
priority: normal
6+
created_at: 2025-12-13T10:43:36Z
7+
updated_at: 2025-12-13T10:48:41Z
8+
---
9+
10+
In deeply nested bean structures, the tree display is missing vertical continuation lines for outer nesting levels. For example, when displaying a feature under an epic under a milestone, there should be a vertical line showing the connection back to the milestone level, but it's currently missing.

internal/tui/list.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func newListModel(resolver *graph.Resolver, cfg *config.Config) listModel {
130130
l.SetFilteringEnabled(true)
131131
l.SetShowHelp(false)
132132
l.Styles.Title = listTitleStyle
133-
l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2)
133+
l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 1)
134134
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
135135
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
136136

@@ -198,7 +198,7 @@ func (m listModel) loadBeans() tea.Msg {
198198
}
199199
}
200200
maxDepth := ui.MaxTreeDepth(items)
201-
// ID column = base ID width + tree indent (2 chars per depth level for depth > 0)
201+
// ID column = base ID width + tree indent (3 chars per depth level)
202202
idColWidth := maxIDLen + 2 // base padding
203203
if maxDepth > 0 {
204204
idColWidth += maxDepth * 3 // 3 chars per depth level (├─ + space)

internal/tui/tagpicker.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func newTagPickerModel(tags []tagWithCount, width, height int) tagPickerModel {
8383
l.SetFilteringEnabled(true)
8484
l.SetShowHelp(false)
8585
l.Styles.Title = listTitleStyle
86-
l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2)
86+
l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 1)
8787
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
8888
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(ui.ColorPrimary)
8989

internal/ui/styles.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,6 @@ func RenderBeanRow(id, status, typeName, title string, cfg BeanRowConfig) string
409409
if cfg.IDColWidth > 0 {
410410
idColWidth = cfg.IDColWidth
411411
}
412-
idStyle := lipgloss.NewStyle().Width(idColWidth)
413412
typeStyle := lipgloss.NewStyle().Width(ColWidthType)
414413
statusStyle := lipgloss.NewStyle().Width(ColWidthStatus)
415414

@@ -427,15 +426,22 @@ func RenderBeanRow(id, status, typeName, title string, cfg BeanRowConfig) string
427426
// Highlight style for marked rows
428427
highlightStyle := lipgloss.NewStyle().Foreground(ColorWarning)
429428

430-
// Build columns - apply dimming or highlight as needed
429+
// Build ID column with manual padding
430+
// (lipgloss Width() doesn't correctly handle Unicode box-drawing characters)
431431
var idCol string
432+
// Calculate visual width: tree prefix (in runes) + ID length
433+
visualWidth := len([]rune(cfg.TreePrefix)) + len(id)
434+
padding := ""
435+
if idColWidth > visualWidth {
436+
padding = strings.Repeat(" ", idColWidth-visualWidth)
437+
}
432438
if cfg.Dimmed {
433-
idCol = idStyle.Render(Muted.Render(cfg.TreePrefix) + Muted.Render(id))
439+
idCol = Muted.Render(cfg.TreePrefix) + Muted.Render(id) + padding
434440
} else if cfg.IsMarked {
435441
// Only highlight the ID when marked
436-
idCol = idStyle.Render(highlightStyle.Render(cfg.TreePrefix) + highlightStyle.Render(id))
442+
idCol = highlightStyle.Render(cfg.TreePrefix) + highlightStyle.Render(id) + padding
437443
} else {
438-
idCol = idStyle.Render(TreeLine.Render(cfg.TreePrefix) + ID.Render(id))
444+
idCol = TreeLine.Render(cfg.TreePrefix) + ID.Render(id) + padding
439445
}
440446

441447
var typeCol string

internal/ui/tree.go

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ func buildNodes(beans []*bean.Bean, children map[string][]*bean.Bean, matchedSet
151151
const (
152152
treeBranch = "├─ "
153153
treeLastBranch = "└─ "
154-
treePipe = "" // no continuation lines
155-
treeSpace = "" // no spacing for completed branches
156-
treeIndent = 3 // width of connector (├─ or └─ )
154+
treePipe = "│ " // vertical line for ongoing branches
155+
treeSpace = " " // empty space for completed branches
156+
treeIndent = 3 // width of connector
157157
)
158158

159159
// calculateMaxDepth returns the maximum depth of the tree.
@@ -217,26 +217,35 @@ func RenderTree(nodes []*TreeNode, cfg *config.Config, maxIDWidth int, hasTags b
217217
sb.WriteString(Muted.Render(strings.Repeat("─", dividerWidth)))
218218
sb.WriteString("\n")
219219

220-
// Render nodes (depth 0 = root level)
221-
renderNodes(&sb, nodes, 0, cfg, treeColWidth, hasTags)
220+
// Render nodes (depth 0 = root level, no ancestry yet)
221+
renderNodes(&sb, nodes, 0, nil, cfg, treeColWidth, hasTags)
222222

223223
return sb.String()
224224
}
225225

226226
// renderNodes recursively renders tree nodes with proper indentation.
227227
// depth 0 = root level (no connector), depth 1+ = nested (has connector)
228-
func renderNodes(sb *strings.Builder, nodes []*TreeNode, depth int, cfg *config.Config, treeColWidth int, hasTags bool) {
228+
// ancestry tracks whether each parent level was a last child (true = last, no continuation line needed)
229+
func renderNodes(sb *strings.Builder, nodes []*TreeNode, depth int, ancestry []bool, cfg *config.Config, treeColWidth int, hasTags bool) {
229230
for i, node := range nodes {
230231
isLast := i == len(nodes)-1
231-
renderNode(sb, node, depth, isLast, cfg, treeColWidth, hasTags)
232-
renderNodes(sb, node.Children, depth+1, cfg, treeColWidth, hasTags)
232+
renderNode(sb, node, depth, isLast, ancestry, cfg, treeColWidth, hasTags)
233+
// Only add to ancestry when depth > 0 (roots have no connectors to continue)
234+
if len(node.Children) > 0 {
235+
var newAncestry []bool
236+
if depth > 0 {
237+
newAncestry = append(ancestry, isLast)
238+
}
239+
renderNodes(sb, node.Children, depth+1, newAncestry, cfg, treeColWidth, hasTags)
240+
}
233241
}
234242
}
235243

236244
// renderNode renders a single tree node with tree connectors.
237245
// treeColWidth is the fixed width of the ID column (includes space for tree connectors).
238246
// depth 0 = root (no connector), depth 1+ = nested (has connector)
239-
func renderNode(sb *strings.Builder, node *TreeNode, depth int, isLast bool, cfg *config.Config, treeColWidth int, hasTags bool) {
247+
// ancestry tracks whether each parent level was a last child (true = last, no continuation line needed)
248+
func renderNode(sb *strings.Builder, node *TreeNode, depth int, isLast bool, ancestry []bool, cfg *config.Config, treeColWidth int, hasTags bool) {
240249
b := node.Bean
241250

242251
// Get status color from config
@@ -275,9 +284,13 @@ func renderNode(sb *strings.Builder, node *TreeNode, depth int, isLast bool, cfg
275284
var indent string
276285
var connector string
277286
if depth > 0 {
278-
// Add indentation for depth > 1 (3 spaces per level beyond first)
279-
if depth > 1 {
280-
indent = strings.Repeat(" ", depth-1)
287+
// Build indent from ancestry - each level adds either │ or space
288+
for _, wasLast := range ancestry {
289+
if wasLast {
290+
indent += treeSpace // parent was last child, no continuation line
291+
} else {
292+
indent += treePipe // parent has more siblings, show continuation line
293+
}
281294
}
282295
if isLast {
283296
connector = treeLastBranch
@@ -399,22 +412,28 @@ type FlatItem struct {
399412
// Each item includes the pre-computed tree prefix for rendering.
400413
func FlattenTree(nodes []*TreeNode) []FlatItem {
401414
var items []FlatItem
402-
flattenNodes(nodes, 0, &items)
415+
flattenNodes(nodes, 0, nil, &items)
403416
return items
404417
}
405418

406-
func flattenNodes(nodes []*TreeNode, depth int, items *[]FlatItem) {
419+
// flattenNodes recursively flattens tree nodes.
420+
// ancestry tracks whether each parent level was a last child (true = last, no continuation line needed)
421+
func flattenNodes(nodes []*TreeNode, depth int, ancestry []bool, items *[]FlatItem) {
407422
for i, node := range nodes {
408423
isLast := i == len(nodes)-1
409424

410425
// Compute tree prefix
411426
var prefix string
412427
if depth > 0 {
413-
// Add indentation for depth > 1 (3 spaces per level beyond first)
414-
if depth > 1 {
415-
prefix = strings.Repeat(" ", depth-1)
428+
// Build prefix from ancestry - each level adds either │ or space
429+
for _, wasLast := range ancestry {
430+
if wasLast {
431+
prefix += treeSpace // parent was last child, no continuation line
432+
} else {
433+
prefix += treePipe // parent has more siblings, show continuation line
434+
}
416435
}
417-
// Add connector
436+
// Add connector for this node
418437
if isLast {
419438
prefix += treeLastBranch
420439
} else {
@@ -430,8 +449,15 @@ func flattenNodes(nodes []*TreeNode, depth int, items *[]FlatItem) {
430449
TreePrefix: prefix,
431450
})
432451

433-
// Recurse into children
434-
flattenNodes(node.Children, depth+1, items)
452+
// Recurse into children, passing updated ancestry
453+
// Only add to ancestry when depth > 0 (roots have no connectors to continue)
454+
if len(node.Children) > 0 {
455+
var newAncestry []bool
456+
if depth > 0 {
457+
newAncestry = append(ancestry, isLast)
458+
}
459+
flattenNodes(node.Children, depth+1, newAncestry, items)
460+
}
435461
}
436462
}
437463

0 commit comments

Comments
 (0)