File Tree: fixed handling of limits

... in the last depth level and comment counting as lines Line limit was not correctly being processed leading to display of much too many lines. At the same time now all folders of the last level get either items or summaries displayed.

Rafael Uzarowski committed Nov 10, 2025 at 00:45 UTC 03cc663ac3ba0bfaf2d831b599f1289d032cad29
2 files changed +309 -38
python/helpers/file_tree.py
+134 -38
@@ -70,7 +70,9 @@ def file_tree(
70 Notes:
71 * The utility is synchronous; avoid calling from latency-sensitive async loops.
72 * The ASCII renderer walks the established tree depth-first so connectors reflect parent/child structure,
73 - while traversal and limit calculations remain breadth-first by depth.
73 + while traversal and limit calculations remain breadth-first by depth. When ``max_lines`` is set, the number
74 + of non-comment entries (excluding the root banner) never exceeds that limit; informational summary comments
75 + are emitted in addition when necessary.
76 * ``created`` and ``modified`` values in structured outputs are timezone-aware UTC
77 :class:`datetime.datetime` objects::
78
@@ -115,7 +117,8 @@ def file_tree(
117
118 queue: deque[tuple[_TreeEntry, str, int]] = deque([(root_node, abs_root, 1)])
119 nodes_in_order: list[_TreeEntry] = []
118 - limit_level: Optional[int] = None
120 + rendered_count = 0
121 + limit_reached = False
122 visibility_cache: dict[str, bool] = {}
123
124 def make_entry(entry: os.DirEntry, parent: _TreeEntry, level: int, item_type: Literal["file", "folder"]) -> _TreeEntry:
@@ -133,7 +136,7 @@ def file_tree(
136 rel_path=rel_posix,
137 )
138
136 - while queue:
139 + while queue and not limit_reached:
140 parent_node, current_dir, level = queue.popleft()
141
142 if max_depth and level > max_depth:
@@ -161,35 +164,63 @@ def file_tree(
164 directory_node=parent_node,
165 )
166
164 - parent_node.items = children
165 - nodes_in_order.extend(children)
166 -
167 - if max_lines and limit_level is None and len(nodes_in_order) >= max_lines:
168 - limit_level = level
169 -
170 - for child in children:
167 + trimmed_children: list[_TreeEntry] = []
168 + hidden_children_local: list[_TreeEntry] = []
169 + if max_lines and rendered_count >= max_lines:
170 + limit_reached = True
171 + hidden_children_local = children
172 + else:
173 + for index, child in enumerate(children):
174 + if max_lines and rendered_count >= max_lines:
175 + limit_reached = True
176 + hidden_children_local = children[index:]
177 + break
178 + trimmed_children.append(child)
179 + nodes_in_order.append(child)
180 + is_global_summary = (
181 + child.item_type == "comment"
182 + and child.rel_path.endswith("#summary:limit")
183 + )
184 + if not is_global_summary:
185 + rendered_count += 1
186 + if limit_reached and hidden_children_local:
187 + summary = _create_global_limit_comment(
188 + parent_node,
189 + hidden_children_local,
190 + )
191 + trimmed_children.append(summary)
192 + nodes_in_order.append(summary)
193 +
194 + parent_node.items = trimmed_children or None
195 +
196 + if limit_reached:
197 + break
198 +
199 + for child in trimmed_children:
200 if child.item_type != "folder":
201 continue
202 if max_depth and level >= max_depth:
203 continue
175 - if limit_level is not None and level >= limit_level:
176 - continue
204 child_abs = os.path.join(current_dir, child.name)
205 queue.append((child, child_abs, level + 1))
206
180 - pruned_nodes: list[_TreeEntry] = nodes_in_order
181 - if max_lines and limit_level is not None:
182 - _prune_nested_children(
183 - root_node,
184 - lambda entry: entry.level <= limit_level,
185 - )
186 - pruned_nodes = [node for node in nodes_in_order if node.level <= limit_level]
207 + remaining_queue = list(queue) if limit_reached else []
208 + queue.clear()
209
188 - visible_nodes: list[_TreeEntry]
189 - if max_lines and limit_level is None:
190 - visible_nodes = pruned_nodes[:max_lines]
191 - else:
192 - visible_nodes = pruned_nodes
210 + if limit_reached and remaining_queue:
211 + for folder_node, folder_path, _ in remaining_queue:
212 + summary = _create_folder_unprocessed_comment(
213 + folder_node,
214 + folder_path,
215 + abs_root,
216 + ignore_spec,
217 + )
218 + if summary is None:
219 + continue
220 + folder_node.items = (folder_node.items or []) + [summary]
221 + nodes_in_order.append(summary)
222 +
223 + visible_nodes = nodes_in_order
224
225 visible_ids = {id(node) for node in visible_nodes}
226 if visible_ids:
@@ -320,15 +351,84 @@ def _create_summary_comment(parent: _TreeEntry, noun: str, count: int) -> _TreeE
351 )
352
353
323 -def _prune_nested_children(node: _TreeEntry, predicate: Callable[[_TreeEntry], bool]) -> None:
324 - if node.items is None:
325 - return
326 - pruned: list[_TreeEntry] = []
327 - for child in node.items:
328 - if predicate(child):
329 - _prune_nested_children(child, predicate)
330 - pruned.append(child)
331 - node.items = pruned
354 +def _create_global_limit_comment(parent: _TreeEntry, hidden_children: Sequence[_TreeEntry]) -> _TreeEntry:
355 + folders = sum(1 for child in hidden_children if child.item_type == "folder")
356 + files = sum(1 for child in hidden_children if child.item_type == "file")
357 + parts: list[str] = []
358 + if folders:
359 + label = "folder" if folders == 1 else "folders"
360 + parts.append(f"{folders} {label}")
361 + if files:
362 + label = "file" if files == 1 else "files"
363 + parts.append(f"{files} {label}")
364 + if not parts:
365 + remaining = len(hidden_children)
366 + label = "item" if remaining == 1 else "items"
367 + parts.append(f"{remaining} {label}")
368 + label_text = ", ".join(parts)
369 + return _TreeEntry(
370 + name=f"limit reached – hidden: {label_text}",
371 + level=parent.level + 1,
372 + item_type="comment",
373 + created=parent.created,
374 + modified=parent.modified,
375 + parent=parent,
376 + items=None,
377 + rel_path=f"{parent.rel_path}#summary:limit",
378 + )
379 +
380 +
381 +def _create_folder_unprocessed_comment(
382 + folder_node: _TreeEntry,
383 + folder_path: str,
384 + abs_root: str,
385 + ignore_spec: Optional[PathSpec],
386 +) -> Optional[_TreeEntry]:
387 + try:
388 + folders, files = _list_directory_children(
389 + folder_path,
390 + abs_root,
391 + ignore_spec,
392 + max_depth_remaining=-1,
393 + cache={},
394 + )
395 + except FileNotFoundError:
396 + return None
397 +
398 + hidden_entries: list[_TreeEntry] = []
399 + for entry in folders:
400 + stat = entry.stat(follow_symlinks=False)
401 + hidden_entries.append(
402 + _TreeEntry(
403 + name=entry.name,
404 + level=folder_node.level + 1,
405 + item_type="folder",
406 + created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
407 + modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
408 + parent=folder_node,
409 + items=None,
410 + rel_path=os.path.join(folder_node.rel_path, entry.name),
411 + )
412 + )
413 + for entry in files:
414 + stat = entry.stat(follow_symlinks=False)
415 + hidden_entries.append(
416 + _TreeEntry(
417 + name=entry.name,
418 + level=folder_node.level + 1,
419 + item_type="file",
420 + created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
421 + modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
422 + parent=folder_node,
423 + items=None,
424 + rel_path=os.path.join(folder_node.rel_path, entry.name),
425 + )
426 + )
427 +
428 + if not hidden_entries:
429 + return None
430 +
431 + return _create_global_limit_comment(folder_node, hidden_entries)
432
433
434 def _prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None:
@@ -339,7 +439,7 @@ def _prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None:
439 if not visible_ids or id(child) in visible_ids:
440 _prune_to_visible(child, visible_ids)
441 filtered.append(child)
342 - node.items = filtered
442 + node.items = filtered or None
443
444
445 def _mark_last_flags(node: _TreeEntry) -> None:
@@ -483,10 +583,6 @@ def _apply_sorting_and_limits(
583 if not overflow:
584 return
585
486 - if len(overflow) == 1 and limit > 0:
487 - combined.append(overflow[0])
488 - return
489 -
586 combined.append(
587 _create_summary_comment(
588 directory_node,
tests/test_file_tree_visualize.py
+175
@@ -464,6 +464,7 @@ def build_scenarios() -> List[Scenario]:
464 {
465 "output_mode": OUTPUT_MODE_NESTED,
466 "max_lines": 6,
467 + "folders_first": True,
468 "sort": (SORT_BY_NAME, SORT_ASC),
469 },
470 ),
@@ -643,6 +644,180 @@ def build_scenarios() -> List[Scenario]:
644 )
645 )
646
647 + stress_structure = {
648 + "level1_a": {
649 + "level2_a1": {
650 + "leaf_a1_1.txt": "",
651 + "leaf_a1_2.txt": "",
652 + "leaf_a1_3.txt": "",
653 + },
654 + "level2_a2": {
655 + "leaf_a2_1.txt": "",
656 + "leaf_a2_2.txt": "",
657 + "leaf_a2_3.txt": "",
658 + },
659 + "level2_a3": {
660 + "subfolder_a3": {
661 + "deep_a3_1.txt": "",
662 + "deep_a3_2.txt": "",
663 + "deep_a3_3.txt": "",
664 + "subsubfolder_a3": {
665 + "deep_a3_4.txt": "",
666 + "deep_a3_5.txt": "",
667 + },
668 + "subsubfolder_a3_extra": {
669 + "deep_a3_extra_1.txt": "",
670 + "deep_a3_extra_2.txt": "",
671 + },
672 + },
673 + "subfolder_a3_extra": {
674 + "deep_extra_1.txt": "",
675 + "deep_extra_2.txt": "",
676 + },
677 + "subfolder_a3_more": {
678 + "deep_more_1.txt": "",
679 + },
680 + },
681 + },
682 + "level1_b": {
683 + "level2_b1": {
684 + "leaf_b1_1.txt": "",
685 + "leaf_b1_2.txt": "",
686 + },
687 + "level2_b2": {
688 + "leaf_b2_1.txt": "",
689 + "leaf_b2_2.txt": "",
690 + "leaf_b2_3.txt": "",
691 + "leaf_b2_4.txt": "",
692 + "leaf_b2_5.txt": "",
693 + },
694 + "level2_b3": {
695 + "subfolder_b3": {
696 + "deep_b3_1.txt": "",
697 + "deep_b3_2.txt": "",
698 + "deep_b3_3.txt": "",
699 + "deep_b3_4.txt": "",
700 + },
701 + "subfolder_b3_extra": {
702 + "deeper_b3_extra.txt": "",
703 + "deeper_b3_extra_2.txt": "",
704 + },
705 + },
706 + },
707 + "level1_c": {
708 + "level2_c1": {
709 + "leaf_c1_1.txt": "",
710 + "leaf_c1_2.txt": "",
711 + "leaf_c1_3.txt": "",
712 + "leaf_c1_4.txt": "",
713 + "leaf_c1_5.txt": "",
714 + },
715 + "level2_c2": {
716 + "subfolder_c2": {
717 + "deep_c2_1.txt": "",
718 + "deep_c2_2.txt": "",
719 + },
720 + "subfolder_c2_extra": {
721 + "deep_c2_extra_1.txt": "",
722 + },
723 + },
724 + },
725 + "level1_d": {
726 + "level2_d1": {
727 + "leaf_d1_1.txt": "",
728 + "leaf_d1_2.txt": "",
729 + "leaf_d1_3.txt": "",
730 + },
731 + "level2_d2": {
732 + "subfolder_d2": {
733 + "deep_d2_1.txt": "",
734 + "deep_d2_2.txt": "",
735 + },
736 + },
737 + },
738 + "root_file.txt": "",
739 + "root_notes.md": "",
740 + "root_file_2.txt": "",
741 + "root_file_3.txt": "",
742 + }
743 +
744 + scenarios.append(
745 + Scenario(
746 + name="mixed_limits_baseline",
747 + description="Full structure without truncation for comparison",
748 + structure=stress_structure,
749 + configs=[
750 + Config(
751 + "string • no limits baseline",
752 + {
753 + "output_mode": OUTPUT_MODE_STRING,
754 + "folders_first": True,
755 + "sort": (SORT_BY_NAME, SORT_ASC),
756 + },
757 + ),
758 + Config(
759 + "flat • no limits baseline",
760 + {
761 + "output_mode": OUTPUT_MODE_FLAT,
762 + "folders_first": True,
763 + "sort": (SORT_BY_NAME, SORT_ASC),
764 + },
765 + ),
766 + Config(
767 + "nested • no limits baseline",
768 + {
769 + "output_mode": OUTPUT_MODE_NESTED,
770 + "folders_first": True,
771 + "sort": (SORT_BY_NAME, SORT_ASC),
772 + },
773 + ),
774 + ],
775 + )
776 + )
777 +
778 + scenarios.append(
779 + Scenario(
780 + name="mixed_limits_stress",
781 + description="Same structure with local and global limits applied",
782 + structure=stress_structure,
783 + configs=[
784 + Config(
785 + "string • mixed local/global limits stress",
786 + {
787 + "output_mode": OUTPUT_MODE_STRING,
788 + "folders_first": True,
789 + "sort": (SORT_BY_NAME, SORT_ASC),
790 + "max_folders": 2,
791 + "max_files": 2,
792 + "max_lines": 19,
793 + },
794 + ),
795 + Config(
796 + "flat • mixed limits stress",
797 + {
798 + "output_mode": OUTPUT_MODE_FLAT,
799 + "folders_first": True,
800 + "sort": (SORT_BY_NAME, SORT_ASC),
801 + "max_folders": 2,
802 + "max_files": 2,
803 + "max_lines": 19,
804 + },
805 + ),
806 + Config(
807 + "nested • mixed limits stress",
808 + {
809 + "output_mode": OUTPUT_MODE_NESTED,
810 + "folders_first": True,
811 + "sort": (SORT_BY_NAME, SORT_ASC),
812 + "max_folders": 2,
813 + "max_files": 2,
814 + "max_lines": 19,
815 + },
816 + ),
817 + ],
818 + )
819 + )
820 +
821 return scenarios
822
823