treeing 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- treeing/__init__.py +7 -0
- treeing/_release_defaults.py +3 -0
- treeing/assets/icon.icns +0 -0
- treeing/assets/icon.ico +0 -0
- treeing/assets/icon.png +0 -0
- treeing/cli/__init__.py +5 -0
- treeing/cli/confirm.py +197 -0
- treeing/cli/help_text.py +311 -0
- treeing/cli/io.py +72 -0
- treeing/cli/main.py +427 -0
- treeing/cli/report.py +104 -0
- treeing/cli_entry.py +16 -0
- treeing/config.py +205 -0
- treeing/core/__init__.py +15 -0
- treeing/core/constants.py +20 -0
- treeing/core/generator.py +567 -0
- treeing/core/parser.py +407 -0
- treeing/core/preview.py +98 -0
- treeing/gui/__init__.py +5 -0
- treeing/gui/app.py +886 -0
- treeing/gui/dnd.py +124 -0
- treeing/gui/icon.py +69 -0
- treeing/gui/preview.py +9 -0
- treeing/gui/settings.py +80 -0
- treeing/gui/tooltip.py +234 -0
- treeing/gui_entry.py +39 -0
- treeing/main.py +21 -0
- treeing/path_checks.py +87 -0
- treeing/strings.bootstrap.json +7 -0
- treeing/strings.json +222 -0
- treeing-1.0.1.dist-info/METADATA +112 -0
- treeing-1.0.1.dist-info/RECORD +36 -0
- treeing-1.0.1.dist-info/WHEEL +5 -0
- treeing-1.0.1.dist-info/entry_points.txt +3 -0
- treeing-1.0.1.dist-info/licenses/LICENSE +21 -0
- treeing-1.0.1.dist-info/top_level.txt +1 -0
treeing/core/parser.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
treeing/core/parser.py
|
|
3
|
+
|
|
4
|
+
Defines the ASCII tree-text parsing logic.
|
|
5
|
+
Handles indent detection, branch symbols, file/directory inference,
|
|
6
|
+
auto-repair and edge cases, producing a structured node tree for the generator.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from functools import reduce
|
|
14
|
+
from math import gcd
|
|
15
|
+
|
|
16
|
+
from ..config import get_string
|
|
17
|
+
from .constants import DOT_ROOT, TRANSPARENT_NODE_NAMES, VIRTUAL_AUTO, VIRTUAL_ROOT
|
|
18
|
+
|
|
19
|
+
# Matches tree branch characters (├└┤┬┴┼) and various horizontal lines
|
|
20
|
+
# (ASCII and Unicode box-drawing characters).
|
|
21
|
+
# MING includes all of these so the parser copes with tree output from
|
|
22
|
+
# different systems and tools.
|
|
23
|
+
_BRANCH_CHARS = r'[├└┤┬┴┼+\-`\\|]'
|
|
24
|
+
_HORIZ_CHARS = r'[-—─━┄┅┈┉]+'
|
|
25
|
+
PREFIX_PATTERN = re.compile(rf'^{_BRANCH_CHARS}\s*{_HORIZ_CHARS}\s*')
|
|
26
|
+
|
|
27
|
+
# Vertical continuation line: │ or | followed by 3 spaces, repeatable.
|
|
28
|
+
# This matches the `tree` command output format, hence the dedicated regex.
|
|
29
|
+
INDENT_PATTERN = re.compile(r'^(?:(?:[│|])\s{3})*')
|
|
30
|
+
SPACE_INDENT_PATTERN = re.compile(r'^ +')
|
|
31
|
+
|
|
32
|
+
# Lines made up only of separators (---, ===, *** and the like) are not tree
|
|
33
|
+
# content and are skipped.
|
|
34
|
+
_SEPARATOR_ONLY = re.compile(r'^[\-=_*·…\.]+$')
|
|
35
|
+
|
|
36
|
+
# Default indent unit: 4 spaces.
|
|
37
|
+
_TREE_STANDARD_UNIT = 4
|
|
38
|
+
|
|
39
|
+
# Filenames without an extension that everyone still recognises as files.
|
|
40
|
+
# MING lists the common ones (LICENSE, README, Makefile) here to avoid
|
|
41
|
+
# misclassifying them as directories.
|
|
42
|
+
_KNOWN_FILE_NAMES = frozenset({
|
|
43
|
+
'license', 'copying', 'authors', 'contributors', 'readme',
|
|
44
|
+
'changelog', 'makefile', 'dockerfile', 'procfile', 'gemfile',
|
|
45
|
+
'rakefile', 'vagrantfile', 'brewfile', 'justfile', 'notice',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _looks_like_file(name: str, *, strict_dirs: bool = False) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Decide whether a name looks like a file or a directory.
|
|
52
|
+
|
|
53
|
+
By default MING uses a fairly aggressive heuristic: if the name contains an
|
|
54
|
+
uppercase letter, treat it as a file, because the Windows `tree` command
|
|
55
|
+
usually prints directories in lower case while files (especially source
|
|
56
|
+
files) often contain capitals.
|
|
57
|
+
|
|
58
|
+
This rule misclassifies some hand-written directory names, so the
|
|
59
|
+
`--strict-dirs` option turns it off and only trusts extensions, dotfiles
|
|
60
|
+
and the known extension-less filenames.
|
|
61
|
+
"""
|
|
62
|
+
if name.lower() in _KNOWN_FILE_NAMES:
|
|
63
|
+
return True
|
|
64
|
+
if name.startswith('.') and len(name) > 1:
|
|
65
|
+
return True
|
|
66
|
+
if '.' in name and not name.startswith('.'):
|
|
67
|
+
return True
|
|
68
|
+
if strict_dirs:
|
|
69
|
+
return False
|
|
70
|
+
return any(c.isupper() for c in name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_noise_line(stripped: str) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Decide whether a line is "noise", i.e. not tree content.
|
|
76
|
+
|
|
77
|
+
Comment lines (starting with # or //) and pure separator lines (---, ===,
|
|
78
|
+
etc.) are skipped. A lone '.' is a legal root marker and is not noise.
|
|
79
|
+
"""
|
|
80
|
+
if stripped == DOT_ROOT:
|
|
81
|
+
return False
|
|
82
|
+
if stripped.startswith('#') or stripped.startswith('//'):
|
|
83
|
+
return True
|
|
84
|
+
return _SEPARATOR_ONLY.match(stripped) is not None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_root_garbage(indent: int, has_branch: bool, name: str) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Decide whether a root-level line is "garbage", i.e. clearly not tree content.
|
|
90
|
+
|
|
91
|
+
If the line has no branch prefix, no indent, contains a space and is not a
|
|
92
|
+
recognised file form, it is most likely prose the user pasted by accident
|
|
93
|
+
(e.g. "random garbage line").
|
|
94
|
+
"""
|
|
95
|
+
if has_branch or indent > 0:
|
|
96
|
+
return False
|
|
97
|
+
if name.endswith('/') or name == DOT_ROOT:
|
|
98
|
+
return False
|
|
99
|
+
if _looks_like_file(name):
|
|
100
|
+
return False
|
|
101
|
+
return ' ' in name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _measure_indent(line: str) -> int:
|
|
105
|
+
"""
|
|
106
|
+
Measure the indent length of a line.
|
|
107
|
+
|
|
108
|
+
The indent can come from two sources:
|
|
109
|
+
1. A tree continuation line (│ or | followed by 3 spaces, repeatable).
|
|
110
|
+
2. Plain space indent (hand-written trees may just use spaces).
|
|
111
|
+
|
|
112
|
+
MING handles both so the parser copes with all sorts of odd input formats.
|
|
113
|
+
"""
|
|
114
|
+
pos_match = INDENT_PATTERN.match(line)
|
|
115
|
+
pos = pos_match.end() if pos_match else 0
|
|
116
|
+
space_match = SPACE_INDENT_PATTERN.match(line[pos:])
|
|
117
|
+
if space_match:
|
|
118
|
+
pos += space_match.end()
|
|
119
|
+
return pos
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_line(
|
|
123
|
+
line: str, *, strict_dirs: bool = False,
|
|
124
|
+
) -> tuple[int, str, bool, bool, bool] | None:
|
|
125
|
+
"""
|
|
126
|
+
Parse a single tree-text line, extracting indent, name, directory flag, etc.
|
|
127
|
+
|
|
128
|
+
Returns a 5-tuple: (indent, name, is_dir, has_branch_prefix, is_heuristic_dir).
|
|
129
|
+
|
|
130
|
+
Returns None for blank lines or lines that fail to parse.
|
|
131
|
+
"""
|
|
132
|
+
line = line.expandtabs(4)
|
|
133
|
+
line = line.rstrip('\n')
|
|
134
|
+
if not line.strip():
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
indent = _measure_indent(line)
|
|
138
|
+
rest = line[indent:]
|
|
139
|
+
|
|
140
|
+
prefix_match = PREFIX_PATTERN.match(rest)
|
|
141
|
+
has_branch_prefix = prefix_match is not None
|
|
142
|
+
if prefix_match:
|
|
143
|
+
name = rest[prefix_match.end():].strip()
|
|
144
|
+
else:
|
|
145
|
+
name = rest.strip()
|
|
146
|
+
|
|
147
|
+
if not name:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
heuristic_dir = False
|
|
151
|
+
is_dir = name.endswith('/')
|
|
152
|
+
if is_dir:
|
|
153
|
+
name = name[:-1]
|
|
154
|
+
elif has_branch_prefix and not _looks_like_file(name, strict_dirs=strict_dirs):
|
|
155
|
+
is_dir = True
|
|
156
|
+
heuristic_dir = True
|
|
157
|
+
|
|
158
|
+
return indent, name, is_dir, has_branch_prefix, heuristic_dir
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _gcd_of_list(values: list[int]) -> int:
|
|
162
|
+
"""
|
|
163
|
+
Compute the greatest common divisor of a list of positive integers.
|
|
164
|
+
|
|
165
|
+
Used by detect_indent_unit: if every indent value is a multiple of some
|
|
166
|
+
number, prefer that multiple as the indent unit. Accepts positive integers
|
|
167
|
+
only; an empty list returns 0.
|
|
168
|
+
"""
|
|
169
|
+
return reduce(gcd, values)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _most_common_diff(positive: list[int]) -> int:
|
|
173
|
+
"""
|
|
174
|
+
Find the most common "adjacent difference" among a list of indent values.
|
|
175
|
+
|
|
176
|
+
One of MING's heuristics for guessing the indent unit: if, in a hand-written
|
|
177
|
+
tree, most children are indented 2 spaces more than their parent, the unit
|
|
178
|
+
is probably 2.
|
|
179
|
+
"""
|
|
180
|
+
diff_counts: Counter = Counter()
|
|
181
|
+
for i in range(1, len(positive)):
|
|
182
|
+
diff = positive[i] - positive[i - 1]
|
|
183
|
+
if diff > 0:
|
|
184
|
+
diff_counts[diff] += 1
|
|
185
|
+
if diff_counts:
|
|
186
|
+
return diff_counts.most_common(1)[0][0]
|
|
187
|
+
return positive[0]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def detect_indent_unit(indents: list[int]) -> int:
|
|
191
|
+
"""
|
|
192
|
+
Infer the most likely indent unit from a set of indent values.
|
|
193
|
+
|
|
194
|
+
MING applies three rules in priority order:
|
|
195
|
+
1. If every indent value is a multiple of some number >= 4, use that number.
|
|
196
|
+
2. If the smallest positive indent is >= 4, use that smallest value.
|
|
197
|
+
3. Otherwise take the larger of "greatest common divisor" and "most common
|
|
198
|
+
difference".
|
|
199
|
+
|
|
200
|
+
Returns the default 4 when there are no positive indents at all.
|
|
201
|
+
"""
|
|
202
|
+
if not indents:
|
|
203
|
+
return _TREE_STANDARD_UNIT
|
|
204
|
+
positive = sorted(set(i for i in indents if i > 0))
|
|
205
|
+
if not positive:
|
|
206
|
+
return _TREE_STANDARD_UNIT
|
|
207
|
+
if len(positive) == 1:
|
|
208
|
+
return positive[0]
|
|
209
|
+
|
|
210
|
+
gcd_unit = _gcd_of_list(positive)
|
|
211
|
+
diff_unit = _most_common_diff(positive)
|
|
212
|
+
|
|
213
|
+
if gcd_unit >= _TREE_STANDARD_UNIT:
|
|
214
|
+
return gcd_unit
|
|
215
|
+
if min(positive) >= _TREE_STANDARD_UNIT:
|
|
216
|
+
return min(positive)
|
|
217
|
+
return max(gcd_unit, diff_unit)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _compute_level(indent: int, unit: int, has_branch_prefix: bool) -> int:
|
|
221
|
+
"""
|
|
222
|
+
Compute a node's tree level from its indent value and the indent unit.
|
|
223
|
+
|
|
224
|
+
A node with a branch prefix is one level deeper than a plain-indent node at
|
|
225
|
+
the same column (the branch itself occupies a level).
|
|
226
|
+
"""
|
|
227
|
+
if has_branch_prefix:
|
|
228
|
+
return indent // unit + 1 if unit > 0 else 1
|
|
229
|
+
return indent // unit if unit > 0 else 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def auto_fix_indent(
|
|
233
|
+
parsed: list[tuple[int, str, bool, bool, bool, int]], unit: int
|
|
234
|
+
) -> tuple[list[tuple[int, str, bool, bool, bool, int]], list[str]]:
|
|
235
|
+
"""
|
|
236
|
+
Snap parsed indent values onto the inferred indent unit.
|
|
237
|
+
|
|
238
|
+
If a line's indent is not a multiple of the unit, round it to the nearest
|
|
239
|
+
multiple. Each fix produces a warning telling the user "I fixed line X's
|
|
240
|
+
indent".
|
|
241
|
+
|
|
242
|
+
MING added this auto-repair so that hand-written trees with messy indents
|
|
243
|
+
still parse into a reasonable structure.
|
|
244
|
+
"""
|
|
245
|
+
fixed = []
|
|
246
|
+
warnings = []
|
|
247
|
+
for indent, name, is_dir, has_branch, heuristic_dir, line_no in parsed:
|
|
248
|
+
if unit == 0:
|
|
249
|
+
new_indent = 0
|
|
250
|
+
else:
|
|
251
|
+
quotient = round(indent / unit)
|
|
252
|
+
new_indent = quotient * unit
|
|
253
|
+
if new_indent < 0:
|
|
254
|
+
new_indent = 0
|
|
255
|
+
if new_indent != indent:
|
|
256
|
+
warn = get_string(
|
|
257
|
+
"core_warn_indent_fixed",
|
|
258
|
+
line=line_no, old=indent, new=new_indent, unit=unit,
|
|
259
|
+
)
|
|
260
|
+
warnings.append(warn)
|
|
261
|
+
fixed.append((new_indent, name, is_dir, has_branch, heuristic_dir, line_no))
|
|
262
|
+
return fixed, warnings
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _infer_directories(tree: list[dict], warnings: list[str]) -> None:
|
|
266
|
+
"""
|
|
267
|
+
Walk the tree and mark entries that have children but no trailing slash as directories.
|
|
268
|
+
|
|
269
|
+
This copes with the Windows `tree` command, which does not add a trailing
|
|
270
|
+
slash to directories, so we infer directory-ness from "has children".
|
|
271
|
+
|
|
272
|
+
MING missed this in an early version, so Windows users often had their
|
|
273
|
+
directories treated as files, which then broke generation.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def walk(node: dict) -> None:
|
|
277
|
+
"""Recursively walk nodes, correcting entries that have children but are still flagged as files into directories."""
|
|
278
|
+
for child in node.get('children', []):
|
|
279
|
+
walk(child)
|
|
280
|
+
if (
|
|
281
|
+
node.get('children')
|
|
282
|
+
and not node.get('is_dir', False)
|
|
283
|
+
and node['name'] not in TRANSPARENT_NODE_NAMES
|
|
284
|
+
and not _looks_like_file(node['name'])
|
|
285
|
+
):
|
|
286
|
+
node['is_dir'] = True
|
|
287
|
+
warnings.append(get_string("core_warn_inferred_dir", name=node['name']))
|
|
288
|
+
|
|
289
|
+
for root in tree:
|
|
290
|
+
walk(root)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_tree(
|
|
294
|
+
lines: list[str],
|
|
295
|
+
auto_fix: bool = True,
|
|
296
|
+
indent_unit: int | None = None,
|
|
297
|
+
strict_dirs: bool = False,
|
|
298
|
+
) -> tuple[list[dict], list[str]]:
|
|
299
|
+
"""
|
|
300
|
+
Parse multi-line ASCII tree text into a structured node tree.
|
|
301
|
+
|
|
302
|
+
This is the entry point of the parser module. It:
|
|
303
|
+
1. Strips the BOM (some editors prepend \ufeff).
|
|
304
|
+
2. Parses line by line, skipping blank lines, comments, separators and
|
|
305
|
+
garbage lines.
|
|
306
|
+
3. Auto-detects the indent unit, or uses the user-supplied one.
|
|
307
|
+
4. Auto-repairs misaligned indents (when auto_fix is on).
|
|
308
|
+
5. Builds the tree, handling indent jumps, virtual roots, multiple roots
|
|
309
|
+
and other edge cases.
|
|
310
|
+
6. Infers directories that lack a trailing slash.
|
|
311
|
+
7. Collects warnings and returns them to the caller for display.
|
|
312
|
+
|
|
313
|
+
MING kept this function long on purpose: tree-text parsing has many edge
|
|
314
|
+
cases, and splitting it into tiny helpers makes it easy for callers to
|
|
315
|
+
forget handling some of them.
|
|
316
|
+
"""
|
|
317
|
+
warnings: list[str] = []
|
|
318
|
+
lines = [
|
|
319
|
+
line.lstrip('\ufeff') if line.startswith('\ufeff') else line
|
|
320
|
+
for line in lines
|
|
321
|
+
]
|
|
322
|
+
parsed = []
|
|
323
|
+
for idx, line in enumerate(lines, start=1):
|
|
324
|
+
stripped = line.strip()
|
|
325
|
+
if not stripped:
|
|
326
|
+
continue
|
|
327
|
+
if _is_noise_line(stripped):
|
|
328
|
+
preview = stripped if len(stripped) <= 60 else stripped[:57] + '...'
|
|
329
|
+
warnings.append(get_string("core_warn_unrecognized_line", line=idx, content=preview))
|
|
330
|
+
continue
|
|
331
|
+
result = parse_line(line, strict_dirs=strict_dirs)
|
|
332
|
+
if result is None:
|
|
333
|
+
preview = stripped if len(stripped) <= 60 else stripped[:57] + '...'
|
|
334
|
+
warnings.append(get_string("core_warn_unrecognized_line", line=idx, content=preview))
|
|
335
|
+
continue
|
|
336
|
+
indent, name, is_dir, has_branch, heuristic_dir = result
|
|
337
|
+
if _is_root_garbage(indent, has_branch, name):
|
|
338
|
+
preview = stripped if len(stripped) <= 60 else stripped[:57] + '...'
|
|
339
|
+
warnings.append(get_string("core_warn_unrecognized_line", line=idx, content=preview))
|
|
340
|
+
continue
|
|
341
|
+
parsed.append((indent, name, is_dir, has_branch, heuristic_dir, idx))
|
|
342
|
+
|
|
343
|
+
if not parsed:
|
|
344
|
+
return [], warnings + [get_string("core_warn_empty_input")]
|
|
345
|
+
|
|
346
|
+
indents = [p[0] for p in parsed if p[0] > 0]
|
|
347
|
+
unit = indent_unit if indent_unit and indent_unit > 0 else detect_indent_unit(indents)
|
|
348
|
+
|
|
349
|
+
fix_warnings: list[str] = []
|
|
350
|
+
if auto_fix:
|
|
351
|
+
parsed, fix_warnings = auto_fix_indent(parsed, unit)
|
|
352
|
+
warnings.extend(fix_warnings)
|
|
353
|
+
if indent_unit is None:
|
|
354
|
+
indents = [p[0] for p in parsed if p[0] > 0]
|
|
355
|
+
if indents:
|
|
356
|
+
unit = detect_indent_unit(indents)
|
|
357
|
+
|
|
358
|
+
roots: list[dict] = []
|
|
359
|
+
stack: list[tuple[int, dict]] = []
|
|
360
|
+
|
|
361
|
+
for indent, name, is_dir, has_branch, heuristic_dir, line_no in parsed:
|
|
362
|
+
level = _compute_level(indent, unit, has_branch)
|
|
363
|
+
|
|
364
|
+
while len(stack) > level:
|
|
365
|
+
stack.pop()
|
|
366
|
+
while len(stack) < level:
|
|
367
|
+
if stack:
|
|
368
|
+
parent = stack[-1][1]
|
|
369
|
+
dummy = {'name': VIRTUAL_AUTO, 'is_dir': True, 'children': []}
|
|
370
|
+
parent['children'].append(dummy)
|
|
371
|
+
stack.append((len(stack), dummy))
|
|
372
|
+
warnings.append(get_string("core_warn_indent_jump", parent=parent['name']))
|
|
373
|
+
else:
|
|
374
|
+
virtual = {'name': VIRTUAL_ROOT, 'is_dir': True, 'children': []}
|
|
375
|
+
roots.append(virtual)
|
|
376
|
+
stack.append((0, virtual))
|
|
377
|
+
warnings.append(get_string("core_warn_virtual_root"))
|
|
378
|
+
|
|
379
|
+
node = {'name': name, 'is_dir': is_dir, 'children': []}
|
|
380
|
+
if name == DOT_ROOT:
|
|
381
|
+
warnings.append(get_string("core_warn_dot_root"))
|
|
382
|
+
if heuristic_dir:
|
|
383
|
+
warnings.append(get_string("core_warn_inferred_dir_heuristic", name=name))
|
|
384
|
+
if level == 0:
|
|
385
|
+
roots.append(node)
|
|
386
|
+
stack = [(0, node)]
|
|
387
|
+
else:
|
|
388
|
+
if stack:
|
|
389
|
+
parent = stack[-1][1]
|
|
390
|
+
parent['children'].append(node)
|
|
391
|
+
else:
|
|
392
|
+
roots.append(node)
|
|
393
|
+
warnings.append(get_string("core_warn_orphan", line=line_no, name=name))
|
|
394
|
+
stack.append((level, node))
|
|
395
|
+
|
|
396
|
+
_infer_directories(roots, warnings)
|
|
397
|
+
|
|
398
|
+
real_roots = [r for r in roots if r['name'] not in TRANSPARENT_NODE_NAMES]
|
|
399
|
+
multiple_roots = len(real_roots) > 1
|
|
400
|
+
if multiple_roots:
|
|
401
|
+
names = ', '.join(r['name'] for r in real_roots)
|
|
402
|
+
warnings.append(get_string("core_warn_multiple_roots", count=len(real_roots), names=names))
|
|
403
|
+
|
|
404
|
+
if indent_unit is not None and indent_unit > 0 and (fix_warnings or multiple_roots):
|
|
405
|
+
warnings.append(get_string("core_hint_indent_unit_check"))
|
|
406
|
+
|
|
407
|
+
return roots, warnings
|
treeing/core/preview.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
treeing/core/preview.py
|
|
3
|
+
|
|
4
|
+
Defines preview-label formatting and ASCII tree rendering.
|
|
5
|
+
Provides `format_preview_label` and `render_text_tree`, shared by the GUI
|
|
6
|
+
Treeview and the CLI `--format tree` output, so both displays stay in sync.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..config import get_string
|
|
12
|
+
from .constants import DOT_ROOT, VIRTUAL_AUTO, VIRTUAL_NODE_NAMES
|
|
13
|
+
from .generator import parse_nested_name, sanitize_filename
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _disk_label(name: str, is_dir: bool, *, allow_nested: bool) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Turn an on-disk name into the form shown in the preview.
|
|
19
|
+
|
|
20
|
+
When `--allow-nested-names` is on, a nested path like `foo/bar` is split,
|
|
21
|
+
each segment sanitised, then rejoined; otherwise the whole thing is
|
|
22
|
+
treated as one name.
|
|
23
|
+
|
|
24
|
+
If the name was changed (e.g. illegal characters replaced), the original
|
|
25
|
+
is appended in parentheses so the user can see "I changed this".
|
|
26
|
+
"""
|
|
27
|
+
raw = name.rstrip('/')
|
|
28
|
+
trailing_slash = name.endswith('/') or is_dir
|
|
29
|
+
if allow_nested and ('/' in raw or '\\' in raw):
|
|
30
|
+
parts = parse_nested_name(raw)
|
|
31
|
+
if parts:
|
|
32
|
+
label = '/'.join(sanitize_filename(part) for part in parts)
|
|
33
|
+
else:
|
|
34
|
+
label = sanitize_filename(raw)
|
|
35
|
+
else:
|
|
36
|
+
label = sanitize_filename(raw)
|
|
37
|
+
if trailing_slash:
|
|
38
|
+
label += '/'
|
|
39
|
+
if label != name and not (name.endswith('/') and label == name.rstrip('/') + '/'):
|
|
40
|
+
return f"{label} ({name})"
|
|
41
|
+
return label
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_preview_label(node: dict, *, allow_nested: bool) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Return the text a node should show in the preview.
|
|
47
|
+
|
|
48
|
+
Virtual nodes (<auto>, <virtual>) and the dot root (.) get special
|
|
49
|
+
treatment: they are prefixed to tell the user these were inserted by the
|
|
50
|
+
program, not written by them.
|
|
51
|
+
|
|
52
|
+
Ordinary nodes go through _disk_label: sanitised and/or expanded as needed.
|
|
53
|
+
"""
|
|
54
|
+
name = node['name']
|
|
55
|
+
is_virtual = name in VIRTUAL_NODE_NAMES
|
|
56
|
+
is_dot_root = name == DOT_ROOT
|
|
57
|
+
is_dir = node.get('is_dir', False)
|
|
58
|
+
|
|
59
|
+
if is_virtual:
|
|
60
|
+
if name == VIRTUAL_AUTO:
|
|
61
|
+
display_name = get_string('preview_virtual_auto_display')
|
|
62
|
+
else:
|
|
63
|
+
display_name = get_string('preview_virtual_root_display')
|
|
64
|
+
elif is_dot_root:
|
|
65
|
+
display_name = get_string('preview_dot_root_display')
|
|
66
|
+
else:
|
|
67
|
+
display_name = _disk_label(name, is_dir, allow_nested=allow_nested)
|
|
68
|
+
|
|
69
|
+
if is_virtual or is_dot_root:
|
|
70
|
+
display_name = get_string('preview_virtual_prefix') + display_name
|
|
71
|
+
return display_name
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_text_tree(tree: list[dict], *, allow_nested: bool) -> list[str]:
|
|
75
|
+
"""
|
|
76
|
+
Render the parsed tree into ASCII text lines for the CLI `--format tree` output.
|
|
77
|
+
|
|
78
|
+
Root nodes carry no branch prefix; children use ├── / └── and │ as the
|
|
79
|
+
level connector. This matches the `tree` command output, which users are
|
|
80
|
+
used to.
|
|
81
|
+
|
|
82
|
+
MING deliberately shares format_preview_label with the GUI Treeview logic,
|
|
83
|
+
so a change in one place updates both and they never drift apart.
|
|
84
|
+
"""
|
|
85
|
+
lines: list[str] = []
|
|
86
|
+
|
|
87
|
+
def walk_children(nodes: list[dict], prefix: str) -> None:
|
|
88
|
+
for i, node in enumerate(nodes):
|
|
89
|
+
is_last = i == len(nodes) - 1
|
|
90
|
+
branch = '└── ' if is_last else '├── '
|
|
91
|
+
contin = ' ' if is_last else '│ '
|
|
92
|
+
lines.append(prefix + branch + format_preview_label(node, allow_nested=allow_nested))
|
|
93
|
+
walk_children(node.get('children', []), prefix + contin)
|
|
94
|
+
|
|
95
|
+
for node in tree:
|
|
96
|
+
lines.append(format_preview_label(node, allow_nested=allow_nested))
|
|
97
|
+
walk_children(node.get('children', []), '')
|
|
98
|
+
return lines
|