trls-cli 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {trls_cli-0.2.0 → trls_cli-0.4.0}/.gitignore +1 -0
- {trls_cli-0.2.0 → trls_cli-0.4.0}/PKG-INFO +28 -11
- {trls_cli-0.2.0 → trls_cli-0.4.0}/README.md +27 -10
- {trls_cli-0.2.0 → trls_cli-0.4.0}/RELEASING.md +4 -4
- {trls_cli-0.2.0 → trls_cli-0.4.0}/pyproject.toml +1 -1
- {trls_cli-0.2.0 → trls_cli-0.4.0}/src/trls/__init__.py +1 -1
- {trls_cli-0.2.0 → trls_cli-0.4.0}/src/trls/cli.py +47 -2
- trls_cli-0.4.0/src/trls/clipboard.py +108 -0
- {trls_cli-0.2.0 → trls_cli-0.4.0}/src/trls/renderers.py +62 -9
- {trls_cli-0.2.0 → trls_cli-0.4.0}/src/trls/tree.py +7 -1
- {trls_cli-0.2.0 → trls_cli-0.4.0}/tests/test_cli.py +75 -4
- {trls_cli-0.2.0 → trls_cli-0.4.0}/tests/test_tree.py +14 -6
- trls_cli-0.2.0/src/trls/clipboard.py +0 -42
- {trls_cli-0.2.0 → trls_cli-0.4.0}/LICENSE +0 -0
- {trls_cli-0.2.0 → trls_cli-0.4.0}/src/trls/__main__.py +0 -0
- {trls_cli-0.2.0 → trls_cli-0.4.0}/src/trls/snapshot.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trls-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A modern tree-style CLI for humans and AI prompts.
|
|
5
5
|
Author: Yonglin and Yuanben
|
|
6
6
|
License: MIT
|
|
@@ -41,6 +41,8 @@ Classic `tree` output is useful, but `trls` focuses on modern developer workflow
|
|
|
41
41
|
- `prompt` output for AI-friendly, copy-pasteable directory snapshots
|
|
42
42
|
- snapshot diff by default, so each run shows what changed since last time
|
|
43
43
|
- `-c` clipboard copy for a compact, paste-ready LLM format
|
|
44
|
+
- always-on progress feedback during scans in interactive terminals
|
|
45
|
+
- richer colors for common media, office, and data files
|
|
44
46
|
- `text` output for universal shell compatibility
|
|
45
47
|
- `markdown` output for docs and prompt sharing
|
|
46
48
|
- `json` output for tooling and automation
|
|
@@ -75,6 +77,8 @@ Copy a compact LLM-friendly version to the clipboard while keeping the normal te
|
|
|
75
77
|
trls . -c
|
|
76
78
|
```
|
|
77
79
|
|
|
80
|
+
In interactive terminals, `trls` also shows live scan progress so large trees do not feel stuck.
|
|
81
|
+
|
|
78
82
|
Snapshots are saved automatically after each run. If you want to refresh the baseline without showing any diff markers, force-save it explicitly:
|
|
79
83
|
|
|
80
84
|
```bash
|
|
@@ -176,12 +180,9 @@ Clipboard copy output with `-c`:
|
|
|
176
180
|
|
|
177
181
|
```text
|
|
178
182
|
project/
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
~ project/README.md
|
|
183
|
-
+ project/src/new_file.py
|
|
184
|
-
- project/src/old_file.py
|
|
183
|
+
src/trls/: cli.py
|
|
184
|
+
root: ~README.md
|
|
185
|
+
src/: +new_file.py, -old_file.py
|
|
185
186
|
```
|
|
186
187
|
|
|
187
188
|
## Python API
|
|
@@ -205,7 +206,7 @@ Diff markers:
|
|
|
205
206
|
- `-` removed file or directory
|
|
206
207
|
- `~` modified file or directory
|
|
207
208
|
|
|
208
|
-
Current behavior in `v0.
|
|
209
|
+
Current behavior in `v0.4.0`:
|
|
209
210
|
|
|
210
211
|
- every successful run updates the latest snapshot for that path
|
|
211
212
|
- default `trls` output compares against the previous run for that path
|
|
@@ -221,11 +222,21 @@ Use `trls -c` to keep the normal terminal render while also copying a compact pr
|
|
|
221
222
|
Clipboard behavior:
|
|
222
223
|
|
|
223
224
|
- the first line keeps the root directory name
|
|
224
|
-
-
|
|
225
|
-
-
|
|
225
|
+
- later lines group entries by directory, such as `src/: cli.py, tree.py`
|
|
226
|
+
- top-level files are collected under `root:`
|
|
227
|
+
- `+`, `-`, and `~` stay inline with each item to preserve diff meaning
|
|
226
228
|
- the clipboard payload is intentionally different from the terminal render to save tokens
|
|
229
|
+
- WSL-aware clipboard fallbacks are attempted before reporting an error
|
|
230
|
+
|
|
231
|
+
## Progress And Color UX
|
|
227
232
|
|
|
228
|
-
|
|
233
|
+
- interactive runs show a transient progress indicator while scanning
|
|
234
|
+
- image files such as `.jpg` and `.png` have their own color family
|
|
235
|
+
- video files such as `.mp4` have their own color family
|
|
236
|
+
- spreadsheet and office files such as `.xlsx`, `.csv`, `.docx`, and `.pptx` have their own color family
|
|
237
|
+
- data-oriented files such as `.parquet` and `.ipynb` are also colorized
|
|
238
|
+
|
|
239
|
+
## CLI contract for `v0.4.0`
|
|
229
240
|
|
|
230
241
|
The first public release guarantees:
|
|
231
242
|
|
|
@@ -238,6 +249,7 @@ The first public release guarantees:
|
|
|
238
249
|
- snapshots are automatically updated after successful runs
|
|
239
250
|
- snapshot diffing is available via `-save`/`--save-snapshot`, `-diff`/`--diff-last`, `-compare`/`--compare-with`, and `-update`/`--update-snapshot`
|
|
240
251
|
- clipboard copy is available via `-c`/`--copy`
|
|
252
|
+
- interactive scans show live progress feedback
|
|
241
253
|
- directories are listed before files and names are sorted case-insensitively
|
|
242
254
|
- unreadable directories are reported in the output instead of crashing the renderers
|
|
243
255
|
|
|
@@ -278,4 +290,9 @@ Recommended flow:
|
|
|
278
290
|
2. Verify `pip install`, `trls --version`, and one real CLI example.
|
|
279
291
|
3. Publish the tagged release to PyPI with Trusted Publishing.
|
|
280
292
|
|
|
293
|
+
Remove-Item -Recurse -Force .\dist
|
|
294
|
+
python -m pytest
|
|
295
|
+
python -m build
|
|
296
|
+
python -m twine check dist/*
|
|
297
|
+
python -m twine upload --disable-progress-bar dist/*
|
|
281
298
|
See `RELEASING.md` and `.github/workflows/publish.yml` for the release checklist.
|
|
@@ -15,6 +15,8 @@ Classic `tree` output is useful, but `trls` focuses on modern developer workflow
|
|
|
15
15
|
- `prompt` output for AI-friendly, copy-pasteable directory snapshots
|
|
16
16
|
- snapshot diff by default, so each run shows what changed since last time
|
|
17
17
|
- `-c` clipboard copy for a compact, paste-ready LLM format
|
|
18
|
+
- always-on progress feedback during scans in interactive terminals
|
|
19
|
+
- richer colors for common media, office, and data files
|
|
18
20
|
- `text` output for universal shell compatibility
|
|
19
21
|
- `markdown` output for docs and prompt sharing
|
|
20
22
|
- `json` output for tooling and automation
|
|
@@ -49,6 +51,8 @@ Copy a compact LLM-friendly version to the clipboard while keeping the normal te
|
|
|
49
51
|
trls . -c
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
In interactive terminals, `trls` also shows live scan progress so large trees do not feel stuck.
|
|
55
|
+
|
|
52
56
|
Snapshots are saved automatically after each run. If you want to refresh the baseline without showing any diff markers, force-save it explicitly:
|
|
53
57
|
|
|
54
58
|
```bash
|
|
@@ -150,12 +154,9 @@ Clipboard copy output with `-c`:
|
|
|
150
154
|
|
|
151
155
|
```text
|
|
152
156
|
project/
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
~ project/README.md
|
|
157
|
-
+ project/src/new_file.py
|
|
158
|
-
- project/src/old_file.py
|
|
157
|
+
src/trls/: cli.py
|
|
158
|
+
root: ~README.md
|
|
159
|
+
src/: +new_file.py, -old_file.py
|
|
159
160
|
```
|
|
160
161
|
|
|
161
162
|
## Python API
|
|
@@ -179,7 +180,7 @@ Diff markers:
|
|
|
179
180
|
- `-` removed file or directory
|
|
180
181
|
- `~` modified file or directory
|
|
181
182
|
|
|
182
|
-
Current behavior in `v0.
|
|
183
|
+
Current behavior in `v0.4.0`:
|
|
183
184
|
|
|
184
185
|
- every successful run updates the latest snapshot for that path
|
|
185
186
|
- default `trls` output compares against the previous run for that path
|
|
@@ -195,11 +196,21 @@ Use `trls -c` to keep the normal terminal render while also copying a compact pr
|
|
|
195
196
|
Clipboard behavior:
|
|
196
197
|
|
|
197
198
|
- the first line keeps the root directory name
|
|
198
|
-
-
|
|
199
|
-
-
|
|
199
|
+
- later lines group entries by directory, such as `src/: cli.py, tree.py`
|
|
200
|
+
- top-level files are collected under `root:`
|
|
201
|
+
- `+`, `-`, and `~` stay inline with each item to preserve diff meaning
|
|
200
202
|
- the clipboard payload is intentionally different from the terminal render to save tokens
|
|
203
|
+
- WSL-aware clipboard fallbacks are attempted before reporting an error
|
|
204
|
+
|
|
205
|
+
## Progress And Color UX
|
|
201
206
|
|
|
202
|
-
|
|
207
|
+
- interactive runs show a transient progress indicator while scanning
|
|
208
|
+
- image files such as `.jpg` and `.png` have their own color family
|
|
209
|
+
- video files such as `.mp4` have their own color family
|
|
210
|
+
- spreadsheet and office files such as `.xlsx`, `.csv`, `.docx`, and `.pptx` have their own color family
|
|
211
|
+
- data-oriented files such as `.parquet` and `.ipynb` are also colorized
|
|
212
|
+
|
|
213
|
+
## CLI contract for `v0.4.0`
|
|
203
214
|
|
|
204
215
|
The first public release guarantees:
|
|
205
216
|
|
|
@@ -212,6 +223,7 @@ The first public release guarantees:
|
|
|
212
223
|
- snapshots are automatically updated after successful runs
|
|
213
224
|
- snapshot diffing is available via `-save`/`--save-snapshot`, `-diff`/`--diff-last`, `-compare`/`--compare-with`, and `-update`/`--update-snapshot`
|
|
214
225
|
- clipboard copy is available via `-c`/`--copy`
|
|
226
|
+
- interactive scans show live progress feedback
|
|
215
227
|
- directories are listed before files and names are sorted case-insensitively
|
|
216
228
|
- unreadable directories are reported in the output instead of crashing the renderers
|
|
217
229
|
|
|
@@ -252,4 +264,9 @@ Recommended flow:
|
|
|
252
264
|
2. Verify `pip install`, `trls --version`, and one real CLI example.
|
|
253
265
|
3. Publish the tagged release to PyPI with Trusted Publishing.
|
|
254
266
|
|
|
267
|
+
Remove-Item -Recurse -Force .\dist
|
|
268
|
+
python -m pytest
|
|
269
|
+
python -m build
|
|
270
|
+
python -m twine check dist/*
|
|
271
|
+
python -m twine upload --disable-progress-bar dist/*
|
|
255
272
|
See `RELEASING.md` and `.github/workflows/publish.yml` for the release checklist.
|
|
@@ -6,7 +6,7 @@ This repository is prepared for a CLI-first PyPI release.
|
|
|
6
6
|
|
|
7
7
|
- distribution name: `trls-cli`
|
|
8
8
|
- import package: `trls`
|
|
9
|
-
- version: `0.
|
|
9
|
+
- version: `0.4.0`
|
|
10
10
|
- release channel: TestPyPI first, then PyPI
|
|
11
11
|
|
|
12
12
|
At the time of preparation, `https://pypi.org/project/trls-cli/` should be re-checked right before uploading.
|
|
@@ -79,8 +79,8 @@ The workflow already expects the `pypi` environment in GitHub Actions.
|
|
|
79
79
|
After Trusted Publishing is configured:
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
git tag v0.
|
|
83
|
-
git push origin v0.
|
|
82
|
+
git tag v0.4.0
|
|
83
|
+
git push origin v0.4.0
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
The GitHub Actions workflow will:
|
|
@@ -97,4 +97,4 @@ The GitHub Actions workflow will:
|
|
|
97
97
|
3. Add the real repository URLs to `pyproject.toml`.
|
|
98
98
|
4. Ensure README examples still match current CLI behavior.
|
|
99
99
|
5. Upload to TestPyPI and validate installation.
|
|
100
|
-
6. Push the `v0.
|
|
100
|
+
6. Push the `v0.4.0` tag to trigger the production release.
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Sequence
|
|
7
7
|
|
|
8
8
|
from rich.console import Console
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
9
10
|
|
|
10
11
|
from trls import __version__
|
|
11
12
|
from trls.clipboard import copy_text
|
|
@@ -116,8 +117,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
116
117
|
root_path = Path(args.path).expanduser().resolve()
|
|
117
118
|
needs_fingerprints = True
|
|
118
119
|
|
|
119
|
-
current_tree =
|
|
120
|
-
root_path,
|
|
120
|
+
current_tree = _scan_tree_with_progress(
|
|
121
|
+
root_path=root_path,
|
|
121
122
|
max_depth=args.depth,
|
|
122
123
|
show_hidden=args.hidden,
|
|
123
124
|
ignore_patterns=args.ignore,
|
|
@@ -160,6 +161,50 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
160
161
|
|
|
161
162
|
return 0
|
|
162
163
|
|
|
164
|
+
def _scan_tree_with_progress(
|
|
165
|
+
*,
|
|
166
|
+
root_path: Path,
|
|
167
|
+
max_depth: int | None,
|
|
168
|
+
show_hidden: bool,
|
|
169
|
+
ignore_patterns: list[str],
|
|
170
|
+
include_fingerprints: bool,
|
|
171
|
+
):
|
|
172
|
+
if not sys.stderr.isatty():
|
|
173
|
+
return scan_tree(
|
|
174
|
+
root_path,
|
|
175
|
+
max_depth=max_depth,
|
|
176
|
+
show_hidden=show_hidden,
|
|
177
|
+
ignore_patterns=ignore_patterns,
|
|
178
|
+
include_fingerprints=include_fingerprints,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
progress_console = Console(stderr=True)
|
|
182
|
+
with Progress(
|
|
183
|
+
SpinnerColumn(),
|
|
184
|
+
TextColumn("[progress.description]{task.description}"),
|
|
185
|
+
TextColumn("{task.completed} items"),
|
|
186
|
+
TimeElapsedColumn(),
|
|
187
|
+
console=progress_console,
|
|
188
|
+
transient=True,
|
|
189
|
+
) as progress:
|
|
190
|
+
task_id = progress.add_task("Scanning...", total=None)
|
|
191
|
+
|
|
192
|
+
def on_visit(path: Path) -> None:
|
|
193
|
+
progress.update(
|
|
194
|
+
task_id,
|
|
195
|
+
advance=1,
|
|
196
|
+
description=f"Scanning {path.name or str(path)}",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return scan_tree(
|
|
200
|
+
root_path,
|
|
201
|
+
max_depth=max_depth,
|
|
202
|
+
show_hidden=show_hidden,
|
|
203
|
+
ignore_patterns=ignore_patterns,
|
|
204
|
+
include_fingerprints=include_fingerprints,
|
|
205
|
+
on_visit=on_visit,
|
|
206
|
+
)
|
|
207
|
+
|
|
163
208
|
|
|
164
209
|
if __name__ == "__main__":
|
|
165
210
|
raise SystemExit(main())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def copy_text(text: str) -> None:
|
|
11
|
+
backends = _clipboard_backends()
|
|
12
|
+
if not backends:
|
|
13
|
+
raise RuntimeError(
|
|
14
|
+
"No supported clipboard command was found. "
|
|
15
|
+
"On Linux/WSL, install wl-clipboard, xclip, or xsel."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
errors: list[str] = []
|
|
19
|
+
for backend_name, command in backends:
|
|
20
|
+
try:
|
|
21
|
+
_run_clipboard_command(command, text)
|
|
22
|
+
return
|
|
23
|
+
except RuntimeError as exc:
|
|
24
|
+
errors.append(f"{backend_name}: {exc}")
|
|
25
|
+
|
|
26
|
+
attempted_names = ", ".join(name for name, _ in backends)
|
|
27
|
+
error_details = "; ".join(errors)
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
f"No clipboard backend succeeded. Tried: {attempted_names}. {error_details}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clipboard_backends() -> list[tuple[str, list[str]]]:
|
|
34
|
+
system = platform.system()
|
|
35
|
+
backends: list[tuple[str, list[str]]] = []
|
|
36
|
+
|
|
37
|
+
if system == "Windows":
|
|
38
|
+
command = _resolve_command("clip")
|
|
39
|
+
if command:
|
|
40
|
+
backends.append(("clip", [command]))
|
|
41
|
+
return backends
|
|
42
|
+
|
|
43
|
+
if system == "Darwin":
|
|
44
|
+
command = _resolve_command("pbcopy")
|
|
45
|
+
if command:
|
|
46
|
+
backends.append(("pbcopy", [command]))
|
|
47
|
+
return backends
|
|
48
|
+
|
|
49
|
+
if _is_wsl():
|
|
50
|
+
clip_exe = _resolve_command("clip.exe") or _resolve_windows_clip_path()
|
|
51
|
+
if clip_exe:
|
|
52
|
+
backends.append(("clip.exe", [clip_exe]))
|
|
53
|
+
powershell_exe = _resolve_command("powershell.exe")
|
|
54
|
+
if powershell_exe:
|
|
55
|
+
backends.append(
|
|
56
|
+
(
|
|
57
|
+
"powershell.exe Set-Clipboard",
|
|
58
|
+
[powershell_exe, "-NoProfile", "-Command", "Set-Clipboard"],
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
wl_copy = _resolve_command("wl-copy")
|
|
63
|
+
if wl_copy:
|
|
64
|
+
backends.append(("wl-copy", [wl_copy]))
|
|
65
|
+
|
|
66
|
+
xclip = _resolve_command("xclip")
|
|
67
|
+
if xclip:
|
|
68
|
+
backends.append(("xclip", [xclip, "-selection", "clipboard"]))
|
|
69
|
+
|
|
70
|
+
xsel = _resolve_command("xsel")
|
|
71
|
+
if xsel:
|
|
72
|
+
backends.append(("xsel", [xsel, "--clipboard", "--input"]))
|
|
73
|
+
|
|
74
|
+
return backends
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_command(name: str) -> str | None:
|
|
78
|
+
return shutil.which(name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_windows_clip_path() -> str | None:
|
|
82
|
+
candidate = Path("/mnt/c/Windows/System32/clip.exe")
|
|
83
|
+
return str(candidate) if candidate.exists() else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_wsl() -> bool:
|
|
87
|
+
if os.environ.get("WSL_DISTRO_NAME"):
|
|
88
|
+
return True
|
|
89
|
+
try:
|
|
90
|
+
return "microsoft" in Path("/proc/version").read_text(encoding="utf-8").lower()
|
|
91
|
+
except OSError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _run_clipboard_command(command: list[str], text: str) -> None:
|
|
96
|
+
try:
|
|
97
|
+
subprocess.run(
|
|
98
|
+
command,
|
|
99
|
+
input=text,
|
|
100
|
+
text=True,
|
|
101
|
+
check=True,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
)
|
|
104
|
+
except FileNotFoundError as exc:
|
|
105
|
+
raise RuntimeError(f"Clipboard command not found: {command[0]}") from exc
|
|
106
|
+
except subprocess.CalledProcessError as exc:
|
|
107
|
+
stderr = exc.stderr.strip() if exc.stderr else "unknown clipboard error"
|
|
108
|
+
raise RuntimeError(f"Clipboard command failed: {stderr}") from exc
|
|
@@ -16,6 +16,7 @@ FILE_STYLES = {
|
|
|
16
16
|
".md": "magenta",
|
|
17
17
|
".rst": "magenta",
|
|
18
18
|
".txt": "white",
|
|
19
|
+
".csv": "bright_yellow",
|
|
19
20
|
".toml": "yellow",
|
|
20
21
|
".yaml": "yellow",
|
|
21
22
|
".yml": "yellow",
|
|
@@ -23,6 +24,25 @@ FILE_STYLES = {
|
|
|
23
24
|
".ini": "yellow",
|
|
24
25
|
".cfg": "yellow",
|
|
25
26
|
".lock": "yellow",
|
|
27
|
+
".jpg": "bright_magenta",
|
|
28
|
+
".jpeg": "bright_magenta",
|
|
29
|
+
".png": "bright_magenta",
|
|
30
|
+
".gif": "bright_magenta",
|
|
31
|
+
".webp": "bright_magenta",
|
|
32
|
+
".svg": "bright_magenta",
|
|
33
|
+
".mp4": "bright_cyan",
|
|
34
|
+
".mov": "bright_cyan",
|
|
35
|
+
".mkv": "bright_cyan",
|
|
36
|
+
".avi": "bright_cyan",
|
|
37
|
+
".mp3": "bright_blue",
|
|
38
|
+
".wav": "bright_blue",
|
|
39
|
+
".flac": "bright_blue",
|
|
40
|
+
".xlsx": "bright_green",
|
|
41
|
+
".xls": "bright_green",
|
|
42
|
+
".docx": "bright_white",
|
|
43
|
+
".pptx": "bright_red",
|
|
44
|
+
".parquet": "bright_yellow",
|
|
45
|
+
".ipynb": "bright_yellow",
|
|
26
46
|
".sh": "bright_green",
|
|
27
47
|
".ps1": "bright_green",
|
|
28
48
|
".bat": "bright_green",
|
|
@@ -76,8 +96,15 @@ def render_prompt(node: TreeNode) -> str:
|
|
|
76
96
|
|
|
77
97
|
def render_compact_copy(node: TreeNode) -> str:
|
|
78
98
|
lines = [node.display_name]
|
|
99
|
+
grouped_entries: dict[str, list[str]] = {}
|
|
100
|
+
|
|
79
101
|
for child in node.children:
|
|
80
|
-
|
|
102
|
+
_collect_grouped_copy_entries(child, grouped_entries=grouped_entries, parent_dir="")
|
|
103
|
+
|
|
104
|
+
for group_name in sorted(grouped_entries, key=lambda key: (key == "", key.lower())):
|
|
105
|
+
label = "root" if group_name == "" else f"{group_name}/"
|
|
106
|
+
lines.append(f"{label}: {', '.join(grouped_entries[group_name])}")
|
|
107
|
+
|
|
81
108
|
return "\n".join(lines)
|
|
82
109
|
|
|
83
110
|
|
|
@@ -138,14 +165,28 @@ def _append_prompt_lines(node: TreeNode, lines: list[str], depth: int) -> None:
|
|
|
138
165
|
_append_prompt_lines(child, lines, depth + 1)
|
|
139
166
|
|
|
140
167
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
def _collect_grouped_copy_entries(
|
|
169
|
+
node: TreeNode,
|
|
170
|
+
*,
|
|
171
|
+
grouped_entries: dict[str, list[str]],
|
|
172
|
+
parent_dir: str,
|
|
173
|
+
) -> None:
|
|
174
|
+
if node.is_dir and node.children:
|
|
175
|
+
next_parent_dir = node.name if not parent_dir else f"{parent_dir}/{node.name}"
|
|
176
|
+
for child in node.children:
|
|
177
|
+
_collect_grouped_copy_entries(
|
|
178
|
+
child,
|
|
179
|
+
grouped_entries=grouped_entries,
|
|
180
|
+
parent_dir=next_parent_dir,
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
group_name = parent_dir
|
|
185
|
+
entry_name = node.display_name if node.is_dir else node.name
|
|
186
|
+
marker = _diff_marker(node).strip()
|
|
187
|
+
if marker:
|
|
188
|
+
entry_name = f"{marker}{entry_name}"
|
|
189
|
+
grouped_entries.setdefault(group_name, []).append(entry_name)
|
|
149
190
|
|
|
150
191
|
|
|
151
192
|
def _prompt_tag(node: TreeNode) -> str:
|
|
@@ -166,6 +207,18 @@ def _prompt_tag(node: TreeNode) -> str:
|
|
|
166
207
|
return "py"
|
|
167
208
|
if suffix in {".md", ".rst", ".txt"}:
|
|
168
209
|
return "doc"
|
|
210
|
+
if suffix in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}:
|
|
211
|
+
return "image"
|
|
212
|
+
if suffix in {".mp4", ".mov", ".mkv", ".avi"}:
|
|
213
|
+
return "video"
|
|
214
|
+
if suffix in {".mp3", ".wav", ".flac"}:
|
|
215
|
+
return "audio"
|
|
216
|
+
if suffix in {".xlsx", ".xls", ".csv"}:
|
|
217
|
+
return "sheet"
|
|
218
|
+
if suffix in {".docx", ".pptx"}:
|
|
219
|
+
return "office"
|
|
220
|
+
if suffix in {".parquet", ".ipynb"}:
|
|
221
|
+
return "data"
|
|
169
222
|
if suffix in {".toml", ".yaml", ".yml", ".json", ".ini", ".cfg", ".lock"}:
|
|
170
223
|
return "meta"
|
|
171
224
|
if suffix in {".sh", ".ps1", ".bat"}:
|
|
@@ -4,7 +4,7 @@ import hashlib
|
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
from fnmatch import fnmatch
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Literal
|
|
7
|
+
from typing import Any, Callable, Literal
|
|
8
8
|
|
|
9
9
|
DEFAULT_IGNORE_PATTERNS = (".venv", "dist", "__pycache__")
|
|
10
10
|
DiffStatus = Literal["added", "removed", "modified", "unchanged"]
|
|
@@ -72,6 +72,7 @@ def scan_tree(
|
|
|
72
72
|
show_hidden: bool = False,
|
|
73
73
|
ignore_patterns: list[str] | None = None,
|
|
74
74
|
include_fingerprints: bool = False,
|
|
75
|
+
on_visit: Callable[[Path], None] | None = None,
|
|
75
76
|
) -> TreeNode:
|
|
76
77
|
root_path = Path(root).expanduser().resolve()
|
|
77
78
|
if not root_path.exists():
|
|
@@ -86,6 +87,7 @@ def scan_tree(
|
|
|
86
87
|
show_hidden=show_hidden,
|
|
87
88
|
ignore_patterns=patterns,
|
|
88
89
|
include_fingerprints=include_fingerprints,
|
|
90
|
+
on_visit=on_visit,
|
|
89
91
|
)
|
|
90
92
|
|
|
91
93
|
|
|
@@ -98,9 +100,12 @@ def _scan_path(
|
|
|
98
100
|
show_hidden: bool,
|
|
99
101
|
ignore_patterns: tuple[str, ...],
|
|
100
102
|
include_fingerprints: bool,
|
|
103
|
+
on_visit: Callable[[Path], None] | None,
|
|
101
104
|
) -> TreeNode:
|
|
102
105
|
kind = "directory" if path.is_dir() else "file"
|
|
103
106
|
node = TreeNode(name=path.name or str(path), path=str(path), kind=kind)
|
|
107
|
+
if on_visit:
|
|
108
|
+
on_visit(path)
|
|
104
109
|
|
|
105
110
|
if not path.is_dir():
|
|
106
111
|
if include_fingerprints:
|
|
@@ -137,6 +142,7 @@ def _scan_path(
|
|
|
137
142
|
show_hidden=show_hidden,
|
|
138
143
|
ignore_patterns=ignore_patterns,
|
|
139
144
|
include_fingerprints=include_fingerprints,
|
|
145
|
+
on_visit=on_visit,
|
|
140
146
|
)
|
|
141
147
|
)
|
|
142
148
|
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
5
|
from trls import __version__
|
|
6
|
+
import trls.clipboard as clipboard_module
|
|
6
7
|
import trls.cli as cli_module
|
|
7
8
|
from trls.cli import main
|
|
8
9
|
from trls.snapshot import save_snapshot
|
|
@@ -110,8 +111,10 @@ def test_cli_copy_writes_compact_clipboard_output(capsys, monkeypatch, tmp_path)
|
|
|
110
111
|
|
|
111
112
|
assert exit_code == 0
|
|
112
113
|
assert "[dir] demo/" in captured.out
|
|
113
|
-
|
|
114
|
-
assert "demo/
|
|
114
|
+
clipboard_lines = copied["text"].splitlines()
|
|
115
|
+
assert clipboard_lines[0] == "demo/"
|
|
116
|
+
assert "src/: main.py" in copied["text"]
|
|
117
|
+
assert "root: notes.md" in copied["text"]
|
|
115
118
|
assert copied["text"] != captured.out.strip()
|
|
116
119
|
|
|
117
120
|
|
|
@@ -210,8 +213,8 @@ def test_cli_copy_preserves_diff_markers_in_clipboard(capsys, monkeypatch, tmp_p
|
|
|
210
213
|
|
|
211
214
|
assert exit_code == 0
|
|
212
215
|
assert "~ [doc] notes.md" in captured.out
|
|
213
|
-
assert "~
|
|
214
|
-
assert "+
|
|
216
|
+
assert "root: ~notes.md" in copied["text"]
|
|
217
|
+
assert "+helper.py" in copied["text"]
|
|
215
218
|
|
|
216
219
|
|
|
217
220
|
def test_cli_copy_reports_clipboard_failure(capsys, monkeypatch, tmp_path):
|
|
@@ -229,6 +232,38 @@ def test_cli_copy_reports_clipboard_failure(capsys, monkeypatch, tmp_path):
|
|
|
229
232
|
assert "Failed to copy to clipboard: clipboard unavailable" in captured.err
|
|
230
233
|
|
|
231
234
|
|
|
235
|
+
def test_cli_progress_runs_during_scan(capsys, monkeypatch, tmp_path):
|
|
236
|
+
root = create_project(tmp_path)
|
|
237
|
+
progress_updates = []
|
|
238
|
+
|
|
239
|
+
class FakeProgress:
|
|
240
|
+
def __init__(self, *args, **kwargs):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
def __enter__(self):
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, exc_type, exc, tb):
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def add_task(self, description, total=None):
|
|
250
|
+
progress_updates.append(("task", description, total))
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
def update(self, task_id, advance=0, description=""):
|
|
254
|
+
progress_updates.append(("update", task_id, advance, description))
|
|
255
|
+
|
|
256
|
+
monkeypatch.setattr(cli_module, "Progress", FakeProgress)
|
|
257
|
+
monkeypatch.setattr(cli_module.sys.stderr, "isatty", lambda: True)
|
|
258
|
+
|
|
259
|
+
exit_code = main([str(root), "--format", "prompt"])
|
|
260
|
+
captured = capsys.readouterr()
|
|
261
|
+
|
|
262
|
+
assert exit_code == 0
|
|
263
|
+
assert "[dir] demo/" in captured.out
|
|
264
|
+
assert any(event[0] == "update" for event in progress_updates)
|
|
265
|
+
|
|
266
|
+
|
|
232
267
|
def test_cli_can_compare_with_explicit_snapshot(capsys, tmp_path):
|
|
233
268
|
root = create_project(tmp_path)
|
|
234
269
|
snapshot_path = tmp_path / "baseline.json"
|
|
@@ -279,3 +314,39 @@ def test_cli_diff_last_first_run_creates_baseline_without_error(capsys, monkeypa
|
|
|
279
314
|
assert "+ " not in captured.out
|
|
280
315
|
assert "- " not in captured.out
|
|
281
316
|
assert any(snapshot_dir.iterdir())
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_copy_text_uses_clip_exe_in_wsl(monkeypatch):
|
|
320
|
+
calls = []
|
|
321
|
+
|
|
322
|
+
monkeypatch.setattr(clipboard_module, "_is_wsl", lambda: True)
|
|
323
|
+
monkeypatch.setattr(clipboard_module.platform, "system", lambda: "Linux")
|
|
324
|
+
monkeypatch.setattr(
|
|
325
|
+
clipboard_module,
|
|
326
|
+
"_resolve_command",
|
|
327
|
+
lambda name: "clip.exe" if name == "clip.exe" else None,
|
|
328
|
+
)
|
|
329
|
+
monkeypatch.setattr(clipboard_module, "_resolve_windows_clip_path", lambda: None)
|
|
330
|
+
monkeypatch.setattr(
|
|
331
|
+
clipboard_module.subprocess,
|
|
332
|
+
"run",
|
|
333
|
+
lambda command, input, text, check, capture_output: calls.append((command, input)),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
clipboard_module.copy_text("hello")
|
|
337
|
+
|
|
338
|
+
assert calls[0][0] == ["clip.exe"]
|
|
339
|
+
assert calls[0][1] == "hello"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_copy_text_reports_install_hint_when_no_backend_exists(monkeypatch):
|
|
343
|
+
monkeypatch.delenv("WSL_DISTRO_NAME", raising=False)
|
|
344
|
+
monkeypatch.setattr(clipboard_module.platform, "system", lambda: "Linux")
|
|
345
|
+
monkeypatch.setattr(clipboard_module, "_is_wsl", lambda: False)
|
|
346
|
+
monkeypatch.setattr(clipboard_module, "_resolve_command", lambda name: None)
|
|
347
|
+
monkeypatch.setattr(clipboard_module, "_resolve_windows_clip_path", lambda: None)
|
|
348
|
+
|
|
349
|
+
with pytest.raises(RuntimeError) as exc_info:
|
|
350
|
+
clipboard_module.copy_text("hello")
|
|
351
|
+
|
|
352
|
+
assert "install wl-clipboard, xclip, or xsel" in str(exc_info.value)
|
|
@@ -19,6 +19,9 @@ def create_sample_tree(tmp_path):
|
|
|
19
19
|
(project / "src").mkdir()
|
|
20
20
|
(project / "src" / "app.py").write_text("print('hello')\n", encoding="utf-8")
|
|
21
21
|
(project / "README.md").write_text("# demo\n", encoding="utf-8")
|
|
22
|
+
(project / "photo.jpg").write_bytes(b"jpg")
|
|
23
|
+
(project / "movie.mp4").write_bytes(b"mp4")
|
|
24
|
+
(project / "sheet.xlsx").write_bytes(b"xlsx")
|
|
22
25
|
(project / ".env").write_text("SECRET=1\n", encoding="utf-8")
|
|
23
26
|
(project / ".venv").mkdir()
|
|
24
27
|
(project / ".venv" / "pyvenv.cfg").write_text("home = C:/Python\n", encoding="utf-8")
|
|
@@ -65,8 +68,10 @@ def test_renderers_produce_stable_outputs(tmp_path):
|
|
|
65
68
|
json_output = render_json(tree)
|
|
66
69
|
payload = json.loads(json_output)
|
|
67
70
|
|
|
68
|
-
assert "project/"
|
|
69
|
-
assert "
|
|
71
|
+
assert compact_output.splitlines()[0] == "project/"
|
|
72
|
+
assert "src/: app.py" in compact_output
|
|
73
|
+
assert "root: " in compact_output
|
|
74
|
+
assert "photo.jpg" in compact_output
|
|
70
75
|
assert "[dir] project/" in prompt_output
|
|
71
76
|
assert "[doc] README.md" in prompt_output
|
|
72
77
|
assert "README.md" in text_output
|
|
@@ -85,6 +90,9 @@ def test_rich_renderer_applies_styles_by_node_type(tmp_path):
|
|
|
85
90
|
labels = {child.label.plain: child.label.spans[-1].style for child in rich_tree.children}
|
|
86
91
|
assert labels["src/"] == "bold cyan"
|
|
87
92
|
assert labels["README.md"] == "bright_magenta"
|
|
93
|
+
assert labels["photo.jpg"] == "bright_magenta"
|
|
94
|
+
assert labels["movie.mp4"] == "bright_cyan"
|
|
95
|
+
assert labels["sheet.xlsx"] == "bright_green"
|
|
88
96
|
|
|
89
97
|
|
|
90
98
|
def test_snapshot_round_trip_and_diff_statuses(tmp_path):
|
|
@@ -133,7 +141,7 @@ def test_snapshot_round_trip_and_diff_statuses(tmp_path):
|
|
|
133
141
|
assert "~ README.md" in text_output
|
|
134
142
|
|
|
135
143
|
compact_output = render_compact_copy(diff_tree)
|
|
136
|
-
assert "project/"
|
|
137
|
-
assert "
|
|
138
|
-
assert "
|
|
139
|
-
assert "
|
|
144
|
+
assert compact_output.splitlines()[0] == "project/"
|
|
145
|
+
assert "src/: +new.py, -app.py" in compact_output or "src/: -app.py, +new.py" in compact_output
|
|
146
|
+
assert "root: " in compact_output
|
|
147
|
+
assert "~README.md" in compact_output
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import platform
|
|
4
|
-
import shutil
|
|
5
|
-
import subprocess
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def copy_text(text: str) -> None:
|
|
9
|
-
system = platform.system()
|
|
10
|
-
if system == "Windows":
|
|
11
|
-
_run_clipboard_command(["clip"], text)
|
|
12
|
-
return
|
|
13
|
-
if system == "Darwin":
|
|
14
|
-
_run_clipboard_command(["pbcopy"], text)
|
|
15
|
-
return
|
|
16
|
-
if shutil.which("wl-copy"):
|
|
17
|
-
_run_clipboard_command(["wl-copy"], text)
|
|
18
|
-
return
|
|
19
|
-
if shutil.which("xclip"):
|
|
20
|
-
_run_clipboard_command(["xclip", "-selection", "clipboard"], text)
|
|
21
|
-
return
|
|
22
|
-
if shutil.which("xsel"):
|
|
23
|
-
_run_clipboard_command(["xsel", "--clipboard", "--input"], text)
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
raise RuntimeError("No supported clipboard command was found on this system.")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _run_clipboard_command(command: list[str], text: str) -> None:
|
|
30
|
-
try:
|
|
31
|
-
subprocess.run(
|
|
32
|
-
command,
|
|
33
|
-
input=text,
|
|
34
|
-
text=True,
|
|
35
|
-
check=True,
|
|
36
|
-
capture_output=True,
|
|
37
|
-
)
|
|
38
|
-
except FileNotFoundError as exc:
|
|
39
|
-
raise RuntimeError(f"Clipboard command not found: {command[0]}") from exc
|
|
40
|
-
except subprocess.CalledProcessError as exc:
|
|
41
|
-
stderr = exc.stderr.strip() if exc.stderr else "unknown clipboard error"
|
|
42
|
-
raise RuntimeError(f"Clipboard command failed: {stderr}") from exc
|
|
File without changes
|
|
File without changes
|
|
File without changes
|