kopipasta 0.30.0__py3-none-any.whl → 0.32.0__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.
Potentially problematic release.
This version of kopipasta might be problematic. Click here for more details.
- kopipasta/cache.py +37 -0
- kopipasta/file.py +96 -14
- kopipasta/main.py +36 -7
- kopipasta/tree_selector.py +152 -60
- {kopipasta-0.30.0.dist-info → kopipasta-0.32.0.dist-info}/METADATA +1 -1
- kopipasta-0.32.0.dist-info/RECORD +13 -0
- kopipasta-0.30.0.dist-info/RECORD +0 -12
- {kopipasta-0.30.0.dist-info → kopipasta-0.32.0.dist-info}/LICENSE +0 -0
- {kopipasta-0.30.0.dist-info → kopipasta-0.32.0.dist-info}/WHEEL +0 -0
- {kopipasta-0.30.0.dist-info → kopipasta-0.32.0.dist-info}/entry_points.txt +0 -0
- {kopipasta-0.30.0.dist-info → kopipasta-0.32.0.dist-info}/top_level.txt +0 -0
kopipasta/cache.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
# Define FileTuple for type hinting
|
|
7
|
+
FileTuple = Tuple[str, bool, List[str] | None, str]
|
|
8
|
+
|
|
9
|
+
def get_cache_file_path() -> Path:
|
|
10
|
+
"""Gets the cross-platform path to the cache file for the last selection."""
|
|
11
|
+
cache_dir = Path.home() / ".cache" / "kopipasta"
|
|
12
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
return cache_dir / "last_selection.json"
|
|
14
|
+
|
|
15
|
+
def save_selection_to_cache(files_to_include: List[FileTuple]):
|
|
16
|
+
"""Saves the list of selected file relative paths to the cache."""
|
|
17
|
+
cache_file = get_cache_file_path()
|
|
18
|
+
relative_paths = sorted([os.path.relpath(f[0]) for f in files_to_include])
|
|
19
|
+
try:
|
|
20
|
+
with open(cache_file, 'w', encoding='utf-8') as f:
|
|
21
|
+
json.dump(relative_paths, f, indent=2)
|
|
22
|
+
except IOError as e:
|
|
23
|
+
print(f"\nWarning: Could not save selection to cache: {e}")
|
|
24
|
+
|
|
25
|
+
def load_selection_from_cache() -> List[str]:
|
|
26
|
+
"""Loads the list of selected files from the cache file."""
|
|
27
|
+
cache_file = get_cache_file_path()
|
|
28
|
+
if not cache_file.exists():
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
with open(cache_file, 'r', encoding='utf-8') as f:
|
|
32
|
+
paths = json.load(f)
|
|
33
|
+
# Filter out paths that no longer exist
|
|
34
|
+
return [p for p in paths if os.path.exists(p)]
|
|
35
|
+
except (IOError, json.JSONDecodeError) as e:
|
|
36
|
+
print(f"\nWarning: Could not load previous selection from cache: {e}")
|
|
37
|
+
return []
|
kopipasta/file.py
CHANGED
|
@@ -1,10 +1,103 @@
|
|
|
1
1
|
import fnmatch
|
|
2
2
|
import os
|
|
3
3
|
from typing import List, Optional, Tuple
|
|
4
|
-
|
|
4
|
+
from pathlib import Path
|
|
5
5
|
|
|
6
6
|
FileTuple = Tuple[str, bool, Optional[List[str]], str]
|
|
7
7
|
|
|
8
|
+
# --- Cache for .gitignore patterns ---
|
|
9
|
+
# Key: Directory path
|
|
10
|
+
# Value: List of patterns
|
|
11
|
+
_gitignore_cache: dict[str, list[str]] = {}
|
|
12
|
+
|
|
13
|
+
def _read_gitignore_patterns(gitignore_path: str) -> list[str]:
|
|
14
|
+
"""Reads patterns from a single .gitignore file and caches them."""
|
|
15
|
+
if gitignore_path in _gitignore_cache:
|
|
16
|
+
return _gitignore_cache[gitignore_path]
|
|
17
|
+
if not os.path.isfile(gitignore_path):
|
|
18
|
+
_gitignore_cache[gitignore_path] = []
|
|
19
|
+
return []
|
|
20
|
+
patterns = []
|
|
21
|
+
try:
|
|
22
|
+
with open(gitignore_path, 'r', encoding='utf-8') as f:
|
|
23
|
+
for line in f:
|
|
24
|
+
stripped_line = line.strip()
|
|
25
|
+
if stripped_line and not stripped_line.startswith('#'):
|
|
26
|
+
patterns.append(stripped_line)
|
|
27
|
+
except IOError:
|
|
28
|
+
pass
|
|
29
|
+
_gitignore_cache[gitignore_path] = patterns
|
|
30
|
+
return patterns
|
|
31
|
+
|
|
32
|
+
def is_ignored(path: str, default_ignore_patterns: list[str], project_root: Optional[str] = None) -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Checks if a path should be ignored based on default patterns and .gitignore files.
|
|
35
|
+
Searches for .gitignore from the path's location up to the project_root.
|
|
36
|
+
"""
|
|
37
|
+
path_abs = os.path.abspath(path)
|
|
38
|
+
if project_root is None:
|
|
39
|
+
project_root = os.getcwd()
|
|
40
|
+
project_root_abs = os.path.abspath(project_root)
|
|
41
|
+
|
|
42
|
+
# --- Step 1: Gather all patterns from all relevant .gitignore files ---
|
|
43
|
+
all_patterns = set(default_ignore_patterns)
|
|
44
|
+
|
|
45
|
+
# Determine the directory to start searching for .gitignore files
|
|
46
|
+
search_start_dir = path_abs if os.path.isdir(path_abs) else os.path.dirname(path_abs)
|
|
47
|
+
|
|
48
|
+
current_dir = search_start_dir
|
|
49
|
+
while True:
|
|
50
|
+
gitignore_path = os.path.join(current_dir, ".gitignore")
|
|
51
|
+
patterns_from_file = _read_gitignore_patterns(gitignore_path)
|
|
52
|
+
|
|
53
|
+
if patterns_from_file:
|
|
54
|
+
gitignore_dir_rel = os.path.relpath(current_dir, project_root_abs)
|
|
55
|
+
if gitignore_dir_rel == '.': gitignore_dir_rel = ''
|
|
56
|
+
|
|
57
|
+
for p in patterns_from_file:
|
|
58
|
+
# Patterns with a '/' are relative to the .gitignore file's location.
|
|
59
|
+
# We construct a new pattern relative to the project root.
|
|
60
|
+
if '/' in p:
|
|
61
|
+
all_patterns.add(os.path.join(gitignore_dir_rel, p.lstrip('/')))
|
|
62
|
+
else:
|
|
63
|
+
# Patterns without a '/' (e.g., `*.log`) can match anywhere.
|
|
64
|
+
all_patterns.add(p)
|
|
65
|
+
|
|
66
|
+
if not current_dir.startswith(project_root_abs) or current_dir == project_root_abs:
|
|
67
|
+
break
|
|
68
|
+
parent = os.path.dirname(current_dir)
|
|
69
|
+
if parent == current_dir: break
|
|
70
|
+
current_dir = parent
|
|
71
|
+
|
|
72
|
+
# --- Step 2: Check the path and its parents against the patterns ---
|
|
73
|
+
try:
|
|
74
|
+
path_rel_to_root = os.path.relpath(path_abs, project_root_abs)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return False # Path is outside the project root
|
|
77
|
+
|
|
78
|
+
path_parts = Path(path_rel_to_root).parts
|
|
79
|
+
|
|
80
|
+
for pattern in all_patterns:
|
|
81
|
+
# Check against basename for simple wildcards (e.g., `*.log`, `__pycache__`)
|
|
82
|
+
# This is a primary matching mechanism.
|
|
83
|
+
if fnmatch.fnmatch(os.path.basename(path_abs), pattern):
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
# Check the full path and its parent directories against the pattern.
|
|
87
|
+
# This handles directory ignores (`node_modules/`) and specific path ignores (`src/*.tmp`).
|
|
88
|
+
for i in range(len(path_parts)):
|
|
89
|
+
current_check_path = os.path.join(*path_parts[:i+1])
|
|
90
|
+
|
|
91
|
+
# Handle directory patterns like `node_modules/`
|
|
92
|
+
if pattern.endswith('/'):
|
|
93
|
+
if fnmatch.fnmatch(current_check_path, pattern.rstrip('/')):
|
|
94
|
+
return True
|
|
95
|
+
# Handle full path patterns
|
|
96
|
+
else:
|
|
97
|
+
if fnmatch.fnmatch(current_check_path, pattern):
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
return False
|
|
8
101
|
|
|
9
102
|
def read_file_contents(file_path):
|
|
10
103
|
try:
|
|
@@ -14,20 +107,11 @@ def read_file_contents(file_path):
|
|
|
14
107
|
print(f"Error reading {file_path}: {e}")
|
|
15
108
|
return ""
|
|
16
109
|
|
|
17
|
-
|
|
18
|
-
def is_ignored(path, ignore_patterns):
|
|
19
|
-
path = os.path.normpath(path)
|
|
20
|
-
for pattern in ignore_patterns:
|
|
21
|
-
if fnmatch.fnmatch(os.path.basename(path), pattern) or fnmatch.fnmatch(path, pattern):
|
|
22
|
-
return True
|
|
23
|
-
return False
|
|
24
|
-
|
|
25
|
-
|
|
26
110
|
def is_binary(file_path):
|
|
27
111
|
try:
|
|
28
112
|
with open(file_path, 'rb') as file:
|
|
29
113
|
chunk = file.read(1024)
|
|
30
|
-
if b'\0' in chunk:
|
|
114
|
+
if b'\0' in chunk:
|
|
31
115
|
return True
|
|
32
116
|
if file_path.lower().endswith(('.json', '.csv')):
|
|
33
117
|
return False
|
|
@@ -35,13 +119,11 @@ def is_binary(file_path):
|
|
|
35
119
|
except IOError:
|
|
36
120
|
return False
|
|
37
121
|
|
|
38
|
-
|
|
39
122
|
def get_human_readable_size(size):
|
|
40
123
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
41
124
|
if size < 1024.0:
|
|
42
125
|
return f"{size:.2f} {unit}"
|
|
43
126
|
size /= 1024.0
|
|
44
127
|
|
|
45
|
-
|
|
46
|
-
def is_large_file(file_path, threshold=102400): # 100 KB threshold
|
|
128
|
+
def is_large_file(file_path, threshold=102400):
|
|
47
129
|
return os.path.getsize(file_path) > threshold
|
kopipasta/main.py
CHANGED
|
@@ -18,6 +18,7 @@ from kopipasta.file import FileTuple, get_human_readable_size, is_binary, is_ign
|
|
|
18
18
|
import kopipasta.import_parser as import_parser
|
|
19
19
|
from kopipasta.tree_selector import TreeSelector
|
|
20
20
|
from kopipasta.prompt import generate_prompt_template, get_file_snippet, get_language_for_file
|
|
21
|
+
from kopipasta.cache import save_selection_to_cache
|
|
21
22
|
|
|
22
23
|
def _propose_and_add_dependencies(
|
|
23
24
|
file_just_added: str,
|
|
@@ -136,7 +137,7 @@ def read_gitignore():
|
|
|
136
137
|
'.terraform', 'output', 'poetry.lock', 'package-lock.json', '.env',
|
|
137
138
|
'*.log', '*.bak', '*.swp', '*.swo', '*.tmp', 'tmp', 'temp', 'logs',
|
|
138
139
|
'build', 'target', '.DS_Store', 'Thumbs.db', '*.class', '*.jar',
|
|
139
|
-
'*.war', '*.ear', '*.sqlite', '*.db',
|
|
140
|
+
'*.war', '*.ear', '*.sqlite', '*.db',
|
|
140
141
|
'*.jpg', '*.jpeg', '*.png', '*.gif', '*.bmp', '*.tiff',
|
|
141
142
|
'*.ico', '*.svg', '*.webp', '*.mp3', '*.mp4', '*.avi',
|
|
142
143
|
'*.mov', '*.wmv', '*.flv', '*.pdf', '*.doc', '*.docx',
|
|
@@ -489,7 +490,7 @@ def grep_files_in_directory(pattern: str, directory: str, ignore_patterns: List[
|
|
|
489
490
|
grep_results = []
|
|
490
491
|
|
|
491
492
|
for file in files:
|
|
492
|
-
if is_ignored(file, ignore_patterns) or is_binary(file):
|
|
493
|
+
if is_ignored(file, ignore_patterns, directory) or is_binary(file):
|
|
493
494
|
continue
|
|
494
495
|
|
|
495
496
|
# Get match count and preview lines
|
|
@@ -993,10 +994,14 @@ def open_editor_for_input(template: str, cursor_position: int) -> str:
|
|
|
993
994
|
|
|
994
995
|
def main():
|
|
995
996
|
parser = argparse.ArgumentParser(description="Generate a prompt with project structure, file contents, and web content.")
|
|
996
|
-
parser.add_argument('inputs', nargs='
|
|
997
|
+
parser.add_argument('inputs', nargs='*', help='Files, directories, or URLs to include. Defaults to current directory.')
|
|
997
998
|
parser.add_argument('-t', '--task', help='Task description for the AI prompt')
|
|
998
999
|
args = parser.parse_args()
|
|
999
1000
|
|
|
1001
|
+
# Default to the current directory if no inputs are provided
|
|
1002
|
+
if not args.inputs:
|
|
1003
|
+
args.inputs.append('.')
|
|
1004
|
+
|
|
1000
1005
|
ignore_patterns = read_gitignore()
|
|
1001
1006
|
env_vars = read_env_file()
|
|
1002
1007
|
project_root_abs = os.path.abspath(os.getcwd())
|
|
@@ -1007,6 +1012,7 @@ def main():
|
|
|
1007
1012
|
|
|
1008
1013
|
# Separate URLs from file/directory paths
|
|
1009
1014
|
paths_for_tree = []
|
|
1015
|
+
files_to_preselect = []
|
|
1010
1016
|
|
|
1011
1017
|
for input_path in args.inputs:
|
|
1012
1018
|
if input_path.startswith(('http://', 'https://')):
|
|
@@ -1045,20 +1051,22 @@ def main():
|
|
|
1045
1051
|
print(f"Added {'snippet of ' if is_snippet else ''}web content from: {input_path}")
|
|
1046
1052
|
print_char_count(current_char_count)
|
|
1047
1053
|
else:
|
|
1048
|
-
|
|
1049
|
-
if os.path.exists(
|
|
1054
|
+
abs_path = os.path.abspath(input_path)
|
|
1055
|
+
if os.path.exists(abs_path):
|
|
1050
1056
|
paths_for_tree.append(input_path)
|
|
1057
|
+
if os.path.isfile(abs_path):
|
|
1058
|
+
files_to_preselect.append(abs_path)
|
|
1051
1059
|
else:
|
|
1052
1060
|
print(f"Warning: {input_path} does not exist. Skipping.")
|
|
1053
1061
|
|
|
1054
1062
|
# Use tree selector for file/directory selection
|
|
1055
1063
|
if paths_for_tree:
|
|
1056
1064
|
print("\nStarting interactive file selection...")
|
|
1057
|
-
print("Use arrow keys to navigate, Space to select, 'q' to finish.
|
|
1065
|
+
print("Use arrow keys to navigate, Space to select, 'q' to finish. See all keys below.\n")
|
|
1058
1066
|
|
|
1059
1067
|
tree_selector = TreeSelector(ignore_patterns, project_root_abs)
|
|
1060
1068
|
try:
|
|
1061
|
-
selected_files, file_char_count = tree_selector.run(paths_for_tree)
|
|
1069
|
+
selected_files, file_char_count = tree_selector.run(paths_for_tree, files_to_preselect)
|
|
1062
1070
|
files_to_include.extend(selected_files)
|
|
1063
1071
|
current_char_count += file_char_count
|
|
1064
1072
|
except KeyboardInterrupt:
|
|
@@ -1069,6 +1077,10 @@ def main():
|
|
|
1069
1077
|
print("No files or web content were selected. Exiting.")
|
|
1070
1078
|
return
|
|
1071
1079
|
|
|
1080
|
+
# Save the final selection for the next run
|
|
1081
|
+
if files_to_include:
|
|
1082
|
+
save_selection_to_cache(files_to_include)
|
|
1083
|
+
|
|
1072
1084
|
print("\nFile and web content selection complete.")
|
|
1073
1085
|
print_char_count(current_char_count)
|
|
1074
1086
|
|
|
@@ -1098,8 +1110,25 @@ def main():
|
|
|
1098
1110
|
|
|
1099
1111
|
try:
|
|
1100
1112
|
pyperclip.copy(final_prompt)
|
|
1113
|
+
print("\n--- Included Files & Content ---\n")
|
|
1114
|
+
for file_path, is_snippet, chunks, _ in sorted(files_to_include, key=lambda x: x[0]):
|
|
1115
|
+
details = []
|
|
1116
|
+
if is_snippet:
|
|
1117
|
+
details.append("snippet")
|
|
1118
|
+
if chunks is not None:
|
|
1119
|
+
details.append(f"{len(chunks)} patches")
|
|
1120
|
+
|
|
1121
|
+
detail_str = f" ({', '.join(details)})" if details else ""
|
|
1122
|
+
print(f"- {os.path.relpath(file_path)}{detail_str}")
|
|
1123
|
+
|
|
1124
|
+
for url, (file_tuple, _) in sorted(web_contents.items()):
|
|
1125
|
+
is_snippet = file_tuple[1]
|
|
1126
|
+
detail_str = " (snippet)" if is_snippet else ""
|
|
1127
|
+
print(f"- {url}{detail_str}")
|
|
1128
|
+
|
|
1101
1129
|
separator = "\n" + "=" * 40 + "\n☕🍝 Kopipasta Complete! 🍝☕\n" + "=" * 40 + "\n"
|
|
1102
1130
|
print(separator)
|
|
1131
|
+
|
|
1103
1132
|
final_char_count = len(final_prompt)
|
|
1104
1133
|
final_token_estimate = final_char_count // 4
|
|
1105
1134
|
print(f"Prompt has been copied to clipboard. Final size: {final_char_count} characters (~ {final_token_estimate} tokens)")
|
kopipasta/tree_selector.py
CHANGED
|
@@ -9,31 +9,28 @@ import click
|
|
|
9
9
|
|
|
10
10
|
from kopipasta.file import FileTuple, is_binary, is_ignored, get_human_readable_size
|
|
11
11
|
from kopipasta.prompt import get_file_snippet, get_language_for_file
|
|
12
|
+
from kopipasta.cache import load_selection_from_cache
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class FileNode:
|
|
15
16
|
"""Represents a file or directory in the tree"""
|
|
16
|
-
def __init__(self, path: str, is_dir: bool, parent: Optional['FileNode'] = None):
|
|
17
|
-
self.path = os.path.abspath(path)
|
|
17
|
+
def __init__(self, path: str, is_dir: bool, parent: Optional['FileNode'] = None, is_scan_root: bool = False):
|
|
18
|
+
self.path = os.path.abspath(path)
|
|
18
19
|
self.is_dir = is_dir
|
|
19
20
|
self.parent = parent
|
|
20
21
|
self.children: List['FileNode'] = []
|
|
21
22
|
self.expanded = False
|
|
22
|
-
|
|
23
|
-
self.
|
|
23
|
+
# This flag marks the invisible root of the file tree, which is not meant to be displayed.
|
|
24
|
+
self.is_scan_root = is_scan_root
|
|
24
25
|
self.size = 0 if is_dir else os.path.getsize(self.path)
|
|
25
|
-
self.is_root = path == "." # Mark if this is the root node
|
|
26
26
|
|
|
27
27
|
@property
|
|
28
28
|
def name(self):
|
|
29
|
-
if self.is_root:
|
|
30
|
-
return "." # Show root as "." instead of directory name
|
|
31
29
|
return os.path.basename(self.path) or self.path
|
|
32
30
|
|
|
33
31
|
@property
|
|
34
32
|
def relative_path(self):
|
|
35
|
-
|
|
36
|
-
return "."
|
|
33
|
+
# os.path.relpath is relative to the current working directory by default
|
|
37
34
|
return os.path.relpath(self.path)
|
|
38
35
|
|
|
39
36
|
|
|
@@ -53,33 +50,33 @@ class TreeSelector:
|
|
|
53
50
|
self.viewport_offset = 0 # First visible item index
|
|
54
51
|
|
|
55
52
|
def build_tree(self, paths: List[str]) -> FileNode:
|
|
56
|
-
"""Build tree structure from given paths"""
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
"""Build tree structure from given paths."""
|
|
54
|
+
# If one directory is given, make its contents the top level of the tree.
|
|
55
|
+
if len(paths) == 1 and os.path.isdir(paths[0]):
|
|
56
|
+
root_path = os.path.abspath(paths[0])
|
|
57
|
+
root = FileNode(root_path, True, is_scan_root=True)
|
|
58
|
+
root.expanded = True
|
|
59
|
+
self._scan_directory(root_path, root)
|
|
60
|
+
return root
|
|
61
|
+
|
|
62
|
+
# Otherwise, create a virtual root to hold multiple items (e.g., `kopipasta file.py dir/`).
|
|
63
|
+
# This virtual root itself won't be displayed.
|
|
64
|
+
virtual_root_path = os.path.join(self.project_root_abs, "__kopipasta_virtual_root__")
|
|
65
|
+
root = FileNode(virtual_root_path, True, is_scan_root=True)
|
|
66
|
+
root.expanded = True
|
|
67
|
+
|
|
62
68
|
for path in paths:
|
|
63
69
|
abs_path = os.path.abspath(path)
|
|
64
|
-
|
|
70
|
+
node = None
|
|
65
71
|
if os.path.isfile(abs_path):
|
|
66
|
-
|
|
67
|
-
if not is_ignored(abs_path, self.ignore_patterns) and not is_binary(abs_path):
|
|
72
|
+
if not is_ignored(abs_path, self.ignore_patterns, self.project_root_abs) and not is_binary(abs_path):
|
|
68
73
|
node = FileNode(abs_path, False, root)
|
|
69
|
-
root.children.append(node)
|
|
70
74
|
elif os.path.isdir(abs_path):
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
dir_node = FileNode(abs_path, True, root)
|
|
77
|
-
root.children.append(dir_node)
|
|
78
|
-
# Auto-expand if it's the only child
|
|
79
|
-
if len(paths) == 1:
|
|
80
|
-
dir_node.expanded = True
|
|
81
|
-
self._scan_directory(abs_path, dir_node)
|
|
82
|
-
|
|
75
|
+
node = FileNode(abs_path, True, root)
|
|
76
|
+
|
|
77
|
+
if node:
|
|
78
|
+
root.children.append(node)
|
|
79
|
+
|
|
83
80
|
return root
|
|
84
81
|
|
|
85
82
|
def _scan_directory(self, dir_path: str, parent_node: FileNode):
|
|
@@ -101,7 +98,7 @@ class TreeSelector:
|
|
|
101
98
|
|
|
102
99
|
for item in items:
|
|
103
100
|
item_path = os.path.join(abs_dir_path, item)
|
|
104
|
-
if is_ignored(item_path, self.ignore_patterns):
|
|
101
|
+
if is_ignored(item_path, self.ignore_patterns, self.project_root_abs):
|
|
105
102
|
continue
|
|
106
103
|
|
|
107
104
|
if os.path.isdir(item_path):
|
|
@@ -130,23 +127,18 @@ class TreeSelector:
|
|
|
130
127
|
parent_node.children.append(file_node)
|
|
131
128
|
|
|
132
129
|
def _flatten_tree(self, node: FileNode, level: int = 0) -> List[Tuple[FileNode, int]]:
|
|
133
|
-
"""Flatten tree into a list of (node, level) tuples for display"""
|
|
130
|
+
"""Flatten tree into a list of (node, level) tuples for display."""
|
|
134
131
|
result = []
|
|
135
132
|
|
|
136
|
-
#
|
|
137
|
-
if node.
|
|
138
|
-
# Don't include the root node itself in the display
|
|
133
|
+
# If it's the special root node, don't display it. Display its children at the top level.
|
|
134
|
+
if node.is_scan_root:
|
|
139
135
|
for child in node.children:
|
|
140
|
-
result.extend(self._flatten_tree(child, 0))
|
|
136
|
+
result.extend(self._flatten_tree(child, 0))
|
|
141
137
|
else:
|
|
142
|
-
# Include this node
|
|
143
138
|
result.append((node, level))
|
|
144
|
-
|
|
145
139
|
if node.is_dir and node.expanded:
|
|
146
|
-
# Load children on demand if not loaded
|
|
147
140
|
if not node.children:
|
|
148
141
|
self._scan_directory(node.path, node)
|
|
149
|
-
|
|
150
142
|
for child in node.children:
|
|
151
143
|
result.extend(self._flatten_tree(child, level + 1))
|
|
152
144
|
|
|
@@ -155,10 +147,10 @@ class TreeSelector:
|
|
|
155
147
|
def _build_display_tree(self) -> Tree:
|
|
156
148
|
"""Build Rich tree for display with viewport"""
|
|
157
149
|
# Get terminal size
|
|
158
|
-
|
|
150
|
+
_, term_height = shutil.get_terminal_size()
|
|
159
151
|
|
|
160
152
|
# Reserve space for header, help panel, and status
|
|
161
|
-
available_height = term_height -
|
|
153
|
+
available_height = term_height - 8
|
|
162
154
|
available_height = max(5, available_height) # Minimum height
|
|
163
155
|
|
|
164
156
|
# Flatten tree to get all visible nodes
|
|
@@ -253,15 +245,16 @@ class TreeSelector:
|
|
|
253
245
|
def _show_help(self) -> Panel:
|
|
254
246
|
"""Create help panel"""
|
|
255
247
|
help_text = """[bold]Navigation:[/bold]
|
|
256
|
-
↑/k: Up ↓/j: Down →/l/Enter: Expand ←/h: Collapse
|
|
248
|
+
↑/k: Up ↓/j: Down →/l/Enter: Expand ←/h: Collapse
|
|
257
249
|
|
|
258
250
|
[bold]Selection:[/bold]
|
|
259
251
|
Space: Toggle file/dir a: Add all in dir s: Snippet mode
|
|
260
252
|
|
|
261
253
|
[bold]Actions:[/bold]
|
|
262
|
-
g: Grep in directory
|
|
254
|
+
r: Reuse last selection g: Grep in directory d: Show dependencies
|
|
255
|
+
q: Quit and finalize"""
|
|
263
256
|
|
|
264
|
-
return Panel(help_text, title="
|
|
257
|
+
return Panel(help_text, title="Keyboard Controls", border_style="dim", expand=False)
|
|
265
258
|
|
|
266
259
|
def _get_status_bar(self) -> str:
|
|
267
260
|
"""Create status bar with selection info"""
|
|
@@ -336,10 +329,7 @@ g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
|
336
329
|
# Unselect
|
|
337
330
|
is_snippet, _ = self.selected_files[abs_path]
|
|
338
331
|
del self.selected_files[abs_path]
|
|
339
|
-
if is_snippet
|
|
340
|
-
self.char_count -= len(get_file_snippet(node.path))
|
|
341
|
-
else:
|
|
342
|
-
self.char_count -= node.size
|
|
332
|
+
self.char_count -= len(get_file_snippet(node.path)) if is_snippet else node.size
|
|
343
333
|
else:
|
|
344
334
|
# Select
|
|
345
335
|
if snippet_mode or (node.size > 102400 and not self._confirm_large_file(node)):
|
|
@@ -352,7 +342,7 @@ g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
|
352
342
|
self.char_count += node.size
|
|
353
343
|
|
|
354
344
|
def _toggle_directory(self, node: FileNode):
|
|
355
|
-
"""Toggle all files in a directory"""
|
|
345
|
+
"""Toggle all files in a directory, now fully recursive."""
|
|
356
346
|
if not node.is_dir:
|
|
357
347
|
return
|
|
358
348
|
|
|
@@ -365,6 +355,9 @@ g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
|
365
355
|
|
|
366
356
|
def collect_files(n: FileNode):
|
|
367
357
|
if n.is_dir:
|
|
358
|
+
# CRITICAL FIX: Ensure sub-directory children are loaded before recursing
|
|
359
|
+
if not n.children:
|
|
360
|
+
self._scan_directory(n.path, n)
|
|
368
361
|
for child in n.children:
|
|
369
362
|
collect_files(child)
|
|
370
363
|
else:
|
|
@@ -376,16 +369,92 @@ g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
|
376
369
|
any_unselected = any(os.path.abspath(f.path) not in self.selected_files for f in all_files)
|
|
377
370
|
|
|
378
371
|
if any_unselected:
|
|
379
|
-
# Select all unselected
|
|
372
|
+
# Select all unselected files
|
|
380
373
|
for file_node in all_files:
|
|
381
|
-
|
|
382
|
-
|
|
374
|
+
abs_path = os.path.abspath(file_node.path)
|
|
375
|
+
if abs_path not in self.selected_files:
|
|
376
|
+
self.selected_files[abs_path] = (False, None)
|
|
377
|
+
self.char_count += file_node.size
|
|
383
378
|
else:
|
|
384
|
-
# Unselect all
|
|
379
|
+
# Unselect all files
|
|
385
380
|
for file_node in all_files:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
381
|
+
abs_path = os.path.abspath(file_node.path)
|
|
382
|
+
if abs_path in self.selected_files:
|
|
383
|
+
is_snippet, _ = self.selected_files[abs_path]
|
|
384
|
+
del self.selected_files[abs_path]
|
|
385
|
+
if is_snippet:
|
|
386
|
+
self.char_count -= len(get_file_snippet(file_node.path))
|
|
387
|
+
else:
|
|
388
|
+
self.char_count -= file_node.size
|
|
389
|
+
|
|
390
|
+
def _propose_and_apply_last_selection(self):
|
|
391
|
+
"""Loads paths from cache, shows a confirmation dialog, and applies the selection if confirmed."""
|
|
392
|
+
cached_paths = load_selection_from_cache()
|
|
393
|
+
|
|
394
|
+
if not cached_paths:
|
|
395
|
+
self.console.print(Panel("[yellow]No cached selection found to reuse.[/yellow]", title="Info", border_style="dim"))
|
|
396
|
+
click.pause("Press any key to continue...")
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
# Categorize cached paths for the preview
|
|
400
|
+
files_to_add = []
|
|
401
|
+
files_already_selected = []
|
|
402
|
+
files_not_found = []
|
|
403
|
+
|
|
404
|
+
for rel_path in cached_paths:
|
|
405
|
+
abs_path = os.path.abspath(rel_path)
|
|
406
|
+
if not os.path.isfile(abs_path):
|
|
407
|
+
files_not_found.append(rel_path)
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
if abs_path in self.selected_files:
|
|
411
|
+
files_already_selected.append(rel_path)
|
|
412
|
+
else:
|
|
413
|
+
files_to_add.append(rel_path)
|
|
414
|
+
|
|
415
|
+
# Build the rich text for the confirmation panel
|
|
416
|
+
preview_text = Text()
|
|
417
|
+
if files_to_add:
|
|
418
|
+
preview_text.append("The following files will be ADDED:\n", style="bold")
|
|
419
|
+
for path in sorted(files_to_add):
|
|
420
|
+
preview_text.append(" ")
|
|
421
|
+
preview_text.append("+", style="cyan")
|
|
422
|
+
preview_text.append(f" {path}\n")
|
|
423
|
+
|
|
424
|
+
if files_already_selected:
|
|
425
|
+
preview_text.append("\nAlready selected (no change):\n", style="bold dim")
|
|
426
|
+
for path in sorted(files_already_selected):
|
|
427
|
+
preview_text.append(f" ✓ {path}\n")
|
|
428
|
+
|
|
429
|
+
if files_not_found:
|
|
430
|
+
preview_text.append("\nNot found on disk (will be skipped):\n", style="bold dim")
|
|
431
|
+
for path in sorted(files_not_found):
|
|
432
|
+
preview_text.append(" ")
|
|
433
|
+
preview_text.append("-", style="red")
|
|
434
|
+
preview_text.append(f" {path}\n")
|
|
435
|
+
|
|
436
|
+
# Display the confirmation panel and prompt
|
|
437
|
+
self.console.clear()
|
|
438
|
+
self.console.print(Panel(preview_text, title="[bold cyan]Reuse Last Selection?", border_style="cyan", padding=(1, 2)))
|
|
439
|
+
|
|
440
|
+
if not files_to_add:
|
|
441
|
+
self.console.print("\n[yellow]No new files to add from the last selection.[/yellow]")
|
|
442
|
+
click.pause("Press any key to continue...")
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
# Use click.confirm for a simple and effective y/n prompt
|
|
446
|
+
if not click.confirm(f"\nAdd {len(files_to_add)} file(s) to your current selection?", default=True):
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
# If confirmed, apply the changes
|
|
450
|
+
for rel_path in files_to_add:
|
|
451
|
+
abs_path = os.path.abspath(rel_path)
|
|
452
|
+
if os.path.isfile(abs_path) and abs_path not in self.selected_files:
|
|
453
|
+
file_size = os.path.getsize(abs_path)
|
|
454
|
+
self.selected_files[abs_path] = (False, None)
|
|
455
|
+
self.char_count += file_size
|
|
456
|
+
self._ensure_path_visible(abs_path)
|
|
457
|
+
|
|
389
458
|
def _ensure_path_visible(self, file_path: str):
|
|
390
459
|
"""Ensure a file path is visible in the tree by expanding parent directories"""
|
|
391
460
|
abs_file_path = os.path.abspath(file_path)
|
|
@@ -458,10 +527,32 @@ g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
|
458
527
|
|
|
459
528
|
self.char_count += deps_char_count
|
|
460
529
|
|
|
461
|
-
def
|
|
530
|
+
def _preselect_files(self, files_to_preselect: List[str]):
|
|
531
|
+
"""Pre-selects a list of files passed from the command line."""
|
|
532
|
+
if not files_to_preselect:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
added_count = 0
|
|
536
|
+
for file_path in files_to_preselect:
|
|
537
|
+
abs_path = os.path.abspath(file_path)
|
|
538
|
+
if abs_path in self.selected_files:
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
# This check is simpler than a full tree walk and sufficient here
|
|
542
|
+
if os.path.isfile(abs_path) and not is_binary(abs_path):
|
|
543
|
+
file_size = os.path.getsize(abs_path)
|
|
544
|
+
self.selected_files[abs_path] = (False, None) # (is_snippet=False, chunks=None)
|
|
545
|
+
self.char_count += file_size
|
|
546
|
+
added_count += 1
|
|
547
|
+
self._ensure_path_visible(abs_path)
|
|
548
|
+
|
|
549
|
+
def run(self, initial_paths: List[str], files_to_preselect: Optional[List[str]] = None) -> Tuple[List[FileTuple], int]:
|
|
462
550
|
"""Run the interactive tree selector"""
|
|
463
551
|
self.root = self.build_tree(initial_paths)
|
|
464
552
|
|
|
553
|
+
if files_to_preselect:
|
|
554
|
+
self._preselect_files(files_to_preselect)
|
|
555
|
+
|
|
465
556
|
# Don't use Live mode, instead manually control the display
|
|
466
557
|
while not self.quit_selection:
|
|
467
558
|
# Clear and redraw
|
|
@@ -529,10 +620,11 @@ g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
|
529
620
|
self._toggle_directory(current_node)
|
|
530
621
|
|
|
531
622
|
# Handle actions
|
|
623
|
+
elif key == 'r': # Reuse last selection
|
|
624
|
+
self._propose_and_apply_last_selection()
|
|
532
625
|
elif key == 'g': # Grep
|
|
533
626
|
self.console.print() # Add some space
|
|
534
627
|
self._handle_grep(current_node)
|
|
535
|
-
click.pause("Press any key to continue...")
|
|
536
628
|
elif key == 'd': # Dependencies
|
|
537
629
|
self.console.print() # Add some space
|
|
538
630
|
self._show_dependencies(current_node)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
kopipasta/cache.py,sha256=rH52yX3g7p22_1NXmrBDcBEntm3ivMjtV8n8zxTLY1k,1454
|
|
3
|
+
kopipasta/file.py,sha256=tPCLNvSXHzIvAXLB6fgJswLpCFce7T1QnbNdSWHbIso,4829
|
|
4
|
+
kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
|
|
5
|
+
kopipasta/main.py,sha256=YzzEnDph1v3NojHYIjvD3Z4blnIEX5zqj9W6gNLlA7E,50058
|
|
6
|
+
kopipasta/prompt.py,sha256=fOCuJVTLUfR0fjKf5qIlnl_3pNsKNKsvt3C8f4tsmxk,6889
|
|
7
|
+
kopipasta/tree_selector.py,sha256=x0CPzPg7dM7jxMU_zt-Qm3r8c3vXQTkFwYznnqNmvns,28206
|
|
8
|
+
kopipasta-0.32.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
|
|
9
|
+
kopipasta-0.32.0.dist-info/METADATA,sha256=6OfldjbTdoYLonCqg0lGpIHurXNgR8ujCnebGx05oxs,4894
|
|
10
|
+
kopipasta-0.32.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
11
|
+
kopipasta-0.32.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
|
|
12
|
+
kopipasta-0.32.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
|
|
13
|
+
kopipasta-0.32.0.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
kopipasta/file.py,sha256=2HEoNczbKH3TtLO0zYUcZfUoHqb0-o83xFUOgY9rG-Y,1244
|
|
3
|
-
kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
|
|
4
|
-
kopipasta/main.py,sha256=MlfmP_eG0ZyvdhxdGyPsN2TxqnM4hq9pa2ToQEbbNP4,48894
|
|
5
|
-
kopipasta/prompt.py,sha256=fOCuJVTLUfR0fjKf5qIlnl_3pNsKNKsvt3C8f4tsmxk,6889
|
|
6
|
-
kopipasta/tree_selector.py,sha256=hoS2yENrI6cLJ8s3ZXesctZkn3kAx4LwpD2cnp_ilSc,23900
|
|
7
|
-
kopipasta-0.30.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
|
|
8
|
-
kopipasta-0.30.0.dist-info/METADATA,sha256=Ol5GwqwEazcTaH_dsMc18We53YLIeSWRlgNT2CeLfWo,4894
|
|
9
|
-
kopipasta-0.30.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
10
|
-
kopipasta-0.30.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
|
|
11
|
-
kopipasta-0.30.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
|
|
12
|
-
kopipasta-0.30.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|