kopipasta 0.30.0__tar.gz → 0.32.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.

Potentially problematic release.


This version of kopipasta might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.30.0
3
+ Version: 0.32.0
4
4
  Summary: A CLI tool to generate prompts with project structure and file contents
5
5
  Home-page: https://github.com/mkorpela/kopipasta
6
6
  Author: Mikko Korpela
@@ -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 []
@@ -0,0 +1,129 @@
1
+ import fnmatch
2
+ import os
3
+ from typing import List, Optional, Tuple
4
+ from pathlib import Path
5
+
6
+ FileTuple = Tuple[str, bool, Optional[List[str]], str]
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
101
+
102
+ def read_file_contents(file_path):
103
+ try:
104
+ with open(file_path, 'r') as file:
105
+ return file.read()
106
+ except Exception as e:
107
+ print(f"Error reading {file_path}: {e}")
108
+ return ""
109
+
110
+ def is_binary(file_path):
111
+ try:
112
+ with open(file_path, 'rb') as file:
113
+ chunk = file.read(1024)
114
+ if b'\0' in chunk:
115
+ return True
116
+ if file_path.lower().endswith(('.json', '.csv')):
117
+ return False
118
+ return False
119
+ except IOError:
120
+ return False
121
+
122
+ def get_human_readable_size(size):
123
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
124
+ if size < 1024.0:
125
+ return f"{size:.2f} {unit}"
126
+ size /= 1024.0
127
+
128
+ def is_large_file(file_path, threshold=102400):
129
+ return os.path.getsize(file_path) > threshold
@@ -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', '.github', '.gitignore',
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='+', help='Files, directories, or URLs to include in the prompt')
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
- # Add to paths for tree selector
1049
- if os.path.exists(input_path):
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. Press 'h' for help.\n")
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)")
@@ -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) # Always store absolute paths
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
- self.selected = False
23
- self.selected_as_snippet = False
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
- if self.is_root:
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
- # Use current directory as root
58
- root = FileNode(".", True)
59
- root.expanded = True # Always expand root
60
-
61
- # Process each input path
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
- # Single file - add to root
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
- # If the directory is the current directory, scan its contents directly
72
- if abs_path == os.path.abspath("."):
73
- self._scan_directory(abs_path, root)
74
- else:
75
- # Otherwise add the directory as a child
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
- # Special handling for root - show its children at top level
137
- if node.is_root:
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)) # Start children at level 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
- term_width, term_height = shutil.get_terminal_size()
150
+ _, term_height = shutil.get_terminal_size()
159
151
 
160
152
  # Reserve space for header, help panel, and status
161
- available_height = term_height - 15 # Adjust based on your UI
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 PgUp/PgDn: Page G/End: Bottom
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 d: Show dependencies q: Quit selection"""
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="Keys", border_style="dim", expand=False)
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
- if file_node.path not in self.selected_files:
382
- self._toggle_selection(file_node)
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
- if file_node.path in self.selected_files:
387
- self._toggle_selection(file_node)
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 run(self, initial_paths: List[str]) -> Tuple[List[FileTuple], int]:
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: kopipasta
3
- Version: 0.30.0
3
+ Version: 0.32.0
4
4
  Summary: A CLI tool to generate prompts with project structure and file contents
5
5
  Home-page: https://github.com/mkorpela/kopipasta
6
6
  Author: Mikko Korpela
@@ -4,6 +4,7 @@ README.md
4
4
  requirements.txt
5
5
  setup.py
6
6
  kopipasta/__init__.py
7
+ kopipasta/cache.py
7
8
  kopipasta/file.py
8
9
  kopipasta/import_parser.py
9
10
  kopipasta/main.py
@@ -14,4 +15,6 @@ kopipasta.egg-info/SOURCES.txt
14
15
  kopipasta.egg-info/dependency_links.txt
15
16
  kopipasta.egg-info/entry_points.txt
16
17
  kopipasta.egg-info/requires.txt
17
- kopipasta.egg-info/top_level.txt
18
+ kopipasta.egg-info/top_level.txt
19
+ tests/test_file.py
20
+ tests/test_tree_selector.py
@@ -10,7 +10,7 @@ with open("requirements.txt", "r", encoding="utf-8") as f:
10
10
 
11
11
  setup(
12
12
  name="kopipasta",
13
- version="0.30.0",
13
+ version="0.32.0",
14
14
  author="Mikko Korpela",
15
15
  author_email="mikko.korpela@gmail.com",
16
16
  description="A CLI tool to generate prompts with project structure and file contents",
@@ -0,0 +1,65 @@
1
+ import pytest
2
+ from pathlib import Path
3
+ from kopipasta.file import is_ignored
4
+
5
+ @pytest.fixture
6
+ def project_root(tmp_path: Path) -> Path:
7
+ """Creates a mock project structure for testing ignore patterns."""
8
+ project_dir = tmp_path / "project"
9
+ project_dir.mkdir()
10
+
11
+ # Root .gitignore
12
+ (project_dir / ".gitignore").write_text("*.log\nnode_modules/\n")
13
+ (project_dir / "file.log").touch()
14
+ (project_dir / "main.py").touch()
15
+ (project_dir / "node_modules").mkdir()
16
+ (project_dir / "node_modules" / "some_lib").touch()
17
+
18
+ # Subdirectory with its own .gitignore
19
+ sub_dir = project_dir / "src"
20
+ sub_dir.mkdir()
21
+ (sub_dir / ".gitignore").write_text("*.tmp\n__pycache__/\n")
22
+ (sub_dir / "component.js").touch()
23
+ (sub_dir / "component.tmp").touch()
24
+ (sub_dir / "__pycache__").mkdir()
25
+ (sub_dir / "__pycache__" / "cache_file").touch()
26
+
27
+ # Nested subdirectory to test cascading
28
+ nested_dir = sub_dir / "api"
29
+ nested_dir.mkdir()
30
+ (nested_dir / "endpoint.py").touch()
31
+ (nested_dir / "endpoint.log").touch() # Should be ignored by root .gitignore
32
+ (nested_dir / "endpoint.tmp").touch() # Should be ignored by subdir .gitignore
33
+
34
+ return project_dir
35
+
36
+ def test_is_ignored_with_nested_gitignores(project_root: Path):
37
+ """
38
+ Tests that is_ignored correctly respects .gitignore files from the current
39
+ directory up to the project root.
40
+ """
41
+ # Test cases: path, expected_result
42
+ test_cases = [
43
+ # Root level ignores
44
+ ("file.log", True),
45
+ ("main.py", False),
46
+ ("node_modules/some_lib", True),
47
+ ("node_modules", True),
48
+
49
+ # Subdirectory level ignores
50
+ ("src/component.js", False),
51
+ ("src/component.tmp", True),
52
+ ("src/__pycache__/cache_file", True),
53
+ ("src/__pycache__", True),
54
+
55
+ # Nested subdirectory, checking cascading ignores
56
+ ("src/api/endpoint.py", False),
57
+ ("src/api/endpoint.log", True), # Ignored by root .gitignore
58
+ ("src/api/endpoint.tmp", True), # Ignored by src/.gitignore
59
+ ]
60
+
61
+ # The ignore patterns would be dynamically loaded by the new logic,
62
+ # so we pass an empty list and let the function handle discovery.
63
+ for rel_path, expected in test_cases:
64
+ full_path = project_root / rel_path
65
+ assert is_ignored(str(full_path), [], str(project_root)) == expected, f"Failed on path: {rel_path}"
@@ -0,0 +1,47 @@
1
+ import os
2
+ import pytest
3
+ from pathlib import Path
4
+ from kopipasta.tree_selector import TreeSelector
5
+
6
+ @pytest.fixture
7
+ def mock_project(tmp_path: Path) -> Path:
8
+ """Creates a mock project structure for testing TreeSelector."""
9
+ proj = tmp_path / "selector_project"
10
+ proj.mkdir()
11
+ (proj / "main.py").write_text("print('hello')")
12
+ (proj / "README.md").write_text("# Test Project")
13
+ sub = proj / "src"
14
+ sub.mkdir()
15
+ (sub / "component.js").write_text("console.log('test');")
16
+ # Change CWD into the mock project for the duration of the test
17
+ original_cwd = os.getcwd()
18
+ os.chdir(proj)
19
+ yield proj
20
+ os.chdir(original_cwd)
21
+
22
+ def test_preselects_files_from_command_line(mock_project: Path):
23
+ """
24
+ Tests that TreeSelector correctly pre-selects files passed to it.
25
+ """
26
+ main_py_abs = os.path.abspath("main.py")
27
+ component_js_abs = os.path.abspath("src/component.js")
28
+
29
+ files_to_preselect = [main_py_abs, component_js_abs]
30
+
31
+ # Instantiate the selector and manually run the pre-selection logic
32
+ selector = TreeSelector(ignore_patterns=[], project_root_abs=str(mock_project))
33
+
34
+ # We pass all potential paths to build_tree
35
+ selector.root = selector.build_tree(["."])
36
+ selector._preselect_files(files_to_preselect)
37
+
38
+ # Assertions
39
+ assert len(selector.selected_files) == 2
40
+ assert main_py_abs in selector.selected_files
41
+ assert component_js_abs in selector.selected_files
42
+
43
+ assert not selector.selected_files[main_py_abs][0]
44
+ assert not selector.selected_files[component_js_abs][0]
45
+
46
+ expected_char_count = os.path.getsize(main_py_abs) + os.path.getsize(component_js_abs)
47
+ assert selector.char_count == expected_char_count
@@ -1,47 +0,0 @@
1
- import fnmatch
2
- import os
3
- from typing import List, Optional, Tuple
4
-
5
-
6
- FileTuple = Tuple[str, bool, Optional[List[str]], str]
7
-
8
-
9
- def read_file_contents(file_path):
10
- try:
11
- with open(file_path, 'r') as file:
12
- return file.read()
13
- except Exception as e:
14
- print(f"Error reading {file_path}: {e}")
15
- return ""
16
-
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
- def is_binary(file_path):
27
- try:
28
- with open(file_path, 'rb') as file:
29
- chunk = file.read(1024)
30
- if b'\0' in chunk: # null bytes indicate binary file
31
- return True
32
- if file_path.lower().endswith(('.json', '.csv')):
33
- return False
34
- return False
35
- except IOError:
36
- return False
37
-
38
-
39
- def get_human_readable_size(size):
40
- for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
41
- if size < 1024.0:
42
- return f"{size:.2f} {unit}"
43
- size /= 1024.0
44
-
45
-
46
- def is_large_file(file_path, threshold=102400): # 100 KB threshold
47
- return os.path.getsize(file_path) > threshold
File without changes
File without changes
File without changes
File without changes
File without changes