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.
@@ -1,4 +1,5 @@
1
1
  .pytest_cache/
2
+ .env
2
3
  .venv/
3
4
  build/
4
5
  dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trls-cli
3
- Version: 0.2.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
- project/src/
180
- project/src/trls/
181
- project/src/trls/cli.py
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.2.0`:
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
- - every later line uses a full root-prefixed path
225
- - `+`, `-`, and `~` diff markers are preserved
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
- ## CLI contract for `v0.2.0`
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
- project/src/
154
- project/src/trls/
155
- project/src/trls/cli.py
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.2.0`:
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
- - every later line uses a full root-prefixed path
199
- - `+`, `-`, and `~` diff markers are preserved
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
- ## CLI contract for `v0.2.0`
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.2.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.2.0
83
- git push origin v0.2.0
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.2.0` tag to trigger the production release.
100
+ 6. Push the `v0.4.0` tag to trigger the production release.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trls-cli"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "A modern tree-style CLI for humans and AI prompts."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -8,7 +8,7 @@ from trls.renderers import (
8
8
  )
9
9
  from trls.tree import TreeNode, scan_tree
10
10
 
11
- __version__ = "0.2.0"
11
+ __version__ = "0.4.0"
12
12
 
13
13
  __all__ = [
14
14
  "TreeNode",
@@ -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 = scan_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
- _append_compact_copy_lines(child, lines, prefix=node.display_name)
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 _append_compact_copy_lines(node: TreeNode, lines: list[str], prefix: str) -> None:
142
- marker = _diff_marker(node)
143
- full_path = f"{prefix}{node.name}/" if node.is_dir else f"{prefix}{node.name}"
144
- lines.append(f"{marker}{full_path}")
145
-
146
- child_prefix = f"{full_path}" if node.is_dir else prefix
147
- for child in node.children:
148
- _append_compact_copy_lines(child, lines, child_prefix)
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
- assert copied["text"].splitlines()[0] == "demo/"
114
- assert "demo/src/main.py" in copied["text"]
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 "~ demo/notes.md" in copied["text"]
214
- assert "+ demo/src/helper.py" in copied["text"]
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/" in compact_output
69
- assert "project/src/app.py" in compact_output
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/" in compact_output
137
- assert "~ project/README.md" in compact_output
138
- assert "+ project/src/new.py" in compact_output
139
- assert "- project/src/app.py" in compact_output
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