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