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.

@@ -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
- def __init__(self, path: str, is_dir: bool, parent: Optional['FileNode'] = None, is_scan_root: bool = False):
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['FileNode'] = []
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[str, Tuple[bool, Optional[List[str]]]] = {} # path -> (is_snippet, chunks)
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._scan_directory(root_path, root)
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(self.project_root_abs, "__kopipasta_virtual_root__")
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(abs_path, self.ignore_patterns, self.project_root_abs) and not is_binary(abs_path):
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 _scan_directory(self, dir_path: str, parent_node: FileNode):
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((child for child in parent_node.children
114
- if os.path.abspath(child.path) == os.path.abspath(dir_path_full)), None)
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((child for child in parent_node.children
124
- if os.path.abspath(child.path) == os.path.abspath(file_path)), None)
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(self, node: FileNode, level: int = 0) -> List[Tuple[FileNode, int]]:
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._scan_directory(node.path, node)
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, guide_style="dim")
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
- size_str = f" ({len(node.children)} items)" if node.children else ""
199
- 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
200
279
  icon = "📄"
201
280
  size_str = f" ({get_human_readable_size(node.size)})"
202
-
203
- # Selection indicator
204
- abs_path = os.path.abspath(node.path)
205
- if abs_path in self.selected_files:
206
- is_snippet = self.selected_files[abs_path][0]
207
- if is_snippet:
208
- selection = "" # Half-selected (snippet)
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 = "" # Fully selected
211
- style = "green " + style
212
- else:
213
- selection = "○"
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
- indent_label = Text()
234
- indent_label.append(" " * level + f"{selection} ", style="dim")
235
- indent_label.append(f"{icon} {node.name}{size_str}", style=style)
236
- tree_node = tree.add(indent_label)
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(help_text, title="Keyboard Controls", border_style="dim", expand=False)
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(1 for _, (is_snippet, _) in self.selected_files.items() if not is_snippet)
259
- snippet_count = sum(1 for _, (is_snippet, _) in self.selected_files.items() if is_snippet)
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(grep_results, self.char_count)
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(f"\n[green]Added {added_count} files from grep results[/green]")
403
+ self.console.print(
404
+ f"\n[green]Added {added_count} files from grep results[/green]"
405
+ )
313
406
  else:
314
- self.console.print(f"\n[yellow]All selected files were already in selection[/yellow]")
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 -= len(get_file_snippet(node.path)) if is_snippet else node.size
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 (node.size > 102400 and not self._confirm_large_file(node)):
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._scan_directory(node.path, node)
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._scan_directory(n.path, n)
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(os.path.abspath(f.path) not in self.selected_files for f in all_files)
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(Panel("[yellow]No cached selection found to reuse.[/yellow]", title="Info", border_style="dim"))
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("\nNot found on disk (will be skipped):\n", style="bold dim")
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(Panel(preview_text, title="[bold cyan]Reuse Last Selection?", border_style="cyan", padding=(1, 2)))
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("\n[yellow]No new files to add from the last selection.[/yellow]")
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(f"\nAdd {len(files_to_add)} file(s) to your current selection?", default=True):
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._scan_directory(node.path, node)
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(f"[yellow]Warning: Could not find directory {component_path} in tree[/yellow]")
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(f"{node.name} is large ({size_str}). Include full content?", default=False)
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 = [(path, is_snippet, chunks, get_language_for_file(path))
514
- for path, (is_snippet, chunks) in self.selected_files.items()]
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] = (False, None) # (is_snippet=False, chunks=None)
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(self, initial_paths: List[str], files_to_preselect: Optional[List[str]] = None) -> Tuple[List[FileTuple], int]:
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 ['\x1b[A', 'k']: # Up arrow or k
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 ['\x1b[B', 'j']: # Down arrow or j
580
- self.current_index = min(len(self.visible_nodes) - 1, self.current_index + 1)
581
- elif key == '\x1b[5~': # Page Up
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 == '\x1b[6~': # Page Down
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(len(self.visible_nodes) - 1, self.current_index + page_size)
589
- elif key == '\x1b[H': # Home - go to top
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 == '\x1b[F': # End - go to bottom
727
+ elif key == "\x1b[F": # End - go to bottom
592
728
  self.current_index = len(self.visible_nodes) - 1
593
- elif key == 'G': # Shift+G - go to bottom (vim style)
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 ['\x1b[C', 'l', '\r']: # Right arrow, l, or Enter
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 ['\x1b[D', 'h']: # Left arrow or h
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((i for i, n in enumerate(self.visible_nodes)
604
- if n == current_node.parent), None)
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 == ' ': # Space - toggle selection
751
+ elif key == " ": # Space - toggle selection
610
752
  self._toggle_selection(current_node)
611
- elif key == 's': # Snippet mode
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 == 'a': # Add all in directory
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 == 'r': # Reuse last selection
761
+ elif key == "r": # Reuse last selection
620
762
  self._propose_and_apply_last_selection()
621
- elif key == 'g': # Grep
763
+ elif key == "g": # Grep
622
764
  self.console.print() # Add some space
623
765
  self._handle_grep(current_node)
624
- elif key == 'd': # Dependencies
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 == 'q': # Quit
770
+ elif key == "q": # Quit
629
771
  self.quit_selection = True
630
- elif key == '\x03': # Ctrl+C
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((rel_path, is_snippet, chunks, get_language_for_file(abs_path)))
646
-
647
- return files_to_include, self.char_count
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