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