Skip to content

Commit a28ad4f

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

File tree

6 files changed

+1609
-104
lines changed

6 files changed

+1609
-104
lines changed

docs/Clipboard Copy.md

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

src/treemapper/cli.py

Lines changed: 6 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,8 @@ 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("--copy-only", action="store_true", help="Copy to clipboard only (no stdout/file output)")
5963
parser.add_argument(
6064
"-v",
6165
"--verbosity",
@@ -109,4 +113,6 @@ def parse_args() -> ParsedArgs:
109113
max_depth=args.max_depth,
110114
no_content=args.no_content,
111115
max_file_bytes=args.max_file_bytes,
116+
copy=args.copy or args.copy_only,
117+
copy_only=args.copy_only,
112118
)

src/treemapper/clipboard.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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") and shutil.which("xclip"):
27+
return ["xclip", "-selection", "clipboard"]
28+
return None
29+
return None
30+
31+
32+
def copy_to_clipboard(text: str) -> None:
33+
cmd = detect_clipboard_command()
34+
if cmd is None:
35+
raise ClipboardError("No clipboard tool found")
36+
37+
system = platform.system()
38+
if system == "Windows":
39+
encoded = text.encode("utf-16le")
40+
else:
41+
encoded = text.encode("utf-8")
42+
43+
try:
44+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
45+
_, stderr = proc.communicate(input=encoded, timeout=5)
46+
if proc.returncode != 0:
47+
raise ClipboardError(stderr.decode(errors="replace").strip() or f"Command failed with code {proc.returncode}")
48+
except subprocess.TimeoutExpired:
49+
proc.kill()
50+
proc.communicate()
51+
raise ClipboardError("Clipboard operation timed out")
52+
except OSError as e:
53+
raise ClipboardError(f"Failed to execute clipboard command: {e}")
54+
55+
56+
def clipboard_available() -> bool:
57+
return detect_clipboard_command() is not None

src/treemapper/treemapper.py

Lines changed: 20 additions & 1 deletion
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_tree_to_file
4149

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

68+
if args.copy:
69+
output_content = tree_to_string(directory_tree, args.output_format)
70+
try:
71+
copy_to_clipboard(output_content)
72+
print(f"Copied to clipboard ({_format_size(len(output_content))})", file=sys.stderr)
73+
except ClipboardError as e:
74+
logging.warning(f"Clipboard: {e}")
75+
76+
if args.copy_only and args.output_file is None:
77+
return
78+
6079
if args.output_file is None:
6180
with _handle_broken_pipe():
6281
write_tree_to_file(directory_tree, args.output_file, args.output_format)

src/treemapper/writer.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ 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+
102113
def write_tree_to_file(tree: Dict[str, Any], output_file: Optional[Path], output_format: str = "yaml") -> None:
103114
def write_tree_content(f: TextIO) -> None:
104115
if output_format == "json":

0 commit comments

Comments
 (0)