Skip to content

fix(plugins): a panel tree holds 20,000 nodes and says when it cuts (#1546) - #1738

Draft
elpidioassinaturas wants to merge 2 commits into
opengeos:mainfrom
elpidioassinaturas:fix/1546-panel-tree-limit
Draft

elpidioassinaturas wants to merge 2 commits into
opengeos:mainfrom
elpidioassinaturas:fix/1546-panel-tree-limit

Conversation

@elpidioassinaturas

Copy link
Copy Markdown
Contributor

Closes #1546

A plugin panel tree (#1050, #1080) kept at most 5,000 nodes. That is too few for a country's administrative units: Brazil's 5,570 municipalities with the IBGE levels above them make 6,298 nodes, and Spain, Italy and Germany pass the limit with the municipalities alone. The BrasilData plugin, whose panel picks the work area from the IBGE boundaries, has had to send one region at a time, so a search for a municipality in a region not yet chosen finds nothing.

What changes

  1. MAX_TREE_NODES is 20,000. That covers Brazil with its whole hierarchy, and Spain (~8,100), Italy (~7,900) and Germany (~10,800).
  2. A cut tree is said, by name. When a tree's nodes go past the limit, the warning on the plugin's answer names the tree, how many nodes it was given and the limit: <plugin>: tree catalogue was given 20001 nodes: only the first 20000 are kept, the most a tree holds. The panel shows a note under the tree ("Only the first 20000 of the 20001 nodes the plugin gave are shown: a tree holds at most 20000."), also when a search finds nothing, since what it looks for may be among those left out. plugin.panel answers the tree's item with "cut": <nodes given>.
  3. Each label is folded once. tree_nodes keeps the folded label (lower case, NFD, marks dropped) in TreeNode when the nodes arrive, and a search compares against it (TreeNode::holds_words). Before, tree_rows folded every node's label at every frame while a search was typed.

What #1667 already covered

#1667 (Plugins: report lossy panel updates) already counted nodes left out of an answer in one warning: N option(s) or tree node(s) left out (no id, one repeated or too long, too deep, or past the limit). That stays for the other reasons. What it didn't say is which tree was cut at the limit, from how many nodes, and nothing showed in the panel. Nodes past the limit are now counted apart from that generic count, so a cut isn't said twice.

Choices

  • The cut is counted where it happens. tree_nodes adds the rest of a level, with what it holds, to a past counter when it stops at the limit; the levels above count their own rest. Nodes left out for other reasons (no id, repeated, too deep) stay in Dropped::nodes.
  • fold moved to the plugins crate as TreeNode::fold, with the unicode-normalization dependency (from ui-egui, which no longer uses it elsewhere). The front end folds the query with the same function, so the two can't drift.
  • TreeNode::new builds a node with its folded label; the field is pub(crate) so a node can't be made outside the crate with a stale one.
  • The panel note is a dim line under the tree, as the empty-search "Nothing matches." is. New UI string, English only (translations are the maintainer's, before a release).

Measurement

A throwaway test (not in the PR) over 20,000 nodes with municipality-like labels ("São José do Rio Preto 123", "Mãe d'Água 456"…), searching "sao jose", in the workspace's test profile (optimized + debuginfo), Windows, three runs:

per search frame
before: fold every label, then compare 29.6 / 30.9 / 51.3 ms
after: compare the stored folded label 1.6 / 1.2 / 2.1 ms

Folding is now paid once per answer that sends the nodes: building the 20,000 nodes took 34 ms (63 ms in the noisy third run), the format! of the test labels included. Before, a 20,000-node search would have spent about 30 ms of every frame on folding alone, past a 60 fps frame's 16 ms.

Tests

  • a_tree_cut_at_its_limit_says_so (plugins): 20,000 nodes are all kept with nothing said; 20,001 keep 20,000 and say the cut once, naming the tree and the count, not again in the generic count; a nested tree cut inside a branch counts the branches after it with what they hold, and a node without an id is still counted apart; fewer nodes again clear cut.
  • a_tree_search_reads_the_label_folded_once (plugins): labels with accents (composed and decomposed), and ids standing for a missing label, are folded when kept; holds_words reads the stored folded label (changing it changes what is found).
  • tree_rows_follow_open_branches_and_searches (ui-egui) runs as before on TreeNode::new.

Locally (Windows): cargo fmt --check, cargo clippy -p rustgis-plugins -p rustgis-engine -p rustgis-ui-egui --all-targets -- -D warnings and cargo xtask layers pass; the tests of rustgis-plugins and rustgis-ui-egui pass. In rustgis-engine two tests fail on this PC on unrelated code: gdal_formats_open_when_gdal_is_installed (the GDAL here is older than the 3.8 it asks for) and portable_projects::reload::wrong_geometry_missing_file_and_missing_chosen_table_leave_the_layer_unchanged, which expects "file" in the operating system's error and gets it in Portuguese here. Two archive_render_revision tests failed once under load and passed when run again. unicode-normalization is already in both THIRD-PARTY-LICENSES.txt files, through other crates, so the notices don't change.

Limits

  • France's 34,900 communes (with the levels above them) still pass 20,000. A larger list needs a lazier tree (children sent when a branch opens), a bigger change and not part of this PR. The docs say to send such lists a level at a time.
  • The limit counts nodes in the order given, depth first: a plugin that sends a whole country keeps the first 20,000, not a balanced sample.
  • The note under the tree is not covered by a UI test, and not yet tried in a real window.
  • The folded label costs one more string per node (at 20,000 nodes with 20–30 character labels, under 1 MB), paid once per answer.

🤖 Generated with Claude Code

…pengeos#1546)

A plugin panel tree kept at most 5,000 nodes: too few for Brazil's
municipalities with the IBGE levels above them (6,298), or Spain's,
Italy's and Germany's municipalities alone.

- MAX_TREE_NODES is 20,000.
- Nodes past the limit are counted where the tree stops (`past` in
  tree_nodes), apart from those left out for other reasons. The answer's
  warning names the tree, how many nodes it was given and the limit; the
  item state keeps it (`cut`, also in `plugin.panel`) and the panel
  shows a note under the tree.
- Each label is folded once, when the node is made (TreeNode::new), and a
  search compares against it (TreeNode::holds_words) instead of folding
  every label at every frame. `fold` moves to the plugins crate as
  TreeNode::fold, with the unicode-normalization dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Comment thread crates/plugins/src/panel.rs Outdated
}
if self.nodes > 0 {
parts.push(format!(
"{} option(s) or tree node(s) left out (no id, one repeated or too long, too deep, or past the limit)",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (wording, low severity): this generic count no longer includes nodes past the limit. For trees, apply subtracts past from it, and nodes past the limit are reported apart by the cut notice above. For dropdowns, choice_options_reported never reaches the limit break, so it can't count one either. The text still lists "past the limit" as a reason, which is now misleading. Suggested wording:

Suggested change
"{} option(s) or tree node(s) left out (no id, one repeated or too long, too deep, or past the limit)",
"{} option(s) or tree node(s) left out (no id, one repeated or too long, or too deep)",

Existing tests only check the prefix 3 option(s) or tree node(s) left out, so this change doesn't affect them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right for trees, fixed in e57a4ea, with a different wording: a dropdown can still reach this count. An update's options past MAX_CHOICES are counted here, since apply adds options.len() minus those kept, and take(MAX_CHOICES) in choice_options_reported drops them before the loop. So the reason now reads "past a dropdown's limit" rather than being removed. a_tree_cut_at_its_limit_says_so now also updates a dropdown with MAX_CHOICES + 1 options and checks that the count says 1, that the notice names the dropdown's limit, and that no cut is set.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

None found. I checked the tree-cut accounting (past counting in tree_nodes, count_given, and the dropped.nodes subtraction) by hand against the new nested test, and it is consistent. left is decremented before recursion, and choice_options_reported never reaches the break. Existing hostile-tree tests still hold under the new limit.

Security

None found. The cut notice and panel note only print sanitised ids and numbers.

Performance

None found. Label folding now happens when a plugin answer is applied (cmd/plugins.rs:738), not on every search frame. That is per command run, not per UI frame.

Quality

  • Stale wording (confidence high, low severity): the generic "left out" notice in panel.rs:471 still lists "past the limit" as a reason. Nodes past the limit are now reported apart, so that text is misleading. Posted inline with a suggestion.
  • Test gap (confidence high, low severity): the panel's cut note in pluginpanel.rs has no UI test. The PR description already says so.

AGENTS.md

No violations found.

  • cut_note uses i18n::fmt, which translates its template itself, so it does not need tl!.
  • The unicode-normalization move is consistent. The root Cargo.toml has no workspace entry, and the licence notices don't name workspace crates, so they stay correct.
  • Both sides of the cut are tested: the clean 20,000-node tree gives no notice, and the cut tree gives one.

Prior review threads: none existed on this PR, so I left no earlier concerns out.

Change size

Compared: the whole PR against main (merge base 7cc9bb6 to PR head 7434545).

Measure Value
Files changed 7 (0 added, 7 modified, 0 removed, 0 renamed)
Size before 499.1 KB
Size after 506.9 KB
Total size change +7.7 KB
Path Before After Change
crates/plugins/src/tests.rs 114.3 KB 118.8 KB +4.6 KB
crates/plugins/src/panel.rs 44.1 KB 46.6 KB +2.5 KB
docs/plugins.md 55.3 KB 55.7 KB +445 B
crates/ui-egui/src/pluginpanel.rs 95.6 KB 95.7 KB +174 B
crates/plugins/Cargo.toml 1.6 KB 1.7 KB +124 B
crates/ui-egui/Cargo.toml 1.9 KB 1.8 KB −124 B
Cargo.lock 186.5 KB 186.5 KB 0 B

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

📖 Docs preview

Item Value
Preview https://preview.rustgis.app/pr-1738/
Web app https://preview.rustgis.app/pr-1738/app/
Commit e57a4ea

GitHub Pages takes a minute or two to publish a new commit.

…t a tree's (opengeos#1546)

A tree's cut is said apart, so "past the limit" in the generic count now
reads "past a dropdown's limit": the options of an update past
MAX_CHOICES are still counted there. The test covers both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
for (i, n) in v.iter().enumerate() {
if *left == 0 {
// The rest of this level, and below it; the levels above count their own rest.
*past = past.saturating_add(count_given(v.get(i..).unwrap_or_default(), depth));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low severity, a wording/semantics point rather than a loss: the cut is decided before a node is validated, so when the limit is hit and the rest of the list holds only nodes that would never be kept (no id, a repeated id, too deep), they are still counted in past and the tree is reported as cut. For example, 20,000 valid nodes followed by one node without an id gives "was given 20001 nodes: only the first 20000 are kept", and the panel note says a search may find what was left out, though nothing a search could find was lost.

The count is accurate as "given", so this may be intended. If you want the cut to mean "a valid node was lost", the cheap fix is to count only the remaining nodes that pass the id check, or to say "given" and "kept" without implying a loss. I'd leave the choice to you; there is no test for this case yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended; no change. Both texts state counts, "given" and "kept", and both stay true in this case. They don't say a node a search could find was lost. The panel note reads "Only the first 20000 of the 20001 nodes the plugin gave are shown". Nodes past the limit are counted, not read. Telling which of them would have been kept would mean running the id, duplicate and depth checks on everything past 20,000, which is the work the limit bounds. Either way the plugin's fix is the same, since it sent more nodes than a tree holds. Those nodes are therefore never counted again among the ones left out for other reasons, so nothing is said twice.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

I found no blocking bugs. The tree-cut accounting holds up: tree_nodes decrements left before recursing, so the past count and the generic count partition the given nodes without double counting. I hand-traced the nested test case (given 2 + MAX+1 + 3 + 3, kept MAX, past 7, generic 2) and it matches. The earlier thread on the generic-count wording was answered correctly by the author: dropdown options past MAX_CHOICES still reach that count, so "past a dropdown's limit" is accurate. I left it alone.

Bugs

  • Cut reported when only unusable nodes are left over (Low, ~40%). The cut is decided before a node is validated, so 20,000 valid nodes followed by one node without an id is reported as "given 20001 nodes: only the first 20000 are kept". The count is literally correct, but nothing a search could find was lost. Posted inline at crates/plugins/src/panel.rs:598. No test covers this case.

Security

  • None found. The new cut value and tree labels are bounded by the existing caps, and count_given is depth-capped.

Performance

  • Larger answers are applied on the thread that runs the plugin command (Low, ~35%). Raising the cap from 5,000 to 20,000 quadruples the per-answer work, which now includes an NFD fold and a second string per node. The PR measured about 34 ms to build 20,000 nodes. I could not confirm from here whether plugin commands run off the UI thread, so the "never hold the window" rule may or may not apply. Worth confirming.
  • Per-frame search cost is improved: labels are folded once, and the measurements in the PR support that.

Quality

  • choice_options_reported passes a throwaway &mut 0 as past, and past is threaded through tree_nodes for every caller. A small wrapper or a separate function for trees would be easier to read. Nit, left for the release quality pass.
  • The panel note (cut_note) has no UI test, which the PR already says. The i18n call is fine: i18n::fmt translates its own template, matching the existing convention.

AGENTS.md

  • Never crash, never silent, and the test rules are followed: lossy input gets a notice and clean input gets none, both are tested, and Session::notify isn't needed because the notice flows through Dropped::notice.
  • The TreeNode change keeps folded private and #[serde(skip)], so the serialized state is unchanged apart from cut.
  • unicode-normalization moved from ui-egui to plugins as a direct dependency. It is not a workspace dependency, so "0.1" is correct. Cargo.lock matches.
  • Docs (docs/plugins.md) match the behaviour.

Earlier concerns left alone: 1 thread (2 comments: the generic-count wording, answered by the author with a fix). Posted: 1 inline comment.

Change size

Compared: the whole PR against main (merge base 7cc9bb6 to PR head e57a4ea).

Measure Value
Files changed 7 (0 added, 7 modified, 0 removed, 0 renamed)
Size before 499.1 KB
Size after 507.6 KB
Total size change +8.4 KB
Path Before After Change
crates/plugins/src/tests.rs 114.3 KB 119.5 KB +5.3 KB
crates/plugins/src/panel.rs 44.1 KB 46.6 KB +2.5 KB
docs/plugins.md 55.3 KB 55.7 KB +445 B
crates/ui-egui/src/pluginpanel.rs 95.6 KB 95.7 KB +174 B
crates/plugins/Cargo.toml 1.6 KB 1.7 KB +124 B
crates/ui-egui/Cargo.toml 1.9 KB 1.8 KB −124 B
Cargo.lock 186.5 KB 186.5 KB 0 B

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: A plugin panel tree holds at most 5,000 nodes and drops the rest silently: raise it to 20,000 and say when it cuts

2 participants