Skip to content

Commit 853fe75

Browse files
authored
Add package-level feature scoping for the navigation graph (#19) (#22)
Lets a single-module app organized by feature packages view and export the graph one feature at a time. - Gradle: a new -Pnavgraph.export.package=<prefix> property on exportNavGraphImage/Html exports only the destinations under a package (matched by route or screen FQN), keeping internal edges and dropping cross-feature ones. - IDE: a "Feature:" tool window selector, auto-discovered from the package layout, slices the graph by feature; the same selection scopes the export so the file matches what is on screen. - Sample: feature-packaged screens (catalog, cart, orders) in :sample demonstrate the selector and the internal vs cross-feature edge behavior. - Docs: the export and graph pages document the flag and the selector.
1 parent a5fbdc6 commit 853fe75

14 files changed

Lines changed: 1104 additions & 9 deletions

File tree

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/ExportNavGraphHtmlTask.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ public abstract class ExportNavGraphHtmlTask : DefaultTask() {
6161
@get:Optional
6262
public abstract val deviceSpec: Property<String>
6363

64+
/** "" = the whole graph; else a package prefix (e.g. "com.app.feature.feed") to export only that feature's
65+
* subgraph (nodes whose route/screen FQN is under it, plus their internal edges). */
66+
@get:Input
67+
@get:Optional
68+
public abstract val packageFilter: Property<String>
69+
6470
@get:OutputFile
6571
public abstract val outputHtml: RegularFileProperty
6672

@@ -73,7 +79,11 @@ public abstract class ExportNavGraphHtmlTask : DefaultTask() {
7379
@TaskAction
7480
public fun export() {
7581
val manifestFile = manifest.get().asFile
76-
val graph = parseGraph(manifestFile.readText())
82+
val pkg = packageFilter.orNull.orEmpty()
83+
val graph = filterGraphByPackage(parseGraph(manifestFile.readText()), pkg)
84+
if (pkg.isNotBlank() && graph.nodes.isEmpty()) {
85+
logger.warn("navgraph: no destinations under package '$pkg'; exporting an empty graph.")
86+
}
7787
val thumbsRoot: File? = thumbsDir.orNull?.asFile
7888
val device = parseDevice(deviceSpec.orNull)
7989
// "Generated" date = the graph data's timestamp (manifest mtime), not now() — keeps the output

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/ExportNavGraphImageTask.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ public abstract class ExportNavGraphImageTask : DefaultTask() {
6767
@get:Optional
6868
public abstract val deviceSpec: Property<String>
6969

70+
/** "" = the whole graph; else a package prefix (e.g. "com.app.feature.feed") to export only that feature's
71+
* subgraph (nodes whose route/screen FQN is under it, plus their internal edges). */
72+
@get:Input
73+
@get:Optional
74+
public abstract val packageFilter: Property<String>
75+
7076
/** Supersampling factor — the image is rendered at this multiple and `g2.scale(scale)`d for crisp hi-DPI. */
7177
@get:Input
7278
@get:Optional
@@ -78,7 +84,11 @@ public abstract class ExportNavGraphImageTask : DefaultTask() {
7884
@TaskAction
7985
public fun export() {
8086
val manifestFile = manifest.get().asFile
81-
val graph = parseGraph(manifestFile.readText())
87+
val pkg = packageFilter.orNull.orEmpty()
88+
val graph = filterGraphByPackage(parseGraph(manifestFile.readText()), pkg)
89+
if (pkg.isNotBlank() && graph.nodes.isEmpty()) {
90+
logger.warn("navgraph: no destinations under package '$pkg'; exporting an empty graph.")
91+
}
8292
val thumbsRoot: File? = thumbsDir.orNull?.asFile
8393
val device = parseDevice(deviceSpec.orNull)
8494
val s = (scale.orNull ?: DEFAULT_SCALE).coerceIn(1, 8)

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/NavGraphGradlePlugin.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ public class NavGraphGradlePlugin : Plugin<Project> {
575575
manifest.set(graphSourceDir.map { it.file("nav-graph.json") })
576576
this.thumbsDir.set(graphSourceDir.map { it.dir("thumbs") })
577577
deviceSpec.set(providers.gradleProperty("navgraph.export.device").orElse(""))
578+
packageFilter.set(providers.gradleProperty("navgraph.export.package").orElse(""))
578579
outputHtml.set(
579580
layout.file(providers.gradleProperty("navgraph.export.out").map { File(it) })
580581
.orElse(navgraphDir.map { it.file("nav-graph.html") }),
@@ -589,6 +590,7 @@ public class NavGraphGradlePlugin : Plugin<Project> {
589590
manifest.set(graphSourceDir.map { it.file("nav-graph.json") })
590591
this.thumbsDir.set(graphSourceDir.map { it.dir("thumbs") })
591592
deviceSpec.set(providers.gradleProperty("navgraph.export.device").orElse(""))
593+
packageFilter.set(providers.gradleProperty("navgraph.export.package").orElse(""))
592594
scale.set(providers.gradleProperty("navgraph.export.scale").map { it.toInt() })
593595
outputImage.set(
594596
layout.file(providers.gradleProperty("navgraph.export.out").map { File(it) })

compose-nav-graph-gradle/src/main/kotlin/com/github/skydoves/navgraph/gradle/NavManifest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ internal data class HPreviewParam(val name: String = "", val provider: String =
7575

7676
internal data class HEdge(val from: String = "", val to: String = "", val label: String? = null)
7777

78+
/** Restrict [graph] to a feature package [prefix] (e.g. `com.app.feature.feed`): keep nodes whose route FQN
79+
* (`id`) or screen FQN (`clickTargetFqn`) is under that package, plus edges with both endpoints kept. A blank
80+
* prefix returns [graph] unchanged. Backs the `-Pnavgraph.export.package=` filter for single-module apps that
81+
* organize screens by feature package. */
82+
internal fun filterGraphByPackage(graph: HGraph, prefix: String): HGraph {
83+
if (prefix.isBlank()) return graph
84+
fun underPackage(fqn: String?): Boolean =
85+
fqn != null && (fqn == prefix || fqn.startsWith("$prefix."))
86+
val nodes = graph.nodes.filter { underPackage(it.id) || underPackage(it.clickTargetFqn) }
87+
val ids = nodes.mapTo(HashSet()) { it.id }
88+
return graph.copy(nodes = nodes, edges = graph.edges.filter { it.from in ids && it.to in ids })
89+
}
90+
7891
internal fun parseGraph(text: String): HGraph {
7992
val root = Json.parseToJsonElement(text).jsonObject
8093
val nodes = root.arr("nodes").map { el ->

compose-nav-graph-gradle/src/test/kotlin/com/github/skydoves/navgraph/gradle/NavManifestParseTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,52 @@ import org.junit.Test
2323

2424
class NavManifestParseTest {
2525

26+
@Test
27+
fun filterByPackage_keepsNodesUnderPrefixAndDropsCrossFeatureEdges() {
28+
val graph = HGraph(
29+
nodes = listOf(
30+
HNode(id = "app.feed.FeedRoute", clickTargetFqn = "app.feed.FeedScreen"),
31+
HNode(id = "app.feed.detail.DetailRoute"),
32+
HNode(id = "app.profile.ProfileRoute"),
33+
),
34+
edges = listOf(
35+
HEdge(from = "app.feed.FeedRoute", to = "app.feed.detail.DetailRoute"),
36+
HEdge(from = "app.feed.FeedRoute", to = "app.profile.ProfileRoute"),
37+
),
38+
)
39+
40+
val feed = filterGraphByPackage(graph, "app.feed")
41+
42+
assertEquals(
43+
listOf("app.feed.FeedRoute", "app.feed.detail.DetailRoute"),
44+
feed.nodes.map {
45+
it.id
46+
},
47+
)
48+
assertEquals(1, feed.edges.size)
49+
assertEquals("app.feed.detail.DetailRoute", feed.edges.single().to)
50+
}
51+
52+
@Test
53+
fun filterByPackage_matchesViaScreenFqnAndIgnoresPartialSegments() {
54+
val graph = HGraph(
55+
nodes = listOf(
56+
HNode(id = "app.routes.SearchRoute", clickTargetFqn = "app.feed.SearchScreen"),
57+
HNode(id = "app.feedback.FeedbackRoute"),
58+
),
59+
)
60+
61+
// Kept via clickTargetFqn; `feedback` is NOT under `feed` (segment boundary).
62+
val kept = filterGraphByPackage(graph, "app.feed").nodes.map { it.id }
63+
assertEquals(listOf("app.routes.SearchRoute"), kept)
64+
}
65+
66+
@Test
67+
fun filterByPackage_blankPrefixReturnsUnchanged() {
68+
val graph = HGraph(nodes = listOf(HNode(id = "app.A")))
69+
assertEquals(graph, filterGraphByPackage(graph, ""))
70+
}
71+
2672
@Test
2773
fun parsesNodesEdgesArgsAndStart() {
2874
val json = """
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Designed and developed by 2026 skydoves (Jaewoong Eum)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.github.skydoves.navgraph.idea
17+
18+
import com.github.skydoves.navgraph.idea.model.NavGraphDto
19+
import com.github.skydoves.navgraph.idea.model.NavNodeDto
20+
21+
/**
22+
* Splits one graph into **feature groups by package** — the within-module counterpart of [NavScopeGrouping]
23+
* (which groups by Gradle module). For apps that keep every screen in a single `:app` module and organize them
24+
* by feature package (`feature.feed`, `feature.profile`, …), this powers the tool window's "Feature:" selector
25+
* so the user can view/export one feature's subgraph at a time. No IntelliJ types, so it is unit-testable, and
26+
* the filter mirrors the Gradle `filterGraphByPackage` exactly so the on-screen view and an export agree.
27+
*
28+
* **Auto-discovery (no configuration).** Anchor at the longest package prefix every destination shares (the
29+
* common root, e.g. `com.app.feature`), then bucket by the single segment that follows it (`feed`, `profile`).
30+
* Screens deeper in a feature (`com.app.feature.feed.detail.DetailScreen`) fold into that feature. When fewer
31+
* than two buckets emerge there is nothing to choose between, so [computeFeatures] returns empty and the caller
32+
* hides the selector. A destination sitting directly under the common root (no further segment) has no feature
33+
* of its own — it only appears under "All features".
34+
*/
35+
internal object NavFeatureGrouping {
36+
37+
/** @property name the segment shown in the selector; @property packagePrefix what [filterByPackage] keys on. */
38+
data class Feature(val name: String, val packagePrefix: String)
39+
40+
/**
41+
* The feature groups discovered in [graph], sorted by name. Empty when fewer than two distinct features
42+
* exist (single-feature or flat apps) — the selector is then pointless and stays hidden.
43+
*/
44+
fun computeFeatures(graph: NavGraphDto): List<Feature> {
45+
val packages = graph.nodes.mapNotNull {
46+
it.featureFqn()?.let(::packageOf)
47+
}.filter { it.isNotBlank() }
48+
if (packages.size < 2) return emptyList()
49+
50+
val common = commonPackagePrefix(packages)
51+
val rootDepth = if (common.isEmpty()) 0 else common.split('.').size
52+
// The segment right after the shared root is the feature; a package with nothing past the root contributes
53+
// no feature key (those destinations live only under "All features").
54+
val keys = LinkedHashSet<String>()
55+
for (pkg in packages) {
56+
val segments = pkg.split('.')
57+
if (segments.size > rootDepth) keys.add(segments[rootDepth])
58+
}
59+
if (keys.size < 2) return emptyList()
60+
61+
return keys.sorted().map { key ->
62+
Feature(name = key, packagePrefix = if (common.isEmpty()) key else "$common.$key")
63+
}
64+
}
65+
66+
/**
67+
* Restrict [graph] to the destinations under [prefix] — a node is kept when its route FQN ([NavNodeDto.id])
68+
* OR its screen FQN ([NavNodeDto.clickTargetFqn]) is the prefix or nested under it, and an edge survives only
69+
* when both endpoints do. A blank [prefix] returns [graph] unchanged. Byte-for-byte the Gradle export filter.
70+
*/
71+
fun filterByPackage(graph: NavGraphDto, prefix: String): NavGraphDto {
72+
if (prefix.isBlank()) return graph
73+
fun underPackage(fqn: String?): Boolean =
74+
fqn != null && (fqn == prefix || fqn.startsWith("$prefix."))
75+
val nodes = graph.nodes.filter { underPackage(it.id) || underPackage(it.clickTargetFqn) }
76+
val ids = nodes.mapTo(HashSet()) { it.id }
77+
return graph.copy(nodes = nodes, edges = graph.edges.filter { it.from in ids && it.to in ids })
78+
}
79+
80+
/** The screen FQN drives the feature (it is where the code lives); the route id is the fallback when the
81+
* screen FQN is absent OR blank (a hand-authored manifest may carry an empty `clickTargetFqn`). */
82+
private fun NavNodeDto.featureFqn(): String? =
83+
(clickTargetFqn?.ifBlank { null } ?: id).ifBlank { null }
84+
85+
/** Package of a fully-qualified name: everything before the final segment (the simple type name). */
86+
private fun packageOf(fqn: String): String = fqn.substringBeforeLast('.', "")
87+
88+
/** The longest run of leading package segments shared by every entry (segment-wise, never mid-segment). */
89+
private fun commonPackagePrefix(packages: List<String>): String {
90+
val split = packages.map { it.split('.') }
91+
val first = split.first()
92+
var depth = 0
93+
while (depth < first.size && split.all { it.size > depth && it[depth] == first[depth] }) depth++
94+
return first.take(depth).joinToString(".")
95+
}
96+
}

0 commit comments

Comments
 (0)