kopipasta 0.28.0__py3-none-any.whl → 0.30.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.
- kopipasta/file.py +47 -0
- kopipasta/main.py +375 -288
- kopipasta/prompt.py +163 -0
- kopipasta/tree_selector.py +559 -0
- {kopipasta-0.28.0.dist-info → kopipasta-0.30.0.dist-info}/METADATA +3 -1
- kopipasta-0.30.0.dist-info/RECORD +12 -0
- kopipasta-0.28.0.dist-info/RECORD +0 -9
- {kopipasta-0.28.0.dist-info → kopipasta-0.30.0.dist-info}/LICENSE +0 -0
- {kopipasta-0.28.0.dist-info → kopipasta-0.30.0.dist-info}/WHEEL +0 -0
- {kopipasta-0.28.0.dist-info → kopipasta-0.30.0.dist-info}/entry_points.txt +0 -0
- {kopipasta-0.28.0.dist-info → kopipasta-0.30.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from typing import Dict, List, Optional, Tuple
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.tree import Tree
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from kopipasta.file import FileTuple, is_binary, is_ignored, get_human_readable_size
|
|
11
|
+
from kopipasta.prompt import get_file_snippet, get_language_for_file
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileNode:
|
|
15
|
+
"""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
|
|
18
|
+
self.is_dir = is_dir
|
|
19
|
+
self.parent = parent
|
|
20
|
+
self.children: List['FileNode'] = []
|
|
21
|
+
self.expanded = False
|
|
22
|
+
self.selected = False
|
|
23
|
+
self.selected_as_snippet = False
|
|
24
|
+
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
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self):
|
|
29
|
+
if self.is_root:
|
|
30
|
+
return "." # Show root as "." instead of directory name
|
|
31
|
+
return os.path.basename(self.path) or self.path
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def relative_path(self):
|
|
35
|
+
if self.is_root:
|
|
36
|
+
return "."
|
|
37
|
+
return os.path.relpath(self.path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TreeSelector:
|
|
41
|
+
"""Interactive file tree selector using Rich"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, ignore_patterns: List[str], project_root_abs: str):
|
|
44
|
+
self.console = Console()
|
|
45
|
+
self.ignore_patterns = ignore_patterns
|
|
46
|
+
self.project_root_abs = project_root_abs
|
|
47
|
+
self.selected_files: Dict[str, Tuple[bool, Optional[List[str]]]] = {} # path -> (is_snippet, chunks)
|
|
48
|
+
self.current_index = 0
|
|
49
|
+
self.nodes: List[FileNode] = []
|
|
50
|
+
self.visible_nodes: List[FileNode] = []
|
|
51
|
+
self.char_count = 0
|
|
52
|
+
self.quit_selection = False
|
|
53
|
+
self.viewport_offset = 0 # First visible item index
|
|
54
|
+
|
|
55
|
+
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
|
|
62
|
+
for path in paths:
|
|
63
|
+
abs_path = os.path.abspath(path)
|
|
64
|
+
|
|
65
|
+
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):
|
|
68
|
+
node = FileNode(abs_path, False, root)
|
|
69
|
+
root.children.append(node)
|
|
70
|
+
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
|
+
|
|
83
|
+
return root
|
|
84
|
+
|
|
85
|
+
def _scan_directory(self, dir_path: str, parent_node: FileNode):
|
|
86
|
+
"""Recursively scan directory and build tree"""
|
|
87
|
+
abs_dir_path = os.path.abspath(dir_path)
|
|
88
|
+
|
|
89
|
+
# Check if we've already scanned this directory
|
|
90
|
+
if parent_node.children:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
items = sorted(os.listdir(abs_dir_path))
|
|
95
|
+
except PermissionError:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Separate and sort directories and files
|
|
99
|
+
dirs = []
|
|
100
|
+
files = []
|
|
101
|
+
|
|
102
|
+
for item in items:
|
|
103
|
+
item_path = os.path.join(abs_dir_path, item)
|
|
104
|
+
if is_ignored(item_path, self.ignore_patterns):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if os.path.isdir(item_path):
|
|
108
|
+
dirs.append(item)
|
|
109
|
+
elif os.path.isfile(item_path) and not is_binary(item_path):
|
|
110
|
+
files.append(item)
|
|
111
|
+
|
|
112
|
+
# Add directories first
|
|
113
|
+
for dir_name in sorted(dirs):
|
|
114
|
+
dir_path_full = os.path.join(abs_dir_path, dir_name)
|
|
115
|
+
# Check if this node already exists as a child
|
|
116
|
+
existing = next((child for child in parent_node.children
|
|
117
|
+
if os.path.abspath(child.path) == os.path.abspath(dir_path_full)), None)
|
|
118
|
+
if not existing:
|
|
119
|
+
dir_node = FileNode(dir_path_full, True, parent_node)
|
|
120
|
+
parent_node.children.append(dir_node)
|
|
121
|
+
|
|
122
|
+
# Then add files
|
|
123
|
+
for file_name in sorted(files):
|
|
124
|
+
file_path = os.path.join(abs_dir_path, file_name)
|
|
125
|
+
# Check if this node already exists as a child
|
|
126
|
+
existing = next((child for child in parent_node.children
|
|
127
|
+
if os.path.abspath(child.path) == os.path.abspath(file_path)), None)
|
|
128
|
+
if not existing:
|
|
129
|
+
file_node = FileNode(file_path, False, parent_node)
|
|
130
|
+
parent_node.children.append(file_node)
|
|
131
|
+
|
|
132
|
+
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"""
|
|
134
|
+
result = []
|
|
135
|
+
|
|
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
|
|
139
|
+
for child in node.children:
|
|
140
|
+
result.extend(self._flatten_tree(child, 0)) # Start children at level 0
|
|
141
|
+
else:
|
|
142
|
+
# Include this node
|
|
143
|
+
result.append((node, level))
|
|
144
|
+
|
|
145
|
+
if node.is_dir and node.expanded:
|
|
146
|
+
# Load children on demand if not loaded
|
|
147
|
+
if not node.children:
|
|
148
|
+
self._scan_directory(node.path, node)
|
|
149
|
+
|
|
150
|
+
for child in node.children:
|
|
151
|
+
result.extend(self._flatten_tree(child, level + 1))
|
|
152
|
+
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
def _build_display_tree(self) -> Tree:
|
|
156
|
+
"""Build Rich tree for display with viewport"""
|
|
157
|
+
# Get terminal size
|
|
158
|
+
term_width, term_height = shutil.get_terminal_size()
|
|
159
|
+
|
|
160
|
+
# Reserve space for header, help panel, and status
|
|
161
|
+
available_height = term_height - 15 # Adjust based on your UI
|
|
162
|
+
available_height = max(5, available_height) # Minimum height
|
|
163
|
+
|
|
164
|
+
# Flatten tree to get all visible nodes
|
|
165
|
+
flat_tree = self._flatten_tree(self.root)
|
|
166
|
+
self.visible_nodes = [node for node, _ in flat_tree]
|
|
167
|
+
|
|
168
|
+
# Calculate viewport
|
|
169
|
+
if self.visible_nodes:
|
|
170
|
+
# Ensure current selection is visible
|
|
171
|
+
if self.current_index < self.viewport_offset:
|
|
172
|
+
self.viewport_offset = self.current_index
|
|
173
|
+
elif self.current_index >= self.viewport_offset + available_height:
|
|
174
|
+
self.viewport_offset = self.current_index - available_height + 1
|
|
175
|
+
|
|
176
|
+
# Clamp viewport to valid range
|
|
177
|
+
max_offset = max(0, len(self.visible_nodes) - available_height)
|
|
178
|
+
self.viewport_offset = max(0, min(self.viewport_offset, max_offset))
|
|
179
|
+
else:
|
|
180
|
+
self.viewport_offset = 0
|
|
181
|
+
|
|
182
|
+
# Create tree with scroll indicators
|
|
183
|
+
tree_title = "📁 Project Files"
|
|
184
|
+
if self.viewport_offset > 0:
|
|
185
|
+
tree_title += f" ↑ ({self.viewport_offset} more)"
|
|
186
|
+
|
|
187
|
+
tree = Tree(tree_title, guide_style="dim")
|
|
188
|
+
|
|
189
|
+
# Build tree structure - only for visible portion
|
|
190
|
+
node_map = {}
|
|
191
|
+
viewport_end = min(len(flat_tree), self.viewport_offset + available_height)
|
|
192
|
+
|
|
193
|
+
# Track what level each visible item is at for proper tree structure
|
|
194
|
+
level_stacks = {} # level -> stack of tree nodes
|
|
195
|
+
|
|
196
|
+
for i in range(self.viewport_offset, viewport_end):
|
|
197
|
+
node, level = flat_tree[i]
|
|
198
|
+
|
|
199
|
+
# Determine style and icon
|
|
200
|
+
is_current = i == self.current_index
|
|
201
|
+
style = "bold cyan" if is_current else ""
|
|
202
|
+
|
|
203
|
+
if node.is_dir:
|
|
204
|
+
icon = "📂" if node.expanded else "📁"
|
|
205
|
+
size_str = f" ({len(node.children)} items)" if node.children else ""
|
|
206
|
+
else:
|
|
207
|
+
icon = "📄"
|
|
208
|
+
size_str = f" ({get_human_readable_size(node.size)})"
|
|
209
|
+
|
|
210
|
+
# Selection indicator
|
|
211
|
+
abs_path = os.path.abspath(node.path)
|
|
212
|
+
if abs_path in self.selected_files:
|
|
213
|
+
is_snippet = self.selected_files[abs_path][0]
|
|
214
|
+
if is_snippet:
|
|
215
|
+
selection = "◐" # Half-selected (snippet)
|
|
216
|
+
else:
|
|
217
|
+
selection = "●" # Fully selected
|
|
218
|
+
style = "green " + style
|
|
219
|
+
else:
|
|
220
|
+
selection = "○"
|
|
221
|
+
|
|
222
|
+
# Build label
|
|
223
|
+
label = Text()
|
|
224
|
+
label.append(f"{selection} ", style="dim")
|
|
225
|
+
label.append(f"{icon} {node.name}{size_str}", style=style)
|
|
226
|
+
|
|
227
|
+
# Add to tree at correct level
|
|
228
|
+
if level == 0:
|
|
229
|
+
tree_node = tree.add(label)
|
|
230
|
+
level_stacks[0] = tree_node
|
|
231
|
+
else:
|
|
232
|
+
# Find parent at previous level
|
|
233
|
+
parent_level = level - 1
|
|
234
|
+
if parent_level in level_stacks:
|
|
235
|
+
parent_tree = level_stacks[parent_level]
|
|
236
|
+
tree_node = parent_tree.add(label)
|
|
237
|
+
level_stacks[level] = tree_node
|
|
238
|
+
else:
|
|
239
|
+
# Fallback - add to root with indentation indicator
|
|
240
|
+
indent_label = Text()
|
|
241
|
+
indent_label.append(" " * level + f"{selection} ", style="dim")
|
|
242
|
+
indent_label.append(f"{icon} {node.name}{size_str}", style=style)
|
|
243
|
+
tree_node = tree.add(indent_label)
|
|
244
|
+
level_stacks[level] = tree_node
|
|
245
|
+
|
|
246
|
+
# Add scroll indicator at bottom if needed
|
|
247
|
+
if viewport_end < len(self.visible_nodes):
|
|
248
|
+
remaining = len(self.visible_nodes) - viewport_end
|
|
249
|
+
tree.add(Text(f"↓ ({remaining} more items)", style="dim italic"))
|
|
250
|
+
|
|
251
|
+
return tree
|
|
252
|
+
|
|
253
|
+
def _show_help(self) -> Panel:
|
|
254
|
+
"""Create help panel"""
|
|
255
|
+
help_text = """[bold]Navigation:[/bold]
|
|
256
|
+
↑/k: Up ↓/j: Down →/l/Enter: Expand ←/h: Collapse PgUp/PgDn: Page G/End: Bottom
|
|
257
|
+
|
|
258
|
+
[bold]Selection:[/bold]
|
|
259
|
+
Space: Toggle file/dir a: Add all in dir s: Snippet mode
|
|
260
|
+
|
|
261
|
+
[bold]Actions:[/bold]
|
|
262
|
+
g: Grep in directory d: Show dependencies q: Quit selection"""
|
|
263
|
+
|
|
264
|
+
return Panel(help_text, title="Keys", border_style="dim", expand=False)
|
|
265
|
+
|
|
266
|
+
def _get_status_bar(self) -> str:
|
|
267
|
+
"""Create status bar with selection info"""
|
|
268
|
+
# Count selections
|
|
269
|
+
full_count = sum(1 for _, (is_snippet, _) in self.selected_files.items() if not is_snippet)
|
|
270
|
+
snippet_count = sum(1 for _, (is_snippet, _) in self.selected_files.items() if is_snippet)
|
|
271
|
+
|
|
272
|
+
# Current item info
|
|
273
|
+
if self.visible_nodes and 0 <= self.current_index < len(self.visible_nodes):
|
|
274
|
+
current = self.visible_nodes[self.current_index]
|
|
275
|
+
current_info = f"[dim]Current:[/dim] {current.relative_path}"
|
|
276
|
+
else:
|
|
277
|
+
current_info = "No selection"
|
|
278
|
+
|
|
279
|
+
selection_info = f"[dim]Selected:[/dim] {full_count} full, {snippet_count} snippets | ~{self.char_count:,} chars (~{self.char_count//4:,} tokens)"
|
|
280
|
+
|
|
281
|
+
return f"\n{current_info} | {selection_info}\n"
|
|
282
|
+
|
|
283
|
+
def _handle_grep(self, node: FileNode):
|
|
284
|
+
"""Handle grep search in directory"""
|
|
285
|
+
if not node.is_dir:
|
|
286
|
+
self.console.print("[red]Grep only works on directories[/red]")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
pattern = click.prompt("Enter search pattern")
|
|
290
|
+
if not pattern:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
self.console.print(f"Searching for '{pattern}' in {node.relative_path}...")
|
|
294
|
+
|
|
295
|
+
# Import here to avoid circular dependency
|
|
296
|
+
from kopipasta.main import grep_files_in_directory, select_from_grep_results
|
|
297
|
+
|
|
298
|
+
grep_results = grep_files_in_directory(pattern, node.path, self.ignore_patterns)
|
|
299
|
+
if not grep_results:
|
|
300
|
+
self.console.print(f"[yellow]No matches found for '{pattern}'[/yellow]")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# Show results and let user select
|
|
304
|
+
selected_files, new_char_count = select_from_grep_results(grep_results, self.char_count)
|
|
305
|
+
|
|
306
|
+
# Add selected files
|
|
307
|
+
added_count = 0
|
|
308
|
+
for file_tuple in selected_files:
|
|
309
|
+
file_path, is_snippet, chunks, _ = file_tuple
|
|
310
|
+
abs_path = os.path.abspath(file_path)
|
|
311
|
+
|
|
312
|
+
# Check if already selected
|
|
313
|
+
if abs_path not in self.selected_files:
|
|
314
|
+
self.selected_files[abs_path] = (is_snippet, chunks)
|
|
315
|
+
added_count += 1
|
|
316
|
+
# Ensure the file is visible in the tree
|
|
317
|
+
self._ensure_path_visible(abs_path)
|
|
318
|
+
|
|
319
|
+
self.char_count = new_char_count
|
|
320
|
+
|
|
321
|
+
# Show summary of what was added
|
|
322
|
+
if added_count > 0:
|
|
323
|
+
self.console.print(f"\n[green]Added {added_count} files from grep results[/green]")
|
|
324
|
+
else:
|
|
325
|
+
self.console.print(f"\n[yellow]All selected files were already in selection[/yellow]")
|
|
326
|
+
|
|
327
|
+
def _toggle_selection(self, node: FileNode, snippet_mode: bool = False):
|
|
328
|
+
"""Toggle selection of a file or directory"""
|
|
329
|
+
if node.is_dir:
|
|
330
|
+
# For directories, toggle all children
|
|
331
|
+
self._toggle_directory(node)
|
|
332
|
+
else:
|
|
333
|
+
abs_path = os.path.abspath(node.path)
|
|
334
|
+
# For files, toggle individual selection
|
|
335
|
+
if abs_path in self.selected_files:
|
|
336
|
+
# Unselect
|
|
337
|
+
is_snippet, _ = self.selected_files[abs_path]
|
|
338
|
+
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
|
|
343
|
+
else:
|
|
344
|
+
# Select
|
|
345
|
+
if snippet_mode or (node.size > 102400 and not self._confirm_large_file(node)):
|
|
346
|
+
# Use snippet
|
|
347
|
+
self.selected_files[abs_path] = (True, None)
|
|
348
|
+
self.char_count += len(get_file_snippet(node.path))
|
|
349
|
+
else:
|
|
350
|
+
# Use full file
|
|
351
|
+
self.selected_files[abs_path] = (False, None)
|
|
352
|
+
self.char_count += node.size
|
|
353
|
+
|
|
354
|
+
def _toggle_directory(self, node: FileNode):
|
|
355
|
+
"""Toggle all files in a directory"""
|
|
356
|
+
if not node.is_dir:
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
# Ensure children are loaded
|
|
360
|
+
if not node.children:
|
|
361
|
+
self._scan_directory(node.path, node)
|
|
362
|
+
|
|
363
|
+
# Collect all files recursively
|
|
364
|
+
all_files = []
|
|
365
|
+
|
|
366
|
+
def collect_files(n: FileNode):
|
|
367
|
+
if n.is_dir:
|
|
368
|
+
for child in n.children:
|
|
369
|
+
collect_files(child)
|
|
370
|
+
else:
|
|
371
|
+
all_files.append(n)
|
|
372
|
+
|
|
373
|
+
collect_files(node)
|
|
374
|
+
|
|
375
|
+
# Check if any are unselected
|
|
376
|
+
any_unselected = any(os.path.abspath(f.path) not in self.selected_files for f in all_files)
|
|
377
|
+
|
|
378
|
+
if any_unselected:
|
|
379
|
+
# Select all unselected
|
|
380
|
+
for file_node in all_files:
|
|
381
|
+
if file_node.path not in self.selected_files:
|
|
382
|
+
self._toggle_selection(file_node)
|
|
383
|
+
else:
|
|
384
|
+
# Unselect all
|
|
385
|
+
for file_node in all_files:
|
|
386
|
+
if file_node.path in self.selected_files:
|
|
387
|
+
self._toggle_selection(file_node)
|
|
388
|
+
|
|
389
|
+
def _ensure_path_visible(self, file_path: str):
|
|
390
|
+
"""Ensure a file path is visible in the tree by expanding parent directories"""
|
|
391
|
+
abs_file_path = os.path.abspath(file_path)
|
|
392
|
+
|
|
393
|
+
# Build the path from root to the file
|
|
394
|
+
path_components = []
|
|
395
|
+
current = abs_file_path
|
|
396
|
+
|
|
397
|
+
while current != os.path.abspath(self.project_root_abs) and current != '/':
|
|
398
|
+
path_components.append(current)
|
|
399
|
+
parent = os.path.dirname(current)
|
|
400
|
+
if parent == current: # Reached root
|
|
401
|
+
break
|
|
402
|
+
current = parent
|
|
403
|
+
|
|
404
|
+
# Reverse to go from root to file
|
|
405
|
+
path_components.reverse()
|
|
406
|
+
|
|
407
|
+
# Find and expand each directory in the path
|
|
408
|
+
for component_path in path_components[:-1]: # All except the file itself
|
|
409
|
+
# Search through all nodes to find this path
|
|
410
|
+
found = False
|
|
411
|
+
for node in self._get_all_nodes(self.root):
|
|
412
|
+
if os.path.abspath(node.path) == component_path and node.is_dir:
|
|
413
|
+
if not node.expanded:
|
|
414
|
+
node.expanded = True
|
|
415
|
+
# Ensure children are loaded
|
|
416
|
+
if not node.children:
|
|
417
|
+
self._scan_directory(node.path, node)
|
|
418
|
+
found = True
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
if not found:
|
|
422
|
+
# This shouldn't happen if the tree is properly built
|
|
423
|
+
self.console.print(f"[yellow]Warning: Could not find directory {component_path} in tree[/yellow]")
|
|
424
|
+
|
|
425
|
+
def _get_all_nodes(self, node: FileNode) -> List[FileNode]:
|
|
426
|
+
"""Get all nodes in the tree recursively"""
|
|
427
|
+
nodes = [node]
|
|
428
|
+
for child in node.children:
|
|
429
|
+
nodes.extend(self._get_all_nodes(child))
|
|
430
|
+
return nodes
|
|
431
|
+
|
|
432
|
+
def _confirm_large_file(self, node: FileNode) -> bool:
|
|
433
|
+
"""Ask user about large file handling"""
|
|
434
|
+
size_str = get_human_readable_size(node.size)
|
|
435
|
+
return click.confirm(f"{node.name} is large ({size_str}). Include full content?", default=False)
|
|
436
|
+
|
|
437
|
+
def _show_dependencies(self, node: FileNode):
|
|
438
|
+
"""Show and optionally add dependencies for a file"""
|
|
439
|
+
if node.is_dir:
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
self.console.print(f"\nAnalyzing dependencies for {node.relative_path}...")
|
|
443
|
+
|
|
444
|
+
# Import here to avoid circular dependency
|
|
445
|
+
from kopipasta.main import _propose_and_add_dependencies
|
|
446
|
+
|
|
447
|
+
# Create a temporary files list for the dependency analyzer
|
|
448
|
+
files_list = [(path, is_snippet, chunks, get_language_for_file(path))
|
|
449
|
+
for path, (is_snippet, chunks) in self.selected_files.items()]
|
|
450
|
+
|
|
451
|
+
new_deps, deps_char_count = _propose_and_add_dependencies(
|
|
452
|
+
node.path, self.project_root_abs, files_list, self.char_count
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Add new dependencies to our selection
|
|
456
|
+
for dep_path, is_snippet, chunks, _ in new_deps:
|
|
457
|
+
self.selected_files[dep_path] = (is_snippet, chunks)
|
|
458
|
+
|
|
459
|
+
self.char_count += deps_char_count
|
|
460
|
+
|
|
461
|
+
def run(self, initial_paths: List[str]) -> Tuple[List[FileTuple], int]:
|
|
462
|
+
"""Run the interactive tree selector"""
|
|
463
|
+
self.root = self.build_tree(initial_paths)
|
|
464
|
+
|
|
465
|
+
# Don't use Live mode, instead manually control the display
|
|
466
|
+
while not self.quit_selection:
|
|
467
|
+
# Clear and redraw
|
|
468
|
+
self.console.clear()
|
|
469
|
+
|
|
470
|
+
# Draw tree
|
|
471
|
+
tree = self._build_display_tree()
|
|
472
|
+
self.console.print(tree)
|
|
473
|
+
|
|
474
|
+
# Draw help
|
|
475
|
+
self.console.print(self._show_help())
|
|
476
|
+
|
|
477
|
+
# Draw status bar
|
|
478
|
+
self.console.print(self._get_status_bar())
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
# Get keyboard input
|
|
482
|
+
key = click.getchar()
|
|
483
|
+
|
|
484
|
+
if not self.visible_nodes:
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
current_node = self.visible_nodes[self.current_index]
|
|
488
|
+
|
|
489
|
+
# Handle navigation
|
|
490
|
+
if key in ['\x1b[A', 'k']: # Up arrow or k
|
|
491
|
+
self.current_index = max(0, self.current_index - 1)
|
|
492
|
+
elif key in ['\x1b[B', 'j']: # Down arrow or j
|
|
493
|
+
self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1)
|
|
494
|
+
elif key == '\x1b[5~': # Page Up
|
|
495
|
+
term_width, term_height = shutil.get_terminal_size()
|
|
496
|
+
page_size = max(1, term_height - 15)
|
|
497
|
+
self.current_index = max(0, self.current_index - page_size)
|
|
498
|
+
elif key == '\x1b[6~': # Page Down
|
|
499
|
+
term_width, term_height = shutil.get_terminal_size()
|
|
500
|
+
page_size = max(1, term_height - 15)
|
|
501
|
+
self.current_index = min(len(self.visible_nodes) - 1, self.current_index + page_size)
|
|
502
|
+
elif key == '\x1b[H': # Home - go to top
|
|
503
|
+
self.current_index = 0
|
|
504
|
+
elif key == '\x1b[F': # End - go to bottom
|
|
505
|
+
self.current_index = len(self.visible_nodes) - 1
|
|
506
|
+
elif key == 'G': # Shift+G - go to bottom (vim style)
|
|
507
|
+
self.current_index = len(self.visible_nodes) - 1
|
|
508
|
+
elif key in ['\x1b[C', 'l', '\r']: # Right arrow, l, or Enter
|
|
509
|
+
if current_node.is_dir:
|
|
510
|
+
current_node.expanded = True
|
|
511
|
+
elif key in ['\x1b[D', 'h']: # Left arrow or h
|
|
512
|
+
if current_node.is_dir and current_node.expanded:
|
|
513
|
+
current_node.expanded = False
|
|
514
|
+
elif current_node.parent:
|
|
515
|
+
# Jump to parent
|
|
516
|
+
parent_idx = next((i for i, n in enumerate(self.visible_nodes)
|
|
517
|
+
if n == current_node.parent), None)
|
|
518
|
+
if parent_idx is not None:
|
|
519
|
+
self.current_index = parent_idx
|
|
520
|
+
|
|
521
|
+
# Handle selection
|
|
522
|
+
elif key == ' ': # Space - toggle selection
|
|
523
|
+
self._toggle_selection(current_node)
|
|
524
|
+
elif key == 's': # Snippet mode
|
|
525
|
+
if not current_node.is_dir:
|
|
526
|
+
self._toggle_selection(current_node, snippet_mode=True)
|
|
527
|
+
elif key == 'a': # Add all in directory
|
|
528
|
+
if current_node.is_dir:
|
|
529
|
+
self._toggle_directory(current_node)
|
|
530
|
+
|
|
531
|
+
# Handle actions
|
|
532
|
+
elif key == 'g': # Grep
|
|
533
|
+
self.console.print() # Add some space
|
|
534
|
+
self._handle_grep(current_node)
|
|
535
|
+
click.pause("Press any key to continue...")
|
|
536
|
+
elif key == 'd': # Dependencies
|
|
537
|
+
self.console.print() # Add some space
|
|
538
|
+
self._show_dependencies(current_node)
|
|
539
|
+
click.pause("Press any key to continue...")
|
|
540
|
+
elif key == 'q': # Quit
|
|
541
|
+
self.quit_selection = True
|
|
542
|
+
elif key == '\x03': # Ctrl+C
|
|
543
|
+
raise KeyboardInterrupt()
|
|
544
|
+
|
|
545
|
+
except Exception as e:
|
|
546
|
+
self.console.print(f"[red]Error: {e}[/red]")
|
|
547
|
+
click.pause("Press any key to continue...")
|
|
548
|
+
|
|
549
|
+
# Clear screen one more time
|
|
550
|
+
self.console.clear()
|
|
551
|
+
|
|
552
|
+
# Convert selections to FileTuple format
|
|
553
|
+
files_to_include = []
|
|
554
|
+
for abs_path, (is_snippet, chunks) in self.selected_files.items():
|
|
555
|
+
# Convert back to relative path for the output
|
|
556
|
+
rel_path = os.path.relpath(abs_path)
|
|
557
|
+
files_to_include.append((rel_path, is_snippet, chunks, get_language_for_file(abs_path)))
|
|
558
|
+
|
|
559
|
+
return files_to_include, self.char_count
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: kopipasta
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.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
|
|
@@ -22,6 +22,8 @@ License-File: LICENSE
|
|
|
22
22
|
Requires-Dist: pyperclip==1.9.0
|
|
23
23
|
Requires-Dist: requests==2.32.3
|
|
24
24
|
Requires-Dist: Pygments==2.18.0
|
|
25
|
+
Requires-Dist: rich==13.8.1
|
|
26
|
+
Requires-Dist: click==8.2.1
|
|
25
27
|
|
|
26
28
|
# kopipasta
|
|
27
29
|
|
|
@@ -0,0 +1,12 @@
|
|
|
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,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
kopipasta/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
kopipasta/import_parser.py,sha256=yLzkMlQm2avKjfqcpMY0PxbA_2ihV9gSYJplreWIPEQ,12424
|
|
3
|
-
kopipasta/main.py,sha256=MUTi4vj_OWTwb2Y0PqQvm--oaX3FKHSrqAUAIDvcPwU,43910
|
|
4
|
-
kopipasta-0.28.0.dist-info/LICENSE,sha256=xw4C9TAU7LFu4r_MwSbky90uzkzNtRwAo3c51IWR8lk,1091
|
|
5
|
-
kopipasta-0.28.0.dist-info/METADATA,sha256=XxmONaSfjOxhNSh4X31mdahvKxKwfwtQz0IxIA1lpFc,4838
|
|
6
|
-
kopipasta-0.28.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
|
|
7
|
-
kopipasta-0.28.0.dist-info/entry_points.txt,sha256=but54qDNz1-F8fVvGstq_QID5tHjczP7bO7rWLFkc6Y,50
|
|
8
|
-
kopipasta-0.28.0.dist-info/top_level.txt,sha256=iXohixMuCdw8UjGDUp0ouICLYBDrx207sgZIJ9lxn0o,10
|
|
9
|
-
kopipasta-0.28.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|