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