Skip to content

Commit d7e86b2

Browse files
bomly-guyclaude
andcommitted
feat(tui): project node describes the target; root node owns the manifest
- The PROJECT node's details pane now describes the scan target only: the target info plus a Manifests section listing everything found inside it (root manifests with their module nodes nested, subprojects with theirs). The root manifest's Manifest/Detector/Dependencies sections no longer leak onto the project node. - The merged project's ROOT component row owns the root manifest: its details pane carries the component sections, the full manifest and detector metadata, and a Modules section listing the module nodes branching out of it. - Dropped the inaccurate "Press Enter to view components for this manifest." hint from manifest details (tree nodes expand with →). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 821eb09 commit d7e86b2

3 files changed

Lines changed: 96 additions & 17 deletions

File tree

internal/tui/scan.go

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -521,10 +521,6 @@ func (m *ScanModel) buildManifestListModel() *listModel {
521521
rootManifests, groups := m.manifestTreeGroups()
522522
projectMerged := len(rootManifests) == 1
523523
projectDetailLines := projectDetails(m, packageCount(m.graphValue), packageCount(m.graphValue))
524-
if projectMerged {
525-
projectDetailLines = append(projectDetailLines, "")
526-
projectDetailLines = append(projectDetailLines, manifestDetails(m.graphValue, m.manifests[rootManifests[0]])...)
527-
}
528524
items = append(items, listItem{
529525
title: m.projectNodeTitle(groups),
530526
subtitle: "project",
@@ -656,10 +652,6 @@ func (m *ScanModel) buildComponentsTreeListModel() *listModel {
656652
projectKey := "project"
657653
projectExpanded := expandedValue(m.componentExpanded, projectKey, true)
658654
projectDetailLines := projectDetails(m, filteredComponentCount, totalComponents)
659-
if projectMerged {
660-
projectDetailLines = append(projectDetailLines, "")
661-
projectDetailLines = append(projectDetailLines, manifestDetails(m.graphValue, m.manifests[rootManifests[0]])...)
662-
}
663655
items = append(items, listItem{
664656
title: m.projectNodeTitle(groups),
665657
subtitle: "project",
@@ -792,7 +784,7 @@ func (m *ScanModel) buildComponentsTreeListModel() *listModel {
792784
title: row.displayName,
793785
subtitle: row.relationship,
794786
badges: badges,
795-
details: componentDetails(m.graphValue, m.registry, row, manifest),
787+
details: m.rootNodeDetails(row, manifest, attached),
796788
key: row.id,
797789
tree: treeLevelPrefix(ancestorsLast) + treeConnector(last),
798790
depth: depth,
@@ -989,7 +981,7 @@ func (m *ScanModel) projectNodeTitle(groups []*manifestTreeGroup) string {
989981
}
990982

991983
func projectDetails(m *ScanModel, filteredComponents, totalComponents int) []string {
992-
return []string{
984+
lines := []string{
993985
render.Style("Project", render.Bold, render.Cyan),
994986
"",
995987
render.Style(" Name: ", render.Dim) + valueOrDash(m.project.Name),
@@ -998,6 +990,54 @@ func projectDetails(m *ScanModel, filteredComponents, totalComponents int) []str
998990
render.Style(" Manifests: ", render.Dim) + fmt.Sprintf("%d", len(m.manifests)),
999991
render.Style(" Components: ", render.Dim) + fmt.Sprintf("%d of %d", filteredComponents, totalComponents),
1000992
}
993+
if contents := m.projectContentsLines(); len(contents) > 0 {
994+
lines = append(lines, "", render.Style("Manifests", render.Bold, render.Cyan))
995+
lines = append(lines, contents...)
996+
}
997+
return lines
998+
}
999+
1000+
// projectContentsLines lists what the scan found inside the target: its
1001+
// manifests with the module and subproject nodes nested beneath the manifest
1002+
// that resolves them.
1003+
func (m *ScanModel) projectContentsLines() []string {
1004+
rootManifests, groups := m.manifestTreeGroups()
1005+
var lines []string
1006+
groupLine := func(group *manifestTreeGroup, indent string) string {
1007+
name := group.label
1008+
suffix := ""
1009+
if len(group.manifests) > 0 {
1010+
manifest := m.manifests[group.manifests[0]]
1011+
if rootName := m.manifestRootName(manifest); rootName != "" && rootName != manifest.displayName {
1012+
name = rootName
1013+
}
1014+
suffix = " [" + manifest.id + "]"
1015+
}
1016+
return render.Style(indent, render.Dim) + name + render.Style(" ("+string(group.kind)+")"+suffix, render.Dim)
1017+
}
1018+
attachedByManifest := map[int][]*manifestTreeGroup{}
1019+
var unattached []*manifestTreeGroup
1020+
for _, group := range groups {
1021+
if group.kind == output.ManifestNodeModule && group.attachedTo >= 0 {
1022+
attachedByManifest[group.attachedTo] = append(attachedByManifest[group.attachedTo], group)
1023+
continue
1024+
}
1025+
unattached = append(unattached, group)
1026+
}
1027+
for _, index := range rootManifests {
1028+
manifest := m.manifests[index]
1029+
lines = append(lines, render.Style(" ", render.Dim)+manifest.id+render.Style(" ("+valueOrDash(manifest.packageManagers)+")", render.Dim))
1030+
for _, group := range attachedByManifest[index] {
1031+
lines = append(lines, groupLine(group, " "))
1032+
}
1033+
}
1034+
for _, group := range unattached {
1035+
lines = append(lines, groupLine(group, " "))
1036+
for _, child := range group.children {
1037+
lines = append(lines, groupLine(child, " "))
1038+
}
1039+
}
1040+
return lines
10011041
}
10021042

10031043
func packageCount(graphValue *sdk.Graph) int {
@@ -2715,9 +2755,6 @@ func manifestDetails(graphValue *sdk.Graph, row listPackageRow) []string {
27152755
render.Style(" Root (project package): ", render.Dim) + packageDisplayName(rootPkg),
27162756
render.Style(" Direct dependencies: ", render.Dim) + fmt.Sprintf("%d", len(groups.direct)),
27172757
render.Style(" Transitive dependencies: ", render.Dim) + fmt.Sprintf("%d", len(groups.transitive)),
2718-
"",
2719-
render.Style("Press Enter to view components for this manifest.", render.Dim),
2720-
"",
27212758
}
27222759
return lines
27232760
}

internal/tui/scan_hierarchy.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,31 @@ func (m *ScanModel) mergedGroupDetails(group *manifestTreeGroup, manifest listPa
253253
return append(lines, manifestDetails(m.graphValue, manifest)...)
254254
}
255255

256+
// rootNodeDetails renders the details pane for the merged project's ROOT
257+
// component row: the root component itself, the root manifest it stands on,
258+
// and the module nodes branching out of it.
259+
func (m *ScanModel) rootNodeDetails(row listPackageRow, manifest listPackageRow, attached []*manifestTreeGroup) []string {
260+
lines := componentDetails(m.graphValue, m.registry, row, manifest)
261+
lines = append(lines, "")
262+
lines = append(lines, manifestDetails(m.graphValue, manifest)...)
263+
if len(attached) > 0 {
264+
lines = append(lines, "", render.Style("Modules", render.Bold, render.Cyan))
265+
for _, group := range attached {
266+
name := group.label
267+
suffix := ""
268+
if len(group.manifests) > 0 {
269+
moduleManifest := m.manifests[group.manifests[0]]
270+
if rootName := m.manifestRootName(moduleManifest); rootName != "" && rootName != moduleManifest.displayName {
271+
name = rootName
272+
}
273+
suffix = " [" + moduleManifest.id + "]"
274+
}
275+
lines = append(lines, render.Style(" ", render.Dim)+name+render.Style(suffix, render.Dim))
276+
}
277+
}
278+
return lines
279+
}
280+
256281
func sortedKeyList(values map[string]struct{}) string {
257282
keys := make([]string, 0, len(values))
258283
for key := range values {

internal/tui/tui_test.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -751,12 +751,27 @@ func TestScanInteractiveModel_ManifestDetailsIncludeDetectorMetadata(t *testing.
751751
model := NewScan(output.ProjectDescriptor{Name: "demo-app", Path: "/tmp/demo-app"}, consolidated, graphValue, nil)
752752
model.SelectView(2)
753753

754-
// The single root manifest is merged into the project node, so the
755-
// project details pane carries the manifest and detector metadata.
754+
// The project node describes the scan target and lists its manifests;
755+
// manifest and detector metadata belong to the ROOT component's details.
756756
plain := render.StripANSI(model.View(110, 40))
757+
for _, want := range []string{"Manifests", "package-lock.json (npm)"} {
758+
if !strings.Contains(plain, want) {
759+
t.Fatalf("expected project details to list manifests, got:\n%s", want+"\n"+plain)
760+
}
761+
}
762+
if strings.Contains(plain, "Planned chain:") {
763+
t.Fatalf("project details must not carry detector metadata, got:\n%s", plain)
764+
}
765+
766+
// Selecting the ROOT component row surfaces the manifest + detector
767+
// sections.
768+
wrapper := &teaModel{inner: model, width: 130, height: 60}
769+
updated, _ := wrapper.Update(tea.KeyMsg{Type: tea.KeyDown})
770+
wrapper = updated.(*teaModel)
771+
plain = render.StripANSI(wrapper.View())
757772
for _, want := range []string{"Detector", "Name: npm-detector", "Package managers: npm", "Planned chain: npm-detector, syft-detector"} {
758773
if !strings.Contains(plain, want) {
759-
t.Fatalf("expected manifest details to contain %q, got:\n%s", want, plain)
774+
t.Fatalf("expected root component details to contain %q, got:\n%s", want, plain)
760775
}
761776
}
762777
}
@@ -1910,7 +1925,9 @@ func TestScanInteractiveModel_ModulesBranchFromParentRoot(t *testing.T) {
19101925
model.componentExpanded[model.manifests[0].rootID] = false
19111926
model.Rebuild()
19121927
plain = render.StripANSI(model.View(190, 40))
1913-
if strings.Contains(plain, "core (") {
1928+
// The module tree node ("core (maven, ...)") disappears; the project
1929+
// details pane still lists the module as content.
1930+
if strings.Contains(plain, "core (maven") {
19141931
t.Fatalf("expected collapsed parent root to hide module nodes, got:\n%s", plain)
19151932
}
19161933
}

0 commit comments

Comments
 (0)