Skip to content

Commit be19f5f

Browse files
committed
feat: add clipboard copy support with -c/--copy and --copy-only flags
1 parent 2ef146a commit be19f5f

File tree

7 files changed

+368
-105
lines changed

7 files changed

+368
-105
lines changed

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,28 @@ treemapper . --max-file-bytes 10000 # skip files larger than 10KB
5050
treemapper . -i custom.ignore # custom ignore patterns
5151
treemapper . --no-default-ignores # disable .gitignore/.treemapperignore (custom -i still works)
5252
treemapper . -v 2 # verbose output (0=ERROR, 1=WARNING, 2=INFO, 3=DEBUG)
53+
treemapper . -c # copy output to clipboard (also outputs to stdout)
54+
treemapper . --copy-only # copy to clipboard only (no stdout output)
5355
treemapper --version # show version
5456
```
5557

58+
## Clipboard Support
59+
60+
Copy output directly to clipboard with `-c` or `--copy`:
61+
62+
```bash
63+
treemapper . -c # copy to clipboard + stdout
64+
treemapper . -c -o tree.yaml # copy to clipboard + save to file
65+
treemapper . --copy-only # copy to clipboard only
66+
treemapper . --copy-only -o tree.yaml # copy to clipboard + save to file (no stdout)
67+
```
68+
69+
**System Requirements:**
70+
- **macOS:** `pbcopy` (pre-installed)
71+
- **Windows:** `clip` (pre-installed)
72+
- **Linux/FreeBSD (Wayland):** `wl-copy` (install: `sudo apt install wl-clipboard`)
73+
- **Linux/FreeBSD (X11):** `xclip` or `xsel` (install: `sudo apt install xclip`)
74+
5675
## Python API
5776

5877
```python

docs/Clipboard Copy.md

Lines changed: 0 additions & 103 deletions
This file was deleted.

src/treemapper/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class ParsedArgs:
1818
max_depth: Optional[int]
1919
no_content: bool
2020
max_file_bytes: Optional[int]
21+
copy: bool
22+
copy_only: bool
2123

2224

2325
DEFAULT_IGNORES_HELP = """
@@ -56,6 +58,10 @@ def parse_args() -> ParsedArgs:
5658
parser.add_argument("--max-depth", type=int, default=None, metavar="N", help="Maximum traversal depth")
5759
parser.add_argument("--no-content", action="store_true", help="Skip file contents (structure only)")
5860
parser.add_argument("--max-file-bytes", type=int, default=None, metavar="N", help="Skip files larger than N bytes")
61+
parser.add_argument("-c", "--copy", action="store_true", help="Copy output to clipboard")
62+
parser.add_argument(
63+
"--copy-only", action="store_true", help="Copy to clipboard, suppress stdout (file output with -o still works)"
64+
)
5965
parser.add_argument(
6066
"-v",
6167
"--verbosity",
@@ -109,4 +115,6 @@ def parse_args() -> ParsedArgs:
109115
max_depth=args.max_depth,
110116
no_content=args.no_content,
111117
max_file_bytes=args.max_file_bytes,
118+
copy=args.copy or args.copy_only,
119+
copy_only=args.copy_only,
112120
)

src/treemapper/clipboard.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import platform
5+
import shutil
6+
import subprocess
7+
8+
9+
class ClipboardError(Exception):
10+
pass
11+
12+
13+
def detect_clipboard_command() -> list[str] | None:
14+
system = platform.system()
15+
if system == "Darwin":
16+
if shutil.which("pbcopy"):
17+
return ["pbcopy"]
18+
return None
19+
if system == "Windows":
20+
if shutil.which("clip"):
21+
return ["clip"]
22+
return None
23+
if system in ("Linux", "FreeBSD"):
24+
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
25+
return ["wl-copy"]
26+
if os.environ.get("DISPLAY"):
27+
if shutil.which("xclip"):
28+
return ["xclip", "-selection", "clipboard"]
29+
if shutil.which("xsel"):
30+
return ["xsel", "--clipboard", "--input"]
31+
return None
32+
return None
33+
34+
35+
def copy_to_clipboard(text: str) -> int:
36+
cmd = detect_clipboard_command()
37+
if cmd is None:
38+
raise ClipboardError("No clipboard tool found")
39+
40+
encoding = "utf-16le" if platform.system() == "Windows" else "utf-8"
41+
encoded = text.encode(encoding)
42+
43+
try:
44+
subprocess.run(
45+
cmd,
46+
input=encoded,
47+
stdout=subprocess.DEVNULL,
48+
stderr=subprocess.PIPE,
49+
timeout=5,
50+
check=True,
51+
)
52+
except subprocess.TimeoutExpired as e:
53+
raise ClipboardError("Clipboard operation timed out") from e
54+
except subprocess.CalledProcessError as e:
55+
stderr_msg = e.stderr.decode(errors="replace").strip() if e.stderr else ""
56+
raise ClipboardError(stderr_msg or f"Command failed with code {e.returncode}") from e
57+
except OSError as e:
58+
raise ClipboardError(f"Failed to execute clipboard command: {e}") from e
59+
60+
return len(encoded)
61+
62+
63+
def clipboard_available() -> bool:
64+
return detect_clipboard_command() is not None

src/treemapper/treemapper.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import errno
2+
import logging
23
import os
34
import sys
45
from contextlib import contextmanager
56
from typing import Generator
67

78

9+
def _format_size(size_bytes: int) -> str:
10+
if size_bytes < 1024:
11+
return f"{size_bytes} B"
12+
return f"{size_bytes / 1024:.1f} KB"
13+
14+
815
@contextmanager
916
def _handle_broken_pipe() -> Generator[None, None, None]:
1017
try:
@@ -34,10 +41,11 @@ def _suppress_broken_pipe() -> None:
3441

3542
def main() -> None:
3643
from .cli import parse_args
44+
from .clipboard import ClipboardError, copy_to_clipboard
3745
from .ignore import get_ignore_specs
3846
from .logger import setup_logging
3947
from .tree import TreeBuildContext, build_tree
40-
from .writer import write_tree_to_file
48+
from .writer import tree_to_string, write_string_to_file, write_tree_to_file
4149

4250
args = parse_args()
4351
setup_logging(args.verbosity)
@@ -57,7 +65,25 @@ def main() -> None:
5765
"children": build_tree(args.root_dir, ctx),
5866
}
5967

60-
if args.output_file is None:
68+
output_content = None
69+
if args.copy:
70+
output_content = tree_to_string(directory_tree, args.output_format)
71+
try:
72+
byte_size = copy_to_clipboard(output_content)
73+
print(f"Copied to clipboard ({_format_size(byte_size)})", file=sys.stderr)
74+
except ClipboardError as e:
75+
logging.warning(f"Clipboard: {e}")
76+
77+
if args.copy_only and args.output_file is None:
78+
return
79+
80+
if output_content is not None:
81+
if args.output_file is None:
82+
with _handle_broken_pipe():
83+
write_string_to_file(output_content, args.output_file, args.output_format)
84+
else:
85+
write_string_to_file(output_content, args.output_file, args.output_format)
86+
elif args.output_file is None:
6187
with _handle_broken_pipe():
6288
write_tree_to_file(directory_tree, args.output_file, args.output_format)
6389
else:

src/treemapper/writer.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,58 @@ def write_tree_text(file: TextIO, tree: Dict[str, Any]) -> None:
9999
_write_tree_text_node(file, child, "", i == len(children) - 1)
100100

101101

102+
def tree_to_string(tree: Dict[str, Any], output_format: str = "yaml") -> str:
103+
buf = io.StringIO()
104+
if output_format == "json":
105+
write_tree_json(buf, tree)
106+
elif output_format == "text":
107+
write_tree_text(buf, tree)
108+
else:
109+
write_tree_yaml(buf, tree)
110+
return buf.getvalue()
111+
112+
113+
def write_string_to_file(content: str, output_file: Optional[Path], output_format: str = "yaml") -> None:
114+
if output_file is None:
115+
try:
116+
buf = sys.stdout.buffer
117+
except AttributeError:
118+
buf = None
119+
120+
try:
121+
if buf:
122+
utf8_stdout = io.TextIOWrapper(buf, encoding="utf-8", newline="")
123+
try:
124+
utf8_stdout.write(content)
125+
utf8_stdout.flush()
126+
finally:
127+
utf8_stdout.detach()
128+
else:
129+
sys.stdout.write(content)
130+
sys.stdout.flush()
131+
except BrokenPipeError:
132+
pass
133+
134+
logging.info(f"Directory tree written to stdout in {output_format} format")
135+
else:
136+
try:
137+
output_file.parent.mkdir(parents=True, exist_ok=True)
138+
139+
if output_file.is_dir():
140+
logging.error(f"Cannot write to '{output_file}': is a directory")
141+
raise IsADirectoryError(f"Is a directory: {output_file}")
142+
143+
with output_file.open("w", encoding="utf-8") as f:
144+
f.write(content)
145+
logging.info(f"Directory tree saved to {output_file} in {output_format} format")
146+
except PermissionError:
147+
logging.error(f"Unable to write to file '{output_file}': Permission denied")
148+
raise
149+
except OSError as e:
150+
logging.error(f"Unable to write to file '{output_file}': {e}")
151+
raise
152+
153+
102154
def write_tree_to_file(tree: Dict[str, Any], output_file: Optional[Path], output_format: str = "yaml") -> None:
103155
def write_tree_content(f: TextIO) -> None:
104156
if output_format == "json":

0 commit comments

Comments
 (0)