kopipasta 0.38.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/__init__.py +0 -0
- kopipasta/cache.py +40 -0
- kopipasta/file.py +225 -0
- kopipasta/import_parser.py +356 -0
- kopipasta/main.py +1449 -0
- kopipasta/prompt.py +174 -0
- kopipasta/tree_selector.py +791 -0
- kopipasta-0.38.0.dist-info/LICENSE +21 -0
- kopipasta-0.38.0.dist-info/METADATA +111 -0
- kopipasta-0.38.0.dist-info/RECORD +13 -0
- kopipasta-0.38.0.dist-info/WHEEL +5 -0
- kopipasta-0.38.0.dist-info/entry_points.txt +2 -0
- kopipasta-0.38.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,791 @@
|
|
|
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
|
+
from kopipasta.cache import load_selection_from_cache
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileNode:
|
|
16
|
+
"""Represents a file or directory in the tree"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
path: str,
|
|
21
|
+
is_dir: bool,
|
|
22
|
+
parent: Optional["FileNode"] = None,
|
|
23
|
+
is_scan_root: bool = False,
|
|
24
|
+
):
|
|
25
|
+
self.path = os.path.abspath(path)
|
|
26
|
+
self.is_dir = is_dir
|
|
27
|
+
self.parent = parent
|
|
28
|
+
self.children: List["FileNode"] = []
|
|
29
|
+
self.expanded = False
|
|
30
|
+
self.is_scan_root = is_scan_root
|
|
31
|
+
# Base size (for files) or initial placeholder (for dirs)
|
|
32
|
+
self.size = 0 if is_dir else os.path.getsize(self.path)
|
|
33
|
+
# New attributes for caching the results of a deep scan
|
|
34
|
+
self.total_size: int = self.size
|
|
35
|
+
self.is_scanned: bool = not self.is_dir
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self):
|
|
39
|
+
return os.path.basename(self.path) or self.path
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def relative_path(self):
|
|
43
|
+
# os.path.relpath is relative to the current working directory by default
|
|
44
|
+
return os.path.relpath(self.path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TreeSelector:
|
|
48
|
+
"""Interactive file tree selector using Rich"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, ignore_patterns: List[str], project_root_abs: str):
|
|
51
|
+
self.console = Console()
|
|
52
|
+
self.ignore_patterns = ignore_patterns
|
|
53
|
+
self.project_root_abs = project_root_abs
|
|
54
|
+
self.selected_files: Dict[
|
|
55
|
+
str, Tuple[bool, Optional[List[str]]]
|
|
56
|
+
] = {} # path -> (is_snippet, chunks)
|
|
57
|
+
self.current_index = 0
|
|
58
|
+
self.nodes: List[FileNode] = []
|
|
59
|
+
self.visible_nodes: List[FileNode] = []
|
|
60
|
+
self.char_count = 0
|
|
61
|
+
self.quit_selection = False
|
|
62
|
+
self.viewport_offset = 0 # First visible item index
|
|
63
|
+
self._metrics_cache: Dict[str, Tuple[int, int]] = {}
|
|
64
|
+
|
|
65
|
+
def _calculate_directory_metrics(self, node: FileNode) -> Tuple[int, int]:
|
|
66
|
+
"""Recursively calculate total and selected size for a directory."""
|
|
67
|
+
if not node.is_dir:
|
|
68
|
+
return 0, 0
|
|
69
|
+
|
|
70
|
+
# If the directory itself is ignored, don't explore it.
|
|
71
|
+
if is_ignored(node.path, self.ignore_patterns, self.project_root_abs):
|
|
72
|
+
return 0, 0
|
|
73
|
+
|
|
74
|
+
# Use instance cache for this render cycle
|
|
75
|
+
if node.path in self._metrics_cache:
|
|
76
|
+
return self._metrics_cache[node.path]
|
|
77
|
+
|
|
78
|
+
total_size = 0
|
|
79
|
+
selected_size = 0
|
|
80
|
+
|
|
81
|
+
# Ensure directory is scanned
|
|
82
|
+
if not node.children:
|
|
83
|
+
self._deep_scan_directory_and_calc_size(node.path, node)
|
|
84
|
+
|
|
85
|
+
for child in node.children:
|
|
86
|
+
if child.is_dir:
|
|
87
|
+
child_total, child_selected = self._calculate_directory_metrics(child)
|
|
88
|
+
total_size += child_total
|
|
89
|
+
selected_size += child_selected
|
|
90
|
+
else: # It's a file
|
|
91
|
+
total_size += child.size
|
|
92
|
+
if child.path in self.selected_files:
|
|
93
|
+
is_snippet, _ = self.selected_files[child.path]
|
|
94
|
+
if is_snippet:
|
|
95
|
+
selected_size += len(get_file_snippet(child.path))
|
|
96
|
+
else:
|
|
97
|
+
selected_size += child.size
|
|
98
|
+
|
|
99
|
+
self._metrics_cache[node.path] = (total_size, selected_size)
|
|
100
|
+
return total_size, selected_size
|
|
101
|
+
|
|
102
|
+
def build_tree(self, paths: List[str]) -> FileNode:
|
|
103
|
+
"""Build tree structure from given paths."""
|
|
104
|
+
# If one directory is given, make its contents the top level of the tree.
|
|
105
|
+
if len(paths) == 1 and os.path.isdir(paths[0]):
|
|
106
|
+
root_path = os.path.abspath(paths[0])
|
|
107
|
+
root = FileNode(root_path, True, is_scan_root=True)
|
|
108
|
+
root.expanded = True
|
|
109
|
+
self._deep_scan_directory_and_calc_size(root_path, root)
|
|
110
|
+
return root
|
|
111
|
+
|
|
112
|
+
# Otherwise, create a virtual root to hold multiple items (e.g., `kopipasta file.py dir/`).
|
|
113
|
+
# This virtual root itself won't be displayed.
|
|
114
|
+
virtual_root_path = os.path.join(
|
|
115
|
+
self.project_root_abs, "__kopipasta_virtual_root__"
|
|
116
|
+
)
|
|
117
|
+
root = FileNode(virtual_root_path, True, is_scan_root=True)
|
|
118
|
+
root.expanded = True
|
|
119
|
+
|
|
120
|
+
for path in paths:
|
|
121
|
+
abs_path = os.path.abspath(path)
|
|
122
|
+
node = None
|
|
123
|
+
if os.path.isfile(abs_path):
|
|
124
|
+
if not is_ignored(
|
|
125
|
+
abs_path, self.ignore_patterns, self.project_root_abs
|
|
126
|
+
) and not is_binary(abs_path):
|
|
127
|
+
node = FileNode(abs_path, False, root)
|
|
128
|
+
elif os.path.isdir(abs_path):
|
|
129
|
+
node = FileNode(abs_path, True, root)
|
|
130
|
+
|
|
131
|
+
if node:
|
|
132
|
+
root.children.append(node)
|
|
133
|
+
|
|
134
|
+
return root
|
|
135
|
+
|
|
136
|
+
def _deep_scan_directory_and_calc_size(self, dir_path: str, parent_node: FileNode):
|
|
137
|
+
"""Recursively scan directory and build tree"""
|
|
138
|
+
abs_dir_path = os.path.abspath(dir_path)
|
|
139
|
+
|
|
140
|
+
# Check if we've already scanned this directory
|
|
141
|
+
if parent_node.children:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
items = sorted(os.listdir(abs_dir_path))
|
|
146
|
+
except PermissionError:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# Separate and sort directories and files
|
|
150
|
+
dirs = []
|
|
151
|
+
files = []
|
|
152
|
+
|
|
153
|
+
for item in items:
|
|
154
|
+
item_path = os.path.join(abs_dir_path, item)
|
|
155
|
+
if is_ignored(item_path, self.ignore_patterns, self.project_root_abs):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if os.path.isdir(item_path):
|
|
159
|
+
dirs.append(item)
|
|
160
|
+
elif os.path.isfile(item_path) and not is_binary(item_path):
|
|
161
|
+
files.append(item)
|
|
162
|
+
|
|
163
|
+
# Add directories first
|
|
164
|
+
for dir_name in sorted(dirs):
|
|
165
|
+
dir_path_full = os.path.join(abs_dir_path, dir_name)
|
|
166
|
+
# Check if this node already exists as a child
|
|
167
|
+
existing = next(
|
|
168
|
+
(
|
|
169
|
+
child
|
|
170
|
+
for child in parent_node.children
|
|
171
|
+
if os.path.abspath(child.path) == os.path.abspath(dir_path_full)
|
|
172
|
+
),
|
|
173
|
+
None,
|
|
174
|
+
)
|
|
175
|
+
if not existing:
|
|
176
|
+
dir_node = FileNode(dir_path_full, True, parent_node)
|
|
177
|
+
parent_node.children.append(dir_node)
|
|
178
|
+
|
|
179
|
+
# Then add files
|
|
180
|
+
for file_name in sorted(files):
|
|
181
|
+
file_path = os.path.join(abs_dir_path, file_name)
|
|
182
|
+
# Check if this node already exists as a child
|
|
183
|
+
existing = next(
|
|
184
|
+
(
|
|
185
|
+
child
|
|
186
|
+
for child in parent_node.children
|
|
187
|
+
if os.path.abspath(child.path) == os.path.abspath(file_path)
|
|
188
|
+
),
|
|
189
|
+
None,
|
|
190
|
+
)
|
|
191
|
+
if not existing:
|
|
192
|
+
file_node = FileNode(file_path, False, parent_node)
|
|
193
|
+
parent_node.children.append(file_node)
|
|
194
|
+
|
|
195
|
+
def _flatten_tree(
|
|
196
|
+
self, node: FileNode, level: int = 0
|
|
197
|
+
) -> List[Tuple[FileNode, int]]:
|
|
198
|
+
"""Flatten tree into a list of (node, level) tuples for display."""
|
|
199
|
+
result = []
|
|
200
|
+
|
|
201
|
+
# If it's the special root node, don't display it. Display its children at the top level.
|
|
202
|
+
if node.is_scan_root:
|
|
203
|
+
for child in node.children:
|
|
204
|
+
result.extend(self._flatten_tree(child, 0))
|
|
205
|
+
else:
|
|
206
|
+
result.append((node, level))
|
|
207
|
+
if node.is_dir and node.expanded:
|
|
208
|
+
if not node.children:
|
|
209
|
+
self._deep_scan_directory_and_calc_size(node.path, node)
|
|
210
|
+
for child in node.children:
|
|
211
|
+
result.extend(self._flatten_tree(child, level + 1))
|
|
212
|
+
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
def _build_display_tree(self) -> Tree:
|
|
216
|
+
"""Build Rich tree for display with viewport"""
|
|
217
|
+
self._metrics_cache = {} # Clear cache for each new render
|
|
218
|
+
|
|
219
|
+
# Get terminal size
|
|
220
|
+
_, term_height = shutil.get_terminal_size()
|
|
221
|
+
|
|
222
|
+
# Reserve space for header, help panel, and status
|
|
223
|
+
reserved_space = 12
|
|
224
|
+
available_height = term_height - reserved_space
|
|
225
|
+
available_height = max(5, available_height) # Minimum height
|
|
226
|
+
|
|
227
|
+
# Flatten tree to get all visible nodes
|
|
228
|
+
flat_tree = self._flatten_tree(self.root)
|
|
229
|
+
self.visible_nodes = [node for node, _ in flat_tree]
|
|
230
|
+
|
|
231
|
+
# Calculate viewport
|
|
232
|
+
if self.visible_nodes:
|
|
233
|
+
# Ensure current selection is visible
|
|
234
|
+
if self.current_index < self.viewport_offset:
|
|
235
|
+
self.viewport_offset = self.current_index
|
|
236
|
+
elif self.current_index >= self.viewport_offset + available_height:
|
|
237
|
+
self.viewport_offset = self.current_index - available_height + 1
|
|
238
|
+
|
|
239
|
+
# Clamp viewport to valid range
|
|
240
|
+
max_offset = max(0, len(self.visible_nodes) - available_height)
|
|
241
|
+
self.viewport_offset = max(0, min(self.viewport_offset, max_offset))
|
|
242
|
+
else:
|
|
243
|
+
self.viewport_offset = 0
|
|
244
|
+
|
|
245
|
+
# Create tree with scroll indicators
|
|
246
|
+
tree_title = "📁 Project Files"
|
|
247
|
+
if self.viewport_offset > 0:
|
|
248
|
+
tree_title += f" ↑ ({self.viewport_offset} more)"
|
|
249
|
+
|
|
250
|
+
tree = Tree(tree_title)
|
|
251
|
+
|
|
252
|
+
# Build tree structure - only for visible portion
|
|
253
|
+
viewport_end = min(len(flat_tree), self.viewport_offset + available_height)
|
|
254
|
+
|
|
255
|
+
# Track what level each visible item is at for proper tree structure
|
|
256
|
+
level_stacks = {} # level -> stack of tree nodes
|
|
257
|
+
|
|
258
|
+
for i in range(self.viewport_offset, viewport_end):
|
|
259
|
+
node, level = flat_tree[i]
|
|
260
|
+
|
|
261
|
+
# Determine style and icon
|
|
262
|
+
is_current = i == self.current_index
|
|
263
|
+
style = "bold cyan" if is_current else ""
|
|
264
|
+
|
|
265
|
+
label = Text()
|
|
266
|
+
|
|
267
|
+
if node.is_dir:
|
|
268
|
+
icon = "📂" if node.expanded else "📁"
|
|
269
|
+
total_size, selected_size = self._calculate_directory_metrics(node)
|
|
270
|
+
if total_size > 0:
|
|
271
|
+
size_str = f" ({get_human_readable_size(selected_size)} / {get_human_readable_size(total_size)})"
|
|
272
|
+
else:
|
|
273
|
+
size_str = "" # Don't show size for empty dirs
|
|
274
|
+
|
|
275
|
+
# Omit the selection circle for directories
|
|
276
|
+
label.append(f"{icon} {node.name}{size_str}", style=style)
|
|
277
|
+
|
|
278
|
+
else: # It's a file
|
|
279
|
+
icon = "📄"
|
|
280
|
+
size_str = f" ({get_human_readable_size(node.size)})"
|
|
281
|
+
|
|
282
|
+
# File selection indicator
|
|
283
|
+
abs_path = os.path.abspath(node.path)
|
|
284
|
+
if abs_path in self.selected_files:
|
|
285
|
+
is_snippet, _ = self.selected_files[abs_path]
|
|
286
|
+
selection = "◐" if is_snippet else "●"
|
|
287
|
+
style = "green " + style
|
|
288
|
+
else:
|
|
289
|
+
selection = "○"
|
|
290
|
+
|
|
291
|
+
label.append(f"{selection} ", style="dim")
|
|
292
|
+
label.append(f"{icon} {node.name}{size_str}", style=style)
|
|
293
|
+
|
|
294
|
+
# Add to tree at correct level
|
|
295
|
+
if level == 0:
|
|
296
|
+
tree_node = tree.add(label)
|
|
297
|
+
level_stacks[0] = tree_node
|
|
298
|
+
else:
|
|
299
|
+
# Find parent at previous level
|
|
300
|
+
parent_level = level - 1
|
|
301
|
+
if parent_level in level_stacks:
|
|
302
|
+
parent_tree = level_stacks[parent_level]
|
|
303
|
+
tree_node = parent_tree.add(label)
|
|
304
|
+
level_stacks[level] = tree_node
|
|
305
|
+
else:
|
|
306
|
+
# Fallback - add to root with indentation indicator
|
|
307
|
+
indent_text = " " * level
|
|
308
|
+
if not node.is_dir:
|
|
309
|
+
# Re-add file selection marker for indented fallback
|
|
310
|
+
selection_char = "○"
|
|
311
|
+
if node.path in self.selected_files:
|
|
312
|
+
selection_char = (
|
|
313
|
+
"◐" if self.selected_files[node.path][0] else "●"
|
|
314
|
+
)
|
|
315
|
+
indent_text += f"{selection_char} "
|
|
316
|
+
|
|
317
|
+
# Create a new label with proper indentation for this edge case
|
|
318
|
+
fallback_label_text = f"{indent_text}{label.plain}"
|
|
319
|
+
tree_node = tree.add(Text(fallback_label_text, style=style))
|
|
320
|
+
level_stacks[level] = tree_node
|
|
321
|
+
|
|
322
|
+
# Add scroll indicator at bottom if needed
|
|
323
|
+
if viewport_end < len(self.visible_nodes):
|
|
324
|
+
remaining = len(self.visible_nodes) - viewport_end
|
|
325
|
+
tree.add(Text(f"↓ ({remaining} more items)", style="dim italic"))
|
|
326
|
+
|
|
327
|
+
return tree
|
|
328
|
+
|
|
329
|
+
def _show_help(self) -> Panel:
|
|
330
|
+
"""Create help panel"""
|
|
331
|
+
help_text = """[bold]Navigation:[/bold] ↑/k: Up ↓/j: Down →/l/Enter: Expand ←/h: Collapse
|
|
332
|
+
[bold]Selection:[/bold] Space: Toggle file/dir a: Add all in dir s: Snippet mode
|
|
333
|
+
[bold]Actions:[/bold] r: Reuse last selection g: Grep in directory d: Show dependencies
|
|
334
|
+
q: Quit and finalize"""
|
|
335
|
+
|
|
336
|
+
return Panel(
|
|
337
|
+
help_text, title="Keyboard Controls", border_style="dim", expand=False
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _get_status_bar(self) -> str:
|
|
341
|
+
"""Create status bar with selection info"""
|
|
342
|
+
# Count selections
|
|
343
|
+
full_count = sum(
|
|
344
|
+
1 for _, (is_snippet, _) in self.selected_files.items() if not is_snippet
|
|
345
|
+
)
|
|
346
|
+
snippet_count = sum(
|
|
347
|
+
1 for _, (is_snippet, _) in self.selected_files.items() if is_snippet
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Current item info
|
|
351
|
+
if self.visible_nodes and 0 <= self.current_index < len(self.visible_nodes):
|
|
352
|
+
current = self.visible_nodes[self.current_index]
|
|
353
|
+
current_info = f"[dim]Current:[/dim] {current.relative_path}"
|
|
354
|
+
else:
|
|
355
|
+
current_info = "No selection"
|
|
356
|
+
|
|
357
|
+
selection_info = f"[dim]Selected:[/dim] {full_count} full, {snippet_count} snippets | ~{self.char_count:,} chars (~{self.char_count//4:,} tokens)"
|
|
358
|
+
|
|
359
|
+
return f"\n{current_info} | {selection_info}\n"
|
|
360
|
+
|
|
361
|
+
def _handle_grep(self, node: FileNode):
|
|
362
|
+
"""Handle grep search in directory"""
|
|
363
|
+
if not node.is_dir:
|
|
364
|
+
self.console.print("[red]Grep only works on directories[/red]")
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
pattern = click.prompt("Enter search pattern")
|
|
368
|
+
if not pattern:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
self.console.print(f"Searching for '{pattern}' in {node.relative_path}...")
|
|
372
|
+
|
|
373
|
+
# Import here to avoid circular dependency
|
|
374
|
+
from kopipasta.main import grep_files_in_directory, select_from_grep_results
|
|
375
|
+
|
|
376
|
+
grep_results = grep_files_in_directory(pattern, node.path, self.ignore_patterns)
|
|
377
|
+
if not grep_results:
|
|
378
|
+
self.console.print(f"[yellow]No matches found for '{pattern}'[/yellow]")
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# Show results and let user select
|
|
382
|
+
selected_files, new_char_count = select_from_grep_results(
|
|
383
|
+
grep_results, self.char_count
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Add selected files
|
|
387
|
+
added_count = 0
|
|
388
|
+
for file_tuple in selected_files:
|
|
389
|
+
file_path, is_snippet, chunks, _ = file_tuple
|
|
390
|
+
abs_path = os.path.abspath(file_path)
|
|
391
|
+
|
|
392
|
+
# Check if already selected
|
|
393
|
+
if abs_path not in self.selected_files:
|
|
394
|
+
self.selected_files[abs_path] = (is_snippet, chunks)
|
|
395
|
+
added_count += 1
|
|
396
|
+
# Ensure the file is visible in the tree
|
|
397
|
+
self._ensure_path_visible(abs_path)
|
|
398
|
+
|
|
399
|
+
self.char_count = new_char_count
|
|
400
|
+
|
|
401
|
+
# Show summary of what was added
|
|
402
|
+
if added_count > 0:
|
|
403
|
+
self.console.print(
|
|
404
|
+
f"\n[green]Added {added_count} files from grep results[/green]"
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
self.console.print(
|
|
408
|
+
f"\n[yellow]All selected files were already in selection[/yellow]"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def _toggle_selection(self, node: FileNode, snippet_mode: bool = False):
|
|
412
|
+
"""Toggle selection of a file or directory"""
|
|
413
|
+
if node.is_dir:
|
|
414
|
+
# For directories, toggle all children
|
|
415
|
+
self._toggle_directory(node)
|
|
416
|
+
else:
|
|
417
|
+
abs_path = os.path.abspath(node.path)
|
|
418
|
+
# For files, toggle individual selection
|
|
419
|
+
if abs_path in self.selected_files:
|
|
420
|
+
# Unselect
|
|
421
|
+
is_snippet, _ = self.selected_files[abs_path]
|
|
422
|
+
del self.selected_files[abs_path]
|
|
423
|
+
self.char_count -= (
|
|
424
|
+
len(get_file_snippet(node.path)) if is_snippet else node.size
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
# Select
|
|
428
|
+
if snippet_mode or (
|
|
429
|
+
node.size > 102400 and not self._confirm_large_file(node)
|
|
430
|
+
):
|
|
431
|
+
# Use snippet
|
|
432
|
+
self.selected_files[abs_path] = (True, None)
|
|
433
|
+
self.char_count += len(get_file_snippet(node.path))
|
|
434
|
+
else:
|
|
435
|
+
# Use full file
|
|
436
|
+
self.selected_files[abs_path] = (False, None)
|
|
437
|
+
self.char_count += node.size
|
|
438
|
+
|
|
439
|
+
def _toggle_directory(self, node: FileNode):
|
|
440
|
+
"""Toggle all files in a directory, now fully recursive."""
|
|
441
|
+
if not node.is_dir:
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# Ensure children are loaded
|
|
445
|
+
if not node.children:
|
|
446
|
+
self._deep_scan_directory_and_calc_size(node.path, node)
|
|
447
|
+
|
|
448
|
+
# Collect all files recursively
|
|
449
|
+
all_files = []
|
|
450
|
+
|
|
451
|
+
def collect_files(n: FileNode):
|
|
452
|
+
if n.is_dir:
|
|
453
|
+
# CRITICAL FIX: Ensure sub-directory children are loaded before recursing
|
|
454
|
+
if not n.children:
|
|
455
|
+
self._deep_scan_directory_and_calc_size(n.path, n)
|
|
456
|
+
for child in n.children:
|
|
457
|
+
collect_files(child)
|
|
458
|
+
else:
|
|
459
|
+
all_files.append(n)
|
|
460
|
+
|
|
461
|
+
collect_files(node)
|
|
462
|
+
|
|
463
|
+
# Check if any are unselected
|
|
464
|
+
any_unselected = any(
|
|
465
|
+
os.path.abspath(f.path) not in self.selected_files for f in all_files
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
if any_unselected:
|
|
469
|
+
# Select all unselected files
|
|
470
|
+
for file_node in all_files:
|
|
471
|
+
abs_path = os.path.abspath(file_node.path)
|
|
472
|
+
if abs_path not in self.selected_files:
|
|
473
|
+
self.selected_files[abs_path] = (False, None)
|
|
474
|
+
self.char_count += file_node.size
|
|
475
|
+
else:
|
|
476
|
+
# Unselect all files
|
|
477
|
+
for file_node in all_files:
|
|
478
|
+
abs_path = os.path.abspath(file_node.path)
|
|
479
|
+
if abs_path in self.selected_files:
|
|
480
|
+
is_snippet, _ = self.selected_files[abs_path]
|
|
481
|
+
del self.selected_files[abs_path]
|
|
482
|
+
if is_snippet:
|
|
483
|
+
self.char_count -= len(get_file_snippet(file_node.path))
|
|
484
|
+
else:
|
|
485
|
+
self.char_count -= file_node.size
|
|
486
|
+
|
|
487
|
+
def _propose_and_apply_last_selection(self):
|
|
488
|
+
"""Loads paths from cache, shows a confirmation dialog, and applies the selection if confirmed."""
|
|
489
|
+
cached_paths = load_selection_from_cache()
|
|
490
|
+
|
|
491
|
+
if not cached_paths:
|
|
492
|
+
self.console.print(
|
|
493
|
+
Panel(
|
|
494
|
+
"[yellow]No cached selection found to reuse.[/yellow]",
|
|
495
|
+
title="Info",
|
|
496
|
+
border_style="dim",
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
click.pause("Press any key to continue...")
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
# Categorize cached paths for the preview
|
|
503
|
+
files_to_add = []
|
|
504
|
+
files_already_selected = []
|
|
505
|
+
files_not_found = []
|
|
506
|
+
|
|
507
|
+
for rel_path in cached_paths:
|
|
508
|
+
abs_path = os.path.abspath(rel_path)
|
|
509
|
+
if not os.path.isfile(abs_path):
|
|
510
|
+
files_not_found.append(rel_path)
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
if abs_path in self.selected_files:
|
|
514
|
+
files_already_selected.append(rel_path)
|
|
515
|
+
else:
|
|
516
|
+
files_to_add.append(rel_path)
|
|
517
|
+
|
|
518
|
+
# Build the rich text for the confirmation panel
|
|
519
|
+
preview_text = Text()
|
|
520
|
+
if files_to_add:
|
|
521
|
+
preview_text.append("The following files will be ADDED:\n", style="bold")
|
|
522
|
+
for path in sorted(files_to_add):
|
|
523
|
+
preview_text.append(" ")
|
|
524
|
+
preview_text.append("+", style="cyan")
|
|
525
|
+
preview_text.append(f" {path}\n")
|
|
526
|
+
|
|
527
|
+
if files_already_selected:
|
|
528
|
+
preview_text.append("\nAlready selected (no change):\n", style="bold dim")
|
|
529
|
+
for path in sorted(files_already_selected):
|
|
530
|
+
preview_text.append(f" ✓ {path}\n")
|
|
531
|
+
|
|
532
|
+
if files_not_found:
|
|
533
|
+
preview_text.append(
|
|
534
|
+
"\nNot found on disk (will be skipped):\n", style="bold dim"
|
|
535
|
+
)
|
|
536
|
+
for path in sorted(files_not_found):
|
|
537
|
+
preview_text.append(" ")
|
|
538
|
+
preview_text.append("-", style="red")
|
|
539
|
+
preview_text.append(f" {path}\n")
|
|
540
|
+
|
|
541
|
+
# Display the confirmation panel and prompt
|
|
542
|
+
self.console.clear()
|
|
543
|
+
self.console.print(
|
|
544
|
+
Panel(
|
|
545
|
+
preview_text,
|
|
546
|
+
title="[bold cyan]Reuse Last Selection?",
|
|
547
|
+
border_style="cyan",
|
|
548
|
+
padding=(1, 2),
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
if not files_to_add:
|
|
553
|
+
self.console.print(
|
|
554
|
+
"\n[yellow]No new files to add from the last selection.[/yellow]"
|
|
555
|
+
)
|
|
556
|
+
click.pause("Press any key to continue...")
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
# Use click.confirm for a simple and effective y/n prompt
|
|
560
|
+
if not click.confirm(
|
|
561
|
+
f"\nAdd {len(files_to_add)} file(s) to your current selection?",
|
|
562
|
+
default=True,
|
|
563
|
+
):
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
# If confirmed, apply the changes
|
|
567
|
+
for rel_path in files_to_add:
|
|
568
|
+
abs_path = os.path.abspath(rel_path)
|
|
569
|
+
if os.path.isfile(abs_path) and abs_path not in self.selected_files:
|
|
570
|
+
file_size = os.path.getsize(abs_path)
|
|
571
|
+
self.selected_files[abs_path] = (False, None)
|
|
572
|
+
self.char_count += file_size
|
|
573
|
+
self._ensure_path_visible(abs_path)
|
|
574
|
+
|
|
575
|
+
def _ensure_path_visible(self, file_path: str):
|
|
576
|
+
"""Ensure a file path is visible in the tree by expanding parent directories"""
|
|
577
|
+
abs_file_path = os.path.abspath(file_path)
|
|
578
|
+
|
|
579
|
+
# Build the path from root to the file
|
|
580
|
+
path_components = []
|
|
581
|
+
current = abs_file_path
|
|
582
|
+
|
|
583
|
+
while current != os.path.abspath(self.project_root_abs) and current != "/":
|
|
584
|
+
path_components.append(current)
|
|
585
|
+
parent = os.path.dirname(current)
|
|
586
|
+
if parent == current: # Reached root
|
|
587
|
+
break
|
|
588
|
+
current = parent
|
|
589
|
+
|
|
590
|
+
# Reverse to go from root to file
|
|
591
|
+
path_components.reverse()
|
|
592
|
+
|
|
593
|
+
# Find and expand each directory in the path
|
|
594
|
+
for component_path in path_components[:-1]: # All except the file itself
|
|
595
|
+
# Search through all nodes to find this path
|
|
596
|
+
found = False
|
|
597
|
+
for node in self._get_all_nodes(self.root):
|
|
598
|
+
if os.path.abspath(node.path) == component_path and node.is_dir:
|
|
599
|
+
if not node.expanded:
|
|
600
|
+
node.expanded = True
|
|
601
|
+
# Ensure children are loaded
|
|
602
|
+
if not node.children:
|
|
603
|
+
self._deep_scan_directory_and_calc_size(node.path, node)
|
|
604
|
+
found = True
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
if not found:
|
|
608
|
+
# This shouldn't happen if the tree is properly built
|
|
609
|
+
self.console.print(
|
|
610
|
+
f"[yellow]Warning: Could not find directory {component_path} in tree[/yellow]"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
def _get_all_nodes(self, node: FileNode) -> List[FileNode]:
|
|
614
|
+
"""Get all nodes in the tree recursively"""
|
|
615
|
+
nodes = [node]
|
|
616
|
+
for child in node.children:
|
|
617
|
+
nodes.extend(self._get_all_nodes(child))
|
|
618
|
+
return nodes
|
|
619
|
+
|
|
620
|
+
def _confirm_large_file(self, node: FileNode) -> bool:
|
|
621
|
+
"""Ask user about large file handling"""
|
|
622
|
+
size_str = get_human_readable_size(node.size)
|
|
623
|
+
return click.confirm(
|
|
624
|
+
f"{node.name} is large ({size_str}). Include full content?", default=False
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def _show_dependencies(self, node: FileNode):
|
|
628
|
+
"""Show and optionally add dependencies for a file"""
|
|
629
|
+
if node.is_dir:
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
self.console.print(f"\nAnalyzing dependencies for {node.relative_path}...")
|
|
633
|
+
|
|
634
|
+
# Import here to avoid circular dependency
|
|
635
|
+
from kopipasta.main import _propose_and_add_dependencies
|
|
636
|
+
|
|
637
|
+
# Create a temporary files list for the dependency analyzer
|
|
638
|
+
files_list = [
|
|
639
|
+
(path, is_snippet, chunks, get_language_for_file(path))
|
|
640
|
+
for path, (is_snippet, chunks) in self.selected_files.items()
|
|
641
|
+
]
|
|
642
|
+
|
|
643
|
+
new_deps, deps_char_count = _propose_and_add_dependencies(
|
|
644
|
+
node.path, self.project_root_abs, files_list, self.char_count
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Add new dependencies to our selection
|
|
648
|
+
for dep_path, is_snippet, chunks, _ in new_deps:
|
|
649
|
+
self.selected_files[dep_path] = (is_snippet, chunks)
|
|
650
|
+
|
|
651
|
+
self.char_count += deps_char_count
|
|
652
|
+
|
|
653
|
+
def _preselect_files(self, files_to_preselect: List[str]):
|
|
654
|
+
"""Pre-selects a list of files passed from the command line."""
|
|
655
|
+
if not files_to_preselect:
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
added_count = 0
|
|
659
|
+
for file_path in files_to_preselect:
|
|
660
|
+
abs_path = os.path.abspath(file_path)
|
|
661
|
+
if abs_path in self.selected_files:
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
# This check is simpler than a full tree walk and sufficient here
|
|
665
|
+
if os.path.isfile(abs_path) and not is_binary(abs_path):
|
|
666
|
+
file_size = os.path.getsize(abs_path)
|
|
667
|
+
self.selected_files[abs_path] = (
|
|
668
|
+
False,
|
|
669
|
+
None,
|
|
670
|
+
) # (is_snippet=False, chunks=None)
|
|
671
|
+
self.char_count += file_size
|
|
672
|
+
added_count += 1
|
|
673
|
+
self._ensure_path_visible(abs_path)
|
|
674
|
+
|
|
675
|
+
def run(
|
|
676
|
+
self, initial_paths: List[str], files_to_preselect: Optional[List[str]] = None
|
|
677
|
+
) -> Tuple[List[FileTuple], int]:
|
|
678
|
+
"""Run the interactive tree selector"""
|
|
679
|
+
self.root = self.build_tree(initial_paths)
|
|
680
|
+
|
|
681
|
+
if files_to_preselect:
|
|
682
|
+
self._preselect_files(files_to_preselect)
|
|
683
|
+
|
|
684
|
+
# Don't use Live mode, instead manually control the display
|
|
685
|
+
while not self.quit_selection:
|
|
686
|
+
# Clear and redraw
|
|
687
|
+
self.console.clear()
|
|
688
|
+
|
|
689
|
+
# Draw tree
|
|
690
|
+
tree = self._build_display_tree()
|
|
691
|
+
self.console.print(tree)
|
|
692
|
+
|
|
693
|
+
# Draw help
|
|
694
|
+
self.console.print(self._show_help())
|
|
695
|
+
|
|
696
|
+
# Draw status bar
|
|
697
|
+
self.console.print(self._get_status_bar())
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
# Get keyboard input
|
|
701
|
+
key = click.getchar()
|
|
702
|
+
|
|
703
|
+
if not self.visible_nodes:
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
current_node = self.visible_nodes[self.current_index]
|
|
707
|
+
|
|
708
|
+
# Handle navigation
|
|
709
|
+
if key in ["\x1b[A", "\xe0H", "k"]: # Up arrow or k
|
|
710
|
+
self.current_index = max(0, self.current_index - 1)
|
|
711
|
+
elif key in ["\x1b[B", "\xe0P", "j"]: # Down arrow or j
|
|
712
|
+
self.current_index = min(
|
|
713
|
+
len(self.visible_nodes) - 1, self.current_index + 1
|
|
714
|
+
)
|
|
715
|
+
elif key == "\x1b[5~": # Page Up
|
|
716
|
+
term_width, term_height = shutil.get_terminal_size()
|
|
717
|
+
page_size = max(1, term_height - 15)
|
|
718
|
+
self.current_index = max(0, self.current_index - page_size)
|
|
719
|
+
elif key == "\x1b[6~": # Page Down
|
|
720
|
+
term_width, term_height = shutil.get_terminal_size()
|
|
721
|
+
page_size = max(1, term_height - 15)
|
|
722
|
+
self.current_index = min(
|
|
723
|
+
len(self.visible_nodes) - 1, self.current_index + page_size
|
|
724
|
+
)
|
|
725
|
+
elif key == "\x1b[H": # Home - go to top
|
|
726
|
+
self.current_index = 0
|
|
727
|
+
elif key == "\x1b[F": # End - go to bottom
|
|
728
|
+
self.current_index = len(self.visible_nodes) - 1
|
|
729
|
+
elif key == "G": # Shift+G - go to bottom (vim style)
|
|
730
|
+
self.current_index = len(self.visible_nodes) - 1
|
|
731
|
+
elif key in ["\x1b[C", "l", "\r", "\xe0M"]: # Right arrow, l, or Enter
|
|
732
|
+
if current_node.is_dir:
|
|
733
|
+
current_node.expanded = True
|
|
734
|
+
elif key in ["\x1b[D", "h", "\xe0K"]: # Left arrow or h
|
|
735
|
+
if current_node.is_dir and current_node.expanded:
|
|
736
|
+
current_node.expanded = False
|
|
737
|
+
elif current_node.parent:
|
|
738
|
+
# Jump to parent
|
|
739
|
+
parent_idx = next(
|
|
740
|
+
(
|
|
741
|
+
i
|
|
742
|
+
for i, n in enumerate(self.visible_nodes)
|
|
743
|
+
if n == current_node.parent
|
|
744
|
+
),
|
|
745
|
+
None,
|
|
746
|
+
)
|
|
747
|
+
if parent_idx is not None:
|
|
748
|
+
self.current_index = parent_idx
|
|
749
|
+
|
|
750
|
+
# Handle selection
|
|
751
|
+
elif key == " ": # Space - toggle selection
|
|
752
|
+
self._toggle_selection(current_node)
|
|
753
|
+
elif key == "s": # Snippet mode
|
|
754
|
+
if not current_node.is_dir:
|
|
755
|
+
self._toggle_selection(current_node, snippet_mode=True)
|
|
756
|
+
elif key == "a": # Add all in directory
|
|
757
|
+
if current_node.is_dir:
|
|
758
|
+
self._toggle_directory(current_node)
|
|
759
|
+
|
|
760
|
+
# Handle actions
|
|
761
|
+
elif key == "r": # Reuse last selection
|
|
762
|
+
self._propose_and_apply_last_selection()
|
|
763
|
+
elif key == "g": # Grep
|
|
764
|
+
self.console.print() # Add some space
|
|
765
|
+
self._handle_grep(current_node)
|
|
766
|
+
elif key == "d": # Dependencies
|
|
767
|
+
self.console.print() # Add some space
|
|
768
|
+
self._show_dependencies(current_node)
|
|
769
|
+
click.pause("Press any key to continue...")
|
|
770
|
+
elif key == "q": # Quit
|
|
771
|
+
self.quit_selection = True
|
|
772
|
+
elif key == "\x03": # Ctrl+C
|
|
773
|
+
raise KeyboardInterrupt()
|
|
774
|
+
|
|
775
|
+
except Exception as e:
|
|
776
|
+
self.console.print(f"[red]Error: {e}[/red]")
|
|
777
|
+
click.pause("Press any key to continue...")
|
|
778
|
+
|
|
779
|
+
# Clear screen one more time
|
|
780
|
+
self.console.clear()
|
|
781
|
+
|
|
782
|
+
# Convert selections to FileTuple format
|
|
783
|
+
files_to_include = []
|
|
784
|
+
for abs_path, (is_snippet, chunks) in self.selected_files.items():
|
|
785
|
+
# Convert back to relative path for the output
|
|
786
|
+
rel_path = os.path.relpath(abs_path)
|
|
787
|
+
files_to_include.append(
|
|
788
|
+
(rel_path, is_snippet, chunks, get_language_for_file(abs_path))
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
return files_to_include, self.char_count
|