pipu-cli 0.1.dev6__py3-none-any.whl → 0.2.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.
pipu_cli/ui/apps.py DELETED
@@ -1,1460 +0,0 @@
1
- """
2
- Main application classes for the TUI interface.
3
-
4
- Contains the complete MainTUIApp and PackageSelectionApp implementations
5
- with full feature sets and functionality.
6
- """
7
-
8
- from typing import List, Dict, Any, Tuple, cast, Optional
9
- import logging
10
- import subprocess
11
- import sys
12
- import time
13
- import signal
14
- import atexit
15
- from textual.app import App, ComposeResult
16
- from textual.containers import Horizontal, Vertical, Grid, ScrollableContainer
17
- from textual.widgets import Header, Footer, DataTable, Button, Static, Input, Label
18
- from textual.binding import Binding
19
- from textual.message import Message
20
- from textual.screen import ModalScreen
21
- from textual.worker import get_current_worker
22
- from textual.errors import NoWidget
23
- from textual.coordinate import Coordinate
24
- from rich.text import Text
25
- from ..internals import _check_constraint_satisfaction, list_outdated, get_constraint_color
26
- from ..package_constraints import add_constraints_to_config, read_constraints, read_ignores
27
- from pip._internal.metadata import get_default_environment
28
-
29
- # Import modular components
30
- from .constants import (
31
- COLUMN_SELECTION, COLUMN_PACKAGE, COLUMN_CURRENT, COLUMN_LATEST,
32
- COLUMN_TYPE, COLUMN_CONSTRAINT, COLUMN_INVALID_WHEN,
33
- FORCE_EXIT_TIMEOUT, UNINSTALL_TIMEOUT
34
- )
35
- from .modal_dialogs import (
36
- ConstraintInputScreen, HelpScreen,
37
- DeleteConstraintConfirmScreen, RemoveAllConstraintsConfirmScreen,
38
- UninstallConfirmScreen, UpdateConfirmScreen, PackageUpdateScreen,
39
- NetworkErrorScreen
40
- )
41
- from .table_widgets import PackageSelectionTable
42
-
43
- # Set up module logger
44
- logger = logging.getLogger(__name__)
45
-
46
-
47
- def _restore_terminal() -> None:
48
- """
49
- Restore terminal to normal mode in case of unclean exit.
50
-
51
- This prevents the terminal from being left in raw mode or alternate screen mode
52
- which can cause control characters to be displayed instead of being interpreted.
53
-
54
- Uses the centralized safe_terminal_reset utility for cross-platform compatibility.
55
- """
56
- from ..utils import safe_terminal_reset
57
- safe_terminal_reset()
58
-
59
-
60
- def _setup_signal_handlers() -> None:
61
- """
62
- Set up signal handlers to ensure clean terminal restoration on exit.
63
-
64
- Handles SIGINT (Ctrl+C), SIGTERM, and other termination signals to ensure
65
- the terminal is properly restored even if the application is forcibly terminated.
66
- """
67
- def signal_handler(signum, frame):
68
- """Handle termination signals by restoring terminal and exiting."""
69
- del signum, frame # Unused parameters
70
- _restore_terminal()
71
- sys.exit(0)
72
-
73
- # Register signal handlers for clean exit
74
- try:
75
- signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
76
- signal.signal(signal.SIGTERM, signal_handler) # Termination
77
- if hasattr(signal, 'SIGHUP'):
78
- signal.signal(signal.SIGHUP, signal_handler) # Hangup (Unix)
79
- except Exception:
80
- # Signal handling may not be available on all platforms
81
- pass
82
-
83
- # Also register atexit handler as final fallback
84
- atexit.register(_restore_terminal)
85
-
86
-
87
- class PackageSelectionApp(App):
88
- """Textual app for interactive package selection."""
89
-
90
- BINDINGS = [
91
- Binding("h", "show_help", "Help", show=True),
92
- Binding("escape,q", "quit_app", "Quit", show=True),
93
- ]
94
-
95
- CSS = """
96
- Screen {
97
- layers: base overlay;
98
- }
99
-
100
- Header {
101
- dock: top;
102
- height: 3;
103
- }
104
-
105
- Footer {
106
- dock: bottom;
107
- height: 3;
108
- }
109
-
110
- #info-panel {
111
- height: 3;
112
- dock: top;
113
- background: $panel;
114
- color: $text;
115
- text-align: center;
116
- }
117
-
118
- #selection-info {
119
- height: 2;
120
- dock: bottom;
121
- background: $panel;
122
- color: $text;
123
- text-align: center;
124
- }
125
-
126
- PackageSelectionTable {
127
- border: solid $primary;
128
- margin: 1;
129
- }
130
-
131
- PackageSelectionTable > .datatable--cursor {
132
- background: $primary 20%;
133
- }
134
-
135
- PackageSelectionTable > .datatable--header {
136
- background: $surface;
137
- color: $text;
138
- text-style: bold;
139
- }
140
-
141
- #button-container > Button {
142
- margin: 1;
143
- width: 20;
144
- height: 3;
145
- }
146
-
147
- #button-container {
148
- height: 4;
149
- align: center middle;
150
- margin-bottom: 2;
151
- }
152
- """
153
-
154
- def __init__(self, outdated_packages: List[Dict[str, Any]]):
155
- """Initialize the package selection app."""
156
- super().__init__()
157
- self.outdated_packages = outdated_packages
158
- self.selected_packages = []
159
- self.confirmed = False
160
-
161
- # Set up terminal cleanup handlers
162
- _setup_signal_handlers()
163
-
164
- def compose(self) -> ComposeResult:
165
- """Create child widgets for the app."""
166
- yield Static(
167
- f"Found {len(self.outdated_packages)} outdated packages.\n"
168
- f"Select packages to update using SPACE, then press ENTER to confirm.",
169
- id="info-panel"
170
- )
171
-
172
- yield PackageSelectionTable(self.outdated_packages, id="package-table")
173
-
174
- with Horizontal(id="button-container"):
175
- yield Button("Update Selected", id="update-btn", variant="primary")
176
- yield Button("Cancel", id="cancel-btn", variant="error")
177
-
178
- yield Static("", id="selection-info")
179
-
180
- def on_mount(self) -> None:
181
- """Set up the app when mounted."""
182
- self._update_selection_info()
183
-
184
- def on_package_selection_table_selection_changed(self, message: PackageSelectionTable.SelectionChanged) -> None:
185
- """Handle package selection change."""
186
- self._update_selection_info(message.selected_count, message.total_count)
187
-
188
- def on_package_selection_table_confirm_selection(self, message: PackageSelectionTable.ConfirmSelection) -> None:
189
- """Handle selection confirmation."""
190
- self.selected_packages = message.selected_packages
191
- self.confirmed = True
192
- self.exit()
193
-
194
- def _update_selection_info(self, selected_count: int = 0, total_count: int = 0) -> None:
195
- """Update the selection info display."""
196
- info_widget = self.query_one("#selection-info", Static)
197
- info_widget.update(f"Selected {selected_count} of {total_count} packages")
198
-
199
- def on_button_pressed(self, event: Button.Pressed) -> None:
200
- """Handle button presses."""
201
- if event.button.id == "update-btn":
202
- # Get selected packages from table
203
- table = self.query_one("#package-table", PackageSelectionTable)
204
- self.selected_packages = [
205
- pkg for pkg in self.outdated_packages
206
- if table.selected_packages.get(pkg['name'], False)
207
- ]
208
- self.confirmed = True
209
- self.exit()
210
- elif event.button.id == "cancel-btn":
211
- self.confirmed = False
212
- self.exit()
213
-
214
- def action_show_help(self) -> None:
215
- """Show help screen."""
216
- self.push_screen(HelpScreen())
217
-
218
- def action_quit_app(self) -> None:
219
- """Quit the application."""
220
- # Ensure terminal is restored to normal mode
221
- _restore_terminal()
222
- self.confirmed = False
223
- self.exit()
224
-
225
-
226
- class MainTUIApp(App):
227
- """Main TUI application that shows all packages and async checks for updates."""
228
-
229
- BINDINGS = [
230
- Binding("c", "add_constraint", "Add Constraint", show=True),
231
- Binding("f", "filter_outdated", "F: filter to outdated", show=True),
232
- Binding("s", "show_all", "S: show all", show=True),
233
- Binding("u", "update_selected", "U: update selected", show=True),
234
- Binding("x", "uninstall_package", "X: uninstall", show=True),
235
- Binding("d", "delete_constraint", "Delete Constraint", show=True),
236
- Binding("r", "remove_all_constraints", "Remove All Constraints", show=True),
237
- Binding("h", "show_help", "Help", show=True),
238
- Binding("escape,q,ctrl+q,ctrl+c", "quit_app", "Quit", show=True),
239
- Binding("enter", "handle_enter", "", show=False), # Hidden binding for conditional Enter behavior
240
- ]
241
-
242
- CSS = """
243
- Screen {
244
- layers: base overlay;
245
- }
246
-
247
- Header {
248
- dock: top;
249
- height: 3;
250
- }
251
-
252
- Footer {
253
- dock: bottom;
254
- height: 3;
255
- }
256
-
257
- #header-stack {
258
- dock: top;
259
- height: 3;
260
- }
261
-
262
- #info-panel {
263
- height: 2;
264
- background: $panel;
265
- color: $text;
266
- text-align: center;
267
- }
268
-
269
- #filter-mode-container {
270
- height: 1;
271
- background: $panel;
272
- align: center middle;
273
- padding: 0 1;
274
- }
275
-
276
- #filter-label {
277
- width: auto;
278
- text-style: bold;
279
- margin-right: 1;
280
- }
281
-
282
- #filter-description {
283
- width: auto;
284
- text-style: italic;
285
- color: $text-muted;
286
- margin-left: 1;
287
- }
288
-
289
- #custom-footer {
290
- height: 1;
291
- dock: bottom;
292
- background: $panel;
293
- color: $text;
294
- text-align: center;
295
- padding: 0 1;
296
- }
297
-
298
- #main-table {
299
- border: solid $primary;
300
- margin: 1;
301
- margin-bottom: 2;
302
- }
303
-
304
- #main-table > .datatable--cursor {
305
- background: $primary 20%;
306
- }
307
-
308
- #main-table > .datatable--header {
309
- background: $surface;
310
- color: $text;
311
- text-style: bold;
312
- }
313
-
314
- #button-container > Button {
315
- margin: 1;
316
- width: 20;
317
- height: 3;
318
- }
319
-
320
- #button-container {
321
- height: 4;
322
- align: center middle;
323
- margin-bottom: 2;
324
- }
325
- """
326
-
327
- def __init__(self):
328
- """Initialize the main TUI app."""
329
- super().__init__()
330
- self.all_packages = []
331
- self.outdated_packages = []
332
- self.update_check_complete = True # Start as True, will be set to False when checking begins
333
- self.update_check_successful = False # Track if the check completed successfully
334
- self.constraints = {}
335
- self.ignores = set()
336
- self.invalidation_triggers = {}
337
- self.filter_outdated_only = False # Default to showing all packages
338
- self.package_row_mapping = {} # Maps package name to row index for efficient updates
339
-
340
- # Set up terminal cleanup handlers
341
- _setup_signal_handlers()
342
-
343
- def compose(self) -> ComposeResult:
344
- """Create child widgets for the app."""
345
- # Use a vertical container to properly stack the header elements
346
- with Vertical(id="header-stack"):
347
- yield Static(
348
- "pipu - Package Management\n"
349
- "Space: toggle, U: update, H: help, Q: quit",
350
- id="info-panel"
351
- )
352
-
353
- # Filter mode indicator - third line
354
- with Horizontal(id="filter-mode-container"):
355
- yield Static("Filter Mode:", id="filter-label")
356
- yield Static("Show all packages", id="filter-description")
357
-
358
- yield DataTable(id="main-table", cursor_type="row")
359
-
360
- with Horizontal(id="button-container"):
361
- yield Button("Update Selected", id="update-btn", variant="primary")
362
- yield Button("Quit", id="quit-btn", variant="error")
363
-
364
- # Custom footer with status
365
- yield Static(
366
- "Ready to load packages",
367
- id="custom-footer"
368
- )
369
-
370
- def on_mount(self) -> None:
371
- """Set up the app when mounted."""
372
- # Load constraints and ignores
373
- self.constraints = read_constraints()
374
- self.ignores = read_ignores()
375
-
376
- # Load invalidation triggers
377
- self.invalidation_triggers = self._load_invalidation_triggers()
378
-
379
- # Load all installed packages first
380
- self._load_installed_packages()
381
-
382
- # Start async outdated package checking using a timer
383
- self.call_later(self._start_update_check, 0.5)
384
-
385
- def _load_invalidation_triggers(self) -> Dict[str, List[str]]:
386
- """Load invalidation triggers from pip configuration and auto-discovered constraints."""
387
- from ..package_constraints import read_invalidation_triggers
388
- return read_invalidation_triggers()
389
-
390
- def on_unmount(self) -> None:
391
- """Clean up when the app is unmounting."""
392
- # Ensure terminal is restored to normal mode
393
- _restore_terminal()
394
-
395
- # Cancel all workers immediately
396
- try:
397
- for worker in self.workers:
398
- worker.cancel()
399
- except Exception:
400
- pass
401
-
402
- def _load_installed_packages(self) -> None:
403
- """Load all installed packages into the table."""
404
- # Update status to show we're loading packages
405
- self._update_status("Loading installed packages...", False)
406
-
407
- try:
408
- env = get_default_environment()
409
- installed_dists = env.iter_all_distributions()
410
-
411
- # Detect editable packages for display and preservation
412
- from ..internals import get_editable_packages
413
- editable_packages = get_editable_packages()
414
-
415
- # Set up the data table (if app is mounted)
416
- table = None
417
- try:
418
- table = self.query_one("#main-table", DataTable)
419
- table.add_column("Sel", width=4)
420
- table.add_column("Package", width=20)
421
- table.add_column("Current", width=12)
422
- table.add_column("Latest", width=12)
423
- table.add_column("Type", width=8)
424
- table.add_column("Constraint", width=20)
425
- table.add_column("Invalid When", width=25)
426
- except Exception:
427
- # App not mounted or table not available (e.g., during testing)
428
- pass
429
-
430
- # Load all packages
431
- self.all_packages = []
432
- for dist in installed_dists:
433
- try:
434
- package_name = dist.metadata["name"]
435
- current_version = str(dist.version)
436
-
437
- # Normalize package name for constraint and trigger lookups
438
- from packaging.utils import canonicalize_name
439
- canonical_name = canonicalize_name(package_name)
440
-
441
- # Get invalidation triggers for this package
442
- package_triggers = self.invalidation_triggers.get(canonical_name, [])
443
- invalid_when_display = ", ".join(package_triggers) if package_triggers else None
444
-
445
- package_info = {
446
- "name": package_name,
447
- "version": current_version,
448
- "latest_version": "Checking...",
449
- "latest_filetype": "",
450
- "constraint": self.constraints.get(canonical_name),
451
- "invalid_when": invalid_when_display,
452
- "selected": False,
453
- "outdated": False,
454
- "editable": canonical_name in editable_packages
455
- }
456
-
457
- self.all_packages.append(package_info)
458
-
459
- except Exception:
460
- continue
461
-
462
- # Sort packages alphabetically
463
- self.all_packages.sort(key=lambda x: x["name"].lower())
464
-
465
- # Add sorted packages to table and build row mapping (if table exists)
466
- self.package_row_mapping = {}
467
- if table is not None:
468
- # In filtered mode, start with an empty table that gets populated as outdated packages are discovered
469
- # In show-all mode, populate with all packages initially
470
- if not self.filter_outdated_only:
471
- for i, package_info in enumerate(self.all_packages):
472
- constraint_display = package_info.get('constraint', '')
473
- if constraint_display:
474
- constraint_text = Text.from_markup(f"[yellow]{constraint_display}[/yellow]")
475
- else:
476
- constraint_text = Text.from_markup("[dim]-[/dim]")
477
-
478
- # Format invalid when display
479
- invalid_when = package_info.get('invalid_when')
480
- if invalid_when:
481
- invalid_when_text = Text.from_markup(f"[yellow]{invalid_when}[/yellow]")
482
- else:
483
- invalid_when_text = Text.from_markup("[dim]-[/dim]")
484
-
485
- # Format selection indicator (initially unselected)
486
- selection_text = Text(" ", style="dim")
487
-
488
- # Format package name with editable indicator
489
- if package_info.get("editable", False):
490
- package_display = Text.from_markup(f"[bold cyan]{package_info['name']}[/bold cyan] [dim]📝[/dim]")
491
- else:
492
- package_display = package_info["name"]
493
-
494
- table.add_row(
495
- selection_text,
496
- package_display,
497
- package_info["version"],
498
- "Checking...",
499
- "",
500
- constraint_text,
501
- invalid_when_text,
502
- key=package_info["name"]
503
- )
504
- self.package_row_mapping[package_info["name"]] = i
505
- # In filtered mode, table starts empty - packages will be added via _update_package_result as they're discovered to be outdated
506
- else:
507
- # If no table, just build the row mapping for all packages
508
- for i, package_info in enumerate(self.all_packages):
509
- self.package_row_mapping[package_info["name"]] = i
510
-
511
- # Update status to show packages loaded
512
- self._update_status("Packages loaded. Checking for updates...", False)
513
-
514
- except Exception as e:
515
- self.notify(f"Error loading packages: {e}")
516
-
517
- def _start_update_check(self, *args) -> None:
518
- """Start the update check using a worker."""
519
- import threading
520
- del args # Unused parameter
521
- self.update_check_complete = False
522
- self._set_update_button_enabled(False)
523
- # Create a cancellation event for this check
524
- self.update_check_cancel_event = threading.Event()
525
- self.run_worker(self._check_outdated_packages, thread=True, exclusive=True, name="outdated_check")
526
-
527
- def _check_outdated_packages(self) -> None:
528
- """Worker function to check for outdated packages."""
529
- import threading
530
-
531
- try:
532
- worker = get_current_worker()
533
- if worker.is_cancelled:
534
- return
535
- except Exception:
536
- # No active worker (e.g., during testing)
537
- worker = None
538
-
539
- try:
540
- self.call_from_thread(self._update_status, "Checking for package updates...", True)
541
- except Exception:
542
- # Not in app context (e.g., during testing)
543
- pass
544
-
545
- try:
546
- # Define progress callback to update status
547
- def progress_callback(package_name: str):
548
- if worker and worker.is_cancelled:
549
- return
550
- try:
551
- self.call_from_thread(self._update_status, f"Checking {package_name}...", True)
552
- except Exception:
553
- pass
554
-
555
- # Define result callback to update individual table rows
556
- def result_callback(package_result: Dict[str, Any]):
557
- if worker and worker.is_cancelled:
558
- return
559
- try:
560
- self.call_from_thread(self._update_package_result, package_result)
561
- except Exception:
562
- pass
563
-
564
- # Create a silent console to avoid interference with TUI
565
- from rich.console import Console
566
- import io
567
- silent_console = Console(file=io.StringIO(), width=120)
568
-
569
- # Use list_outdated with silent console to avoid interference with TUI
570
- outdated_packages = list_outdated(
571
- console=silent_console,
572
- print_table=False,
573
- constraints=self.constraints,
574
- ignores=self.ignores,
575
- pre=False,
576
- progress_callback=progress_callback,
577
- result_callback=result_callback,
578
- cancel_event=self.update_check_cancel_event
579
- )
580
-
581
- if not worker or not worker.is_cancelled:
582
- try:
583
- self.call_from_thread(self._update_outdated_results, outdated_packages)
584
- except Exception:
585
- pass
586
-
587
- except ConnectionError as e:
588
- # Network connectivity issue - show error dialog and exit
589
- if not worker or not worker.is_cancelled:
590
- try:
591
- error_msg = str(e)
592
- self.call_from_thread(self._handle_update_check_error, error_msg, is_network_error=True)
593
- except Exception:
594
- pass
595
- except Exception as e:
596
- # Other errors during update check
597
- if not worker or not worker.is_cancelled:
598
- try:
599
- error_msg = f"Error checking for updates: {str(e)}"
600
- logger.error(error_msg, exc_info=True)
601
- self.call_from_thread(self._handle_update_check_error, error_msg, is_network_error=False)
602
- except Exception:
603
- pass
604
-
605
- def _update_status(self, message: str, show_spinner: bool = False) -> None:
606
- """Update the status message."""
607
- try:
608
- footer_widget = self.query_one("#custom-footer", Static)
609
- spinner_text = "⟳ " if show_spinner else ""
610
- footer_widget.update(f"{spinner_text}{message}")
611
- except Exception:
612
- # Fallback to notification if status widget fails
613
- self.notify(f"{message}")
614
-
615
- def _handle_update_check_error(self, error_message: str, is_network_error: bool = False) -> None:
616
- """Handle errors that occur during the update check process."""
617
- # Mark check as complete but unsuccessful
618
- self.update_check_complete = True
619
- self.update_check_successful = False
620
- self._set_update_button_enabled(False)
621
-
622
- # Update status to show error
623
- self._update_status(f"❌ {error_message} - Press Enter or Escape to exit", False)
624
-
625
- # Show notification
626
- self.notify(f"Update check failed: {error_message}. Press Enter or Escape to exit.", severity="error")
627
-
628
- # For network errors, show the modal dialog and exit
629
- if is_network_error:
630
- self.push_screen(NetworkErrorScreen(error_message))
631
-
632
- def _show_network_error_and_exit(self, error_message: str) -> None:
633
- """Show network error dialog and exit when dismissed."""
634
- self.push_screen(NetworkErrorScreen(error_message))
635
-
636
- def _update_package_result(self, package_result: Dict[str, Any]) -> None:
637
- """Update a single package row with its result."""
638
- try:
639
- package_name = package_result["name"]
640
- latest_version = package_result["latest_version"]
641
- filetype = package_result["latest_filetype"] or ""
642
- current_version = package_result["version"]
643
-
644
- # First, update the package data in all_packages (this is the source of truth)
645
- pkg_data = None
646
- for pkg in self.all_packages:
647
- if pkg["name"] == package_name:
648
- pkg["latest_version"] = latest_version
649
- pkg["latest_filetype"] = filetype
650
- pkg["outdated"] = (latest_version != current_version)
651
-
652
- # Auto-select packages that can be updated without constraint conflicts
653
- if pkg["outdated"]:
654
- constraint = pkg.get('constraint')
655
- if constraint:
656
- # Check if the latest version satisfies the constraint
657
- selected = _check_constraint_satisfaction(latest_version, constraint)
658
- else:
659
- # No constraint, can be updated freely
660
- selected = True
661
- pkg["selected"] = selected
662
- else:
663
- # Package is up to date, don't select it
664
- pkg["selected"] = False
665
-
666
- pkg_data = pkg
667
- break
668
-
669
- if not pkg_data:
670
- # Package not found in all_packages, skip
671
- return
672
-
673
- # Check if this package should be visible in current filter mode
674
- should_be_visible = True
675
- if self.filter_outdated_only:
676
- # In filtered mode, only show packages that have been confirmed as outdated
677
- # Don't show packages that are still being checked or up-to-date
678
- should_be_visible = pkg_data["outdated"]
679
-
680
- # Check if package is currently visible in the table
681
- row_index = self.package_row_mapping.get(package_name)
682
- is_currently_visible = row_index is not None
683
-
684
- # Determine if we need to refresh the entire table
685
- needs_refresh = False
686
- if self.filter_outdated_only:
687
- # If package visibility changed, we need to refresh
688
- if should_be_visible != is_currently_visible:
689
- needs_refresh = True
690
-
691
- if needs_refresh:
692
- # Full table refresh needed due to filtering changes
693
- # Preserve cursor position during real-time filtering
694
- self._refresh_table_display(preserve_cursor=True)
695
- elif is_currently_visible and should_be_visible:
696
- # Package is visible and should remain visible - update the row in place
697
- table = self.query_one("#main-table", DataTable)
698
-
699
- # Validate that the row index is still valid
700
- if row_index >= len(table.rows) or row_index < 0:
701
- # Row mapping is stale, do a full refresh
702
- # Preserve cursor position when doing emergency refresh
703
- self._refresh_table_display(preserve_cursor=True)
704
- return
705
-
706
- # Color code the latest version based on update status
707
- if latest_version == current_version:
708
- # Package is up-to-date, show in default color
709
- latest_display = latest_version
710
- type_display = "" # Empty type column for current packages
711
- else:
712
- # Package is outdated - check if it can be updated
713
- constraint = pkg_data.get('constraint')
714
- # Use utility method for consistent formatting
715
- latest_display = self._format_latest_version(latest_version, constraint)
716
- type_display = filetype
717
-
718
- # Update the table cells
719
- try:
720
- # Update selection indicator
721
- if pkg_data.get("selected", False):
722
- selection_text = Text("●", style="green bold")
723
- else:
724
- selection_text = Text(" ", style="dim")
725
-
726
- table.update_cell_at(cast(Coordinate, (row_index, COLUMN_SELECTION)), selection_text)
727
- table.update_cell_at(cast(Coordinate, (row_index, COLUMN_LATEST)), latest_display)
728
- table.update_cell_at(cast(Coordinate, (row_index, COLUMN_TYPE)), type_display)
729
- except Exception:
730
- # Coordinates are invalid, do a full refresh
731
- # Preserve cursor position when doing emergency refresh
732
- self._refresh_table_display(preserve_cursor=True)
733
-
734
- except Exception:
735
- # Log error for debugging but don't crash the app - make it less verbose
736
- pass # Silent failure for coordinate errors
737
-
738
- def _format_latest_version(self, latest_version: str, constraint: Optional[str]) -> Text:
739
- """
740
- Format latest version with conditional coloring based on constraint satisfaction.
741
-
742
- :param latest_version: The latest version string
743
- :param constraint: Optional constraint specification
744
- :returns: Text object with appropriate color markup
745
- """
746
- color = get_constraint_color(latest_version, constraint)
747
- return Text.from_markup(f"[{color}]{latest_version}[/{color}]")
748
-
749
- def _get_selected_package(self) -> dict | None:
750
- """
751
- Get the complete package data for the currently selected row in the table.
752
-
753
- This method properly handles the table cursor and filtering to return
754
- the correct package data, avoiding RowKey object issues.
755
-
756
- :returns: Package dictionary or None if no valid selection
757
- """
758
- table = self.query_one("#main-table", DataTable)
759
- if table.cursor_row is None or table.cursor_row >= len(table.rows):
760
- return None
761
-
762
- # Filter packages based on current display settings
763
- packages_to_show = []
764
- for pkg in self.all_packages:
765
- if self.filter_outdated_only:
766
- if pkg.get("outdated", False):
767
- packages_to_show.append(pkg)
768
- else:
769
- packages_to_show.append(pkg)
770
-
771
- # Sort to match table order
772
- packages_to_show.sort(key=lambda x: x["name"].lower())
773
-
774
- # Get the package at the cursor position
775
- if table.cursor_row < len(packages_to_show):
776
- return packages_to_show[table.cursor_row]
777
-
778
- return None
779
-
780
- def _refresh_table_display(self, preserve_cursor: bool = False) -> None:
781
- """Refresh the table display based on current filter settings."""
782
- try:
783
- table = self.query_one("#main-table", DataTable)
784
-
785
- # Save cursor position and scroll offset if requested
786
- cursor_row = None
787
- cursor_package_name = None
788
- scroll_offset_y = None
789
- if preserve_cursor:
790
- if table.cursor_row is not None:
791
- cursor_row = table.cursor_row
792
- # Try to get the package name at the current cursor position
793
- try:
794
- if cursor_row < len(table.rows):
795
- # Get the package name from the displayed packages list
796
- packages_to_show = []
797
- for pkg in self.all_packages:
798
- if self.filter_outdated_only:
799
- if pkg.get("outdated", False):
800
- packages_to_show.append(pkg)
801
- else:
802
- packages_to_show.append(pkg)
803
- packages_to_show.sort(key=lambda x: x["name"].lower())
804
-
805
- if cursor_row < len(packages_to_show):
806
- cursor_package_name = packages_to_show[cursor_row]['name']
807
- except Exception:
808
- pass
809
-
810
- # Save the current scroll position
811
- try:
812
- scroll_offset_y = table.scroll_offset.y
813
- except Exception:
814
- pass
815
-
816
- # Clear only the rows, not the columns
817
- table.clear(columns=False)
818
-
819
- # Filter packages based on current settings
820
- packages_to_show = []
821
- for pkg in self.all_packages:
822
- if self.filter_outdated_only:
823
- # In filtered mode, only show packages that have been confirmed as outdated
824
- # Don't show packages that are still being checked ("Checking...") or up-to-date
825
- if pkg.get("outdated", False):
826
- packages_to_show.append(pkg)
827
- # Skip packages with "Checking..." or up-to-date packages
828
- else:
829
- # Show all packages
830
- packages_to_show.append(pkg)
831
-
832
- # Ensure packages remain sorted alphabetically
833
- packages_to_show.sort(key=lambda x: x["name"].lower())
834
-
835
- # Rebuild row mapping for displayed packages
836
- self.package_row_mapping = {}
837
-
838
- # Add rows to table
839
- for i, pkg in enumerate(packages_to_show):
840
- constraint_display = pkg.get('constraint', '')
841
- if constraint_display:
842
- constraint_text = Text.from_markup(f"[yellow]{constraint_display}[/yellow]")
843
- else:
844
- constraint_text = Text.from_markup("[dim]-[/dim]")
845
-
846
- # Format the latest version with color coding based on update status
847
- latest_version = pkg.get("latest_version", "Checking...")
848
- if latest_version == "Checking...":
849
- # Still checking, show as-is
850
- latest_display = latest_version
851
- type_display = pkg.get("latest_filetype", "")
852
- elif latest_version == pkg["version"]:
853
- # Package is up-to-date, show in default color
854
- latest_display = latest_version
855
- type_display = "" # Empty type column for current packages
856
- else:
857
- # Package is outdated - check if it can be updated
858
- constraint = pkg.get('constraint')
859
- # Use utility method for consistent formatting
860
- latest_display = self._format_latest_version(latest_version, constraint)
861
- type_display = pkg.get("latest_filetype", "")
862
-
863
- # Format invalid when display
864
- invalid_when = pkg.get('invalid_when')
865
- if invalid_when:
866
- invalid_when_text = Text.from_markup(f"[yellow]{invalid_when}[/yellow]")
867
- else:
868
- invalid_when_text = Text.from_markup("[dim]-[/dim]")
869
-
870
- # Format selection indicator
871
- if pkg.get("selected", False):
872
- selection_text = Text("●", style="green bold") # Selected indicator
873
- else:
874
- selection_text = Text(" ", style="dim") # Empty space
875
-
876
- # Format package name with editable indicator
877
- if pkg.get("editable", False):
878
- package_display = Text.from_markup(f"[bold cyan]{pkg['name']}[/bold cyan] [dim]📝[/dim]")
879
- else:
880
- package_display = pkg["name"]
881
-
882
- table.add_row(
883
- selection_text,
884
- package_display,
885
- pkg["version"],
886
- latest_display,
887
- type_display,
888
- constraint_text,
889
- invalid_when_text,
890
- key=pkg["name"]
891
- )
892
- # Update row mapping for this package
893
- self.package_row_mapping[pkg["name"]] = i
894
-
895
- # Restore cursor position and scroll offset if possible
896
- if preserve_cursor:
897
- # First restore cursor position
898
- if cursor_package_name:
899
- # Try to find the package in the new table and restore cursor
900
- new_row_index = self.package_row_mapping.get(cursor_package_name)
901
- if new_row_index is not None:
902
- try:
903
- table.move_cursor(row=new_row_index)
904
- except Exception:
905
- pass # If cursor restoration fails, just continue
906
- elif cursor_row is not None:
907
- # Package is no longer visible, try to restore to the same row index
908
- try:
909
- max_row = len(table.rows) - 1
910
- if max_row >= 0:
911
- restore_row = min(cursor_row, max_row)
912
- table.move_cursor(row=restore_row)
913
- except Exception:
914
- pass
915
-
916
- # Then restore scroll position
917
- if scroll_offset_y is not None:
918
- try:
919
- # Schedule scroll restoration to happen after the table is rendered
920
- self.set_timer(0.01, lambda: self._restore_scroll_position(table, scroll_offset_y))
921
- except Exception:
922
- pass
923
-
924
- except Exception:
925
- pass
926
-
927
- def _restore_scroll_position(self, table, scroll_y: int) -> None:
928
- """Helper method to restore scroll position after table refresh."""
929
- try:
930
- table.scroll_to(y=scroll_y, animate=False)
931
- except Exception:
932
- pass # If scroll restoration fails, just continue
933
-
934
- def _show_uninstall_confirmation(self, package_name: str) -> None:
935
- """Show confirmation dialog for uninstalling a package."""
936
- def uninstall_confirmed(confirmed: bool | None) -> None:
937
- if confirmed:
938
- self._uninstall_package(package_name)
939
-
940
- self.push_screen(UninstallConfirmScreen(package_name), uninstall_confirmed)
941
-
942
- def _uninstall_package(self, package_name: str) -> None:
943
- """Actually uninstall the package."""
944
- self._update_status(f"Uninstalling {package_name}...", True)
945
-
946
- def run_uninstall():
947
- """Run pip uninstall in a worker thread."""
948
- try:
949
- # Use sys.executable to find the correct pip for the current Python environment
950
- pip_cmd = [sys.executable, "-m", "pip", "uninstall", package_name, "-y"]
951
- result = subprocess.run(
952
- pip_cmd,
953
- capture_output=True,
954
- text=True,
955
- timeout=UNINSTALL_TIMEOUT
956
- )
957
-
958
- if result.returncode == 0:
959
- self.call_from_thread(self.notify, f"Successfully uninstalled {package_name}", "information")
960
- self.call_from_thread(self._remove_package_from_table, package_name)
961
- else:
962
- self.call_from_thread(self.notify, f"Failed to uninstall {package_name}: {result.stderr}", "error")
963
- except subprocess.TimeoutExpired:
964
- self.call_from_thread(self.notify, f"Uninstall of {package_name} timed out", "error")
965
- except Exception as e:
966
- self.call_from_thread(self.notify, f"Error uninstalling {package_name}: {e}", "error")
967
- finally:
968
- self.call_from_thread(self._update_status, "Ready", False)
969
-
970
- self.run_worker(run_uninstall, thread=True, exclusive=False)
971
-
972
- def _remove_package_from_table(self, package_name: str) -> None:
973
- """Remove a package from the table after uninstall."""
974
- try:
975
- table = self.query_one("#main-table", DataTable)
976
- if package_name in table.rows:
977
- table.remove_row(package_name)
978
-
979
- # Remove from our data structures
980
- self.all_packages = [pkg for pkg in self.all_packages if pkg["name"] != package_name]
981
- self.outdated_packages = [pkg for pkg in self.outdated_packages if pkg["name"] != package_name]
982
- except Exception:
983
- pass
984
-
985
- def _update_outdated_results(self, outdated_packages: List[Dict[str, Any]]) -> None:
986
- """Update the table with outdated package results."""
987
- self.outdated_packages = outdated_packages
988
- self.update_check_complete = True
989
- self.update_check_successful = True # Mark as successful
990
- self._set_update_button_enabled(True)
991
-
992
- # Create a mapping for quick lookup
993
- outdated_map = {pkg['name'].lower(): pkg for pkg in outdated_packages}
994
-
995
- # Update package data first (source of truth)
996
- for package in self.all_packages:
997
- package_name_lower = package['name'].lower()
998
-
999
- if package_name_lower in outdated_map:
1000
- # Package is outdated
1001
- outdated_info = outdated_map[package_name_lower]
1002
- package['outdated'] = True
1003
- package['latest_version'] = outdated_info['latest_version']
1004
- package['latest_filetype'] = outdated_info['latest_filetype']
1005
-
1006
- # Auto-select if no constraint or constraint is satisfied
1007
- constraint = package.get('constraint')
1008
- if constraint:
1009
- selected = _check_constraint_satisfaction(outdated_info['latest_version'], constraint)
1010
- else:
1011
- selected = True
1012
-
1013
- package['selected'] = selected
1014
-
1015
- else:
1016
- # Package is up to date
1017
- package['outdated'] = False
1018
- package['latest_version'] = package['version']
1019
- package['latest_filetype'] = ""
1020
- package['selected'] = False
1021
-
1022
- # Now update the visible table display based on current filter
1023
- # This handles the row mapping correctly and ensures table consistency
1024
- self._refresh_table_display()
1025
-
1026
- # Update status
1027
- outdated_count = len(outdated_packages)
1028
- selected_count = sum(1 for pkg in self.all_packages if pkg.get('selected', False))
1029
-
1030
- if outdated_count > 0:
1031
- self._update_status(f"Found {outdated_count} outdated packages, {selected_count} selected for update", False)
1032
- else:
1033
- self._update_status("All packages are up to date!", False)
1034
-
1035
- def action_quit_app(self) -> None:
1036
- """Quit the application."""
1037
- # Signal cancellation to the update check if it's running
1038
- if hasattr(self, 'update_check_cancel_event') and self.update_check_cancel_event:
1039
- self.update_check_cancel_event.set()
1040
-
1041
- # Cancel any running workers before exiting with proper tracking
1042
- cancelled_workers = []
1043
- failed_workers = []
1044
-
1045
- for worker in self.workers:
1046
- if not worker.is_finished:
1047
- try:
1048
- worker.cancel()
1049
- cancelled_workers.append(worker.name or "unnamed")
1050
- except Exception as e:
1051
- logger.error(f"Failed to cancel worker {worker.name or 'unnamed'}: {e}")
1052
- failed_workers.append(worker.name or "unnamed")
1053
-
1054
- if cancelled_workers:
1055
- logger.debug(f"Cancelled workers: {', '.join(cancelled_workers)}")
1056
- if failed_workers:
1057
- logger.warning(f"Failed to cancel workers: {', '.join(failed_workers)}")
1058
-
1059
- # Exit the application
1060
- self.exit()
1061
-
1062
- def action_handle_enter(self) -> None:
1063
- """Handle Enter key - only quit if update check failed."""
1064
- # Only allow Enter to quit if the update check failed
1065
- if self.update_check_complete and not self.update_check_successful:
1066
- self.exit()
1067
- # Otherwise, do nothing (Enter doesn't quit during normal operation)
1068
-
1069
- def action_filter_outdated(self) -> None:
1070
- """Set filter to show only outdated packages."""
1071
- if not self.filter_outdated_only:
1072
- self.filter_outdated_only = True
1073
- self._refresh_table_display(preserve_cursor=True)
1074
-
1075
- # Update the description
1076
- try:
1077
- description = self.query_one("#filter-description", Static)
1078
- description.update("Show outdated only")
1079
- except NoWidget:
1080
- logger.debug("Filter description widget not found - context may not support it")
1081
- except Exception as e:
1082
- logger.warning(f"Could not update filter description: {e}")
1083
-
1084
- self.notify("Filter: showing only outdated packages")
1085
- else:
1086
- # Already filtering, just acknowledge
1087
- self.notify("Filter: already showing only outdated packages")
1088
-
1089
- def action_show_all(self) -> None:
1090
- """Set filter to show all packages."""
1091
- if self.filter_outdated_only:
1092
- self.filter_outdated_only = False
1093
- self._refresh_table_display(preserve_cursor=True)
1094
-
1095
- # Update the description
1096
- try:
1097
- description = self.query_one("#filter-description", Static)
1098
- description.update("Show all packages")
1099
- except NoWidget:
1100
- logger.debug("Filter description widget not found - context may not support it")
1101
- except Exception as e:
1102
- logger.warning(f"Could not update filter description: {e}")
1103
-
1104
- self.notify("Filter: showing all packages")
1105
- else:
1106
- # Already showing all, just acknowledge
1107
- self.notify("Filter: already showing all packages")
1108
-
1109
- def action_uninstall_package(self) -> None:
1110
- """Uninstall the currently selected package."""
1111
- selected_package = self._get_selected_package()
1112
- if selected_package:
1113
- self._show_uninstall_confirmation(selected_package['name'])
1114
-
1115
- def action_show_help(self) -> None:
1116
- """Show the help modal with keyboard shortcuts and features."""
1117
- self.push_screen(HelpScreen())
1118
-
1119
- def action_add_constraint(self) -> None:
1120
- """Add constraint to the currently selected package."""
1121
- selected_package = self._get_selected_package()
1122
- if not selected_package:
1123
- return
1124
-
1125
- package_name = selected_package['name']
1126
- current_constraint = selected_package.get('constraint', '')
1127
-
1128
- def handle_constraint_result(result) -> None:
1129
- """Handle the result from constraint input dialog."""
1130
- if result:
1131
- constraint = "" # Initialize to prevent unbound variable
1132
- try:
1133
- # Handle both string (constraint only) and tuple (constraint, trigger) results
1134
- if isinstance(result, tuple):
1135
- constraint, invalidation_trigger = result
1136
- else:
1137
- constraint = result
1138
- invalidation_trigger = ""
1139
-
1140
- from ..package_constraints import add_constraints_to_config
1141
-
1142
- # Add constraint to configuration
1143
- constraint_spec = f"{package_name}{constraint}"
1144
- config_path, changes = add_constraints_to_config([constraint_spec])
1145
-
1146
- # Add invalidation trigger if provided
1147
- if invalidation_trigger:
1148
- from ..package_constraints import format_invalidation_triggers, _get_section_name, _load_config, _write_config_file
1149
-
1150
- section_name = _get_section_name(None)
1151
- config, _ = _load_config(create_if_missing=False)
1152
-
1153
- # Format the trigger entry
1154
- formatted_entry = format_invalidation_triggers(constraint_spec, [invalidation_trigger])
1155
- if formatted_entry:
1156
- # Get existing triggers
1157
- existing_triggers = ""
1158
- if config.has_option(section_name, 'constraint_invalid_when'):
1159
- existing_triggers = config.get(section_name, 'constraint_invalid_when')
1160
-
1161
- # Add the new trigger
1162
- if existing_triggers.strip():
1163
- triggers_value = f"{existing_triggers},{formatted_entry}"
1164
- else:
1165
- triggers_value = formatted_entry
1166
-
1167
- config.set(section_name, 'constraint_invalid_when', triggers_value)
1168
- _write_config_file(config, config_path)
1169
-
1170
- # Update the package data in all_packages
1171
- for pkg in self.all_packages:
1172
- if pkg['name'] == package_name:
1173
- pkg['constraint'] = constraint
1174
- if invalidation_trigger:
1175
- pkg['invalid_when'] = invalidation_trigger
1176
- break
1177
-
1178
- # Refresh the table display
1179
- self._refresh_table_display(preserve_cursor=True)
1180
-
1181
- # Show success message
1182
- change_type, old_constraint = changes.get(package_name.lower(), ('added', None))
1183
- if change_type == 'updated':
1184
- message = f"Updated constraint for {package_name}: {old_constraint} → {constraint}"
1185
- elif change_type == 'added':
1186
- message = f"Added constraint {package_name}{constraint}"
1187
- else:
1188
- message = f"Constraint {package_name}{constraint} already exists"
1189
-
1190
- if invalidation_trigger:
1191
- message += f" with invalidation trigger: {invalidation_trigger}"
1192
- self.notify(message)
1193
-
1194
- except Exception as e:
1195
- error_msg = str(e)
1196
- if "Invalid constraint specification" in error_msg:
1197
- self.notify(f"Invalid constraint '{constraint}' for {package_name}. Try formats like: >=1.0.0, <2.0, ==1.5.0, >1.0")
1198
- else:
1199
- self.notify(f"Error adding constraint: {e}")
1200
-
1201
- # Show constraint input screen
1202
- self.push_screen(
1203
- ConstraintInputScreen(package_name, current_constraint),
1204
- handle_constraint_result
1205
- )
1206
-
1207
- def action_delete_constraint(self) -> None:
1208
- """Delete constraint for the currently selected package."""
1209
- selected_package = self._get_selected_package()
1210
- if not selected_package:
1211
- return
1212
-
1213
- package_name = selected_package['name']
1214
- current_constraint = selected_package.get('constraint')
1215
-
1216
- if not current_constraint:
1217
- self.notify(f"No constraint to delete for {package_name}")
1218
- return
1219
-
1220
- def handle_delete_confirmation(confirmed: bool | None) -> None:
1221
- """Handle the result from delete constraint confirmation."""
1222
- if confirmed:
1223
- try:
1224
- from ..package_constraints import remove_constraints_from_config
1225
-
1226
- # Remove constraint from configuration
1227
- _, removed_constraints, removed_triggers = remove_constraints_from_config([package_name])
1228
-
1229
- if package_name.lower() in removed_constraints:
1230
- # Update the package data in all_packages
1231
- for pkg in self.all_packages:
1232
- if pkg['name'] == package_name:
1233
- pkg.pop('constraint', None)
1234
- pkg.pop('invalid_when', None)
1235
- break
1236
-
1237
- # Refresh the table display
1238
- self._refresh_table_display(preserve_cursor=True)
1239
-
1240
- # Show success message
1241
- trigger_count = len(removed_triggers.get(package_name.lower(), []))
1242
- if trigger_count > 0:
1243
- self.notify(f"Deleted constraint and {trigger_count} invalidation triggers for {package_name}")
1244
- else:
1245
- self.notify(f"Deleted constraint for {package_name}")
1246
- else:
1247
- self.notify(f"No constraint found for {package_name} in configuration")
1248
-
1249
- except Exception as e:
1250
- self.notify(f"Error deleting constraint: {e}")
1251
-
1252
- # Show confirmation dialog
1253
- self.push_screen(
1254
- DeleteConstraintConfirmScreen(package_name, current_constraint),
1255
- handle_delete_confirmation
1256
- )
1257
-
1258
- def action_remove_all_constraints(self) -> None:
1259
- """Remove all constraints from the pip configuration."""
1260
- # Count current constraints
1261
- constraint_count = len(self.constraints)
1262
-
1263
- if constraint_count == 0:
1264
- self.notify("No constraints to remove")
1265
- return
1266
-
1267
- def handle_remove_all_confirmation(confirmed: bool | None) -> None:
1268
- """Handle the result from remove all constraints confirmation."""
1269
- if confirmed:
1270
- try:
1271
- from ..package_constraints import _get_section_name, _load_config, _write_config_file
1272
-
1273
- # Get config section
1274
- section_name = _get_section_name(None)
1275
- config, config_path = _load_config(create_if_missing=False)
1276
-
1277
- if not config.has_section(section_name):
1278
- self.notify("No constraints configuration found")
1279
- return
1280
-
1281
- # Remove all constraint-related options
1282
- options_removed = []
1283
- if config.has_option(section_name, 'constraint'):
1284
- config.remove_option(section_name, 'constraint')
1285
- options_removed.append('constraints')
1286
-
1287
- if config.has_option(section_name, 'constraint_invalid_when'):
1288
- config.remove_option(section_name, 'constraint_invalid_when')
1289
- options_removed.append('invalidation triggers')
1290
-
1291
- # Remove the section if it's empty
1292
- if not config.options(section_name):
1293
- config.remove_section(section_name)
1294
-
1295
- # Write the updated config
1296
- _write_config_file(config, config_path)
1297
-
1298
- # Update our internal state
1299
- self.constraints = {}
1300
- self.invalidation_triggers = {}
1301
-
1302
- # Update all package data
1303
- for pkg in self.all_packages:
1304
- pkg.pop('constraint', None)
1305
- pkg.pop('invalid_when', None)
1306
-
1307
- # Refresh the table display
1308
- self._refresh_table_display(preserve_cursor=True)
1309
-
1310
- # Show success message
1311
- if options_removed:
1312
- self.notify(f"Removed all {constraint_count} constraints and {' and '.join(options_removed)}")
1313
- else:
1314
- self.notify("No constraints were found to remove")
1315
-
1316
- except Exception as e:
1317
- self.notify(f"Error removing constraints: {e}")
1318
-
1319
- # Show confirmation dialog
1320
- self.push_screen(
1321
- RemoveAllConstraintsConfirmScreen(constraint_count),
1322
- handle_remove_all_confirmation
1323
- )
1324
-
1325
- def _reload_constraints_in_ui(self) -> None:
1326
- """Reload constraints from configuration and update the UI display."""
1327
- try:
1328
- # Reload constraints and invalidation triggers from configuration
1329
- self.constraints = read_constraints()
1330
- self.invalidation_triggers = self._load_invalidation_triggers()
1331
-
1332
- # Update all packages with new constraint and invalidation trigger information
1333
- for pkg in self.all_packages:
1334
- pkg['constraint'] = self.constraints.get(pkg['name'].lower())
1335
-
1336
- # Update invalidation triggers
1337
- package_triggers = self.invalidation_triggers.get(pkg['name'].lower(), [])
1338
- pkg['invalid_when'] = ", ".join(package_triggers) if package_triggers else None
1339
-
1340
- # Refresh table display to show updated constraints
1341
- self._refresh_table_display(preserve_cursor=True)
1342
-
1343
- except Exception as e:
1344
- self.notify(f"Error reloading constraints in UI: {e}")
1345
-
1346
- def _set_update_button_enabled(self, enabled: bool) -> None:
1347
- """Enable or disable the Update Selected button."""
1348
- try:
1349
- button = self.query_one("#update-btn", Button)
1350
- button.disabled = not enabled
1351
- if not enabled:
1352
- button.label = "Checking Updates..."
1353
- else:
1354
- button.label = "Update Selected"
1355
- except Exception:
1356
- # Button might not exist or be accessible in all contexts
1357
- pass
1358
-
1359
- def on_button_pressed(self, event: Button.Pressed) -> None:
1360
- """Handle button presses."""
1361
- if event.button.id == "update-btn":
1362
- self.action_update_selected()
1363
- elif event.button.id == "quit-btn":
1364
- self.action_quit_app()
1365
-
1366
- def action_update_selected(self) -> None:
1367
- """Update selected packages."""
1368
- if not self.update_check_complete:
1369
- self.notify("⏳ Still checking for updates... Press U again once checking is complete", severity="warning")
1370
- return
1371
-
1372
- # Check if the update check was successful
1373
- if not self.update_check_successful:
1374
- self.notify("❌ Cannot update packages - the update check failed. Please check your network connection and try restarting pipu.", severity="error")
1375
- return
1376
-
1377
- # Get selected packages (those marked with green dots)
1378
- selected_packages = []
1379
-
1380
- for pkg in self.all_packages:
1381
- # Check if this package is selected for update
1382
- if pkg.get('selected', False) and pkg.get('outdated', False):
1383
- selected_packages.append(pkg)
1384
-
1385
- if not selected_packages:
1386
- self.notify("No packages selected for update", severity="warning")
1387
- return
1388
-
1389
- # Show confirmation dialog with update details
1390
- def handle_update_confirmation(confirmed: bool | None) -> None:
1391
- """Handle the result from update confirmation dialog."""
1392
- logger.info(f"Update confirmation result: {confirmed}")
1393
- if confirmed:
1394
- logger.info(f"Pushing PackageUpdateScreen with {len(selected_packages)} packages")
1395
- # Push the PackageUpdateScreen which handles the full update process
1396
- self.push_screen(PackageUpdateScreen(selected_packages))
1397
- logger.info("PackageUpdateScreen pushed successfully")
1398
- else:
1399
- logger.info("Update cancelled by user")
1400
- # If cancelled, just return without doing anything
1401
-
1402
- logger.info(f"Showing UpdateConfirmScreen for {len(selected_packages)} packages")
1403
- self.push_screen(UpdateConfirmScreen(selected_packages), handle_update_confirmation)
1404
-
1405
-
1406
-
1407
- def main_tui_app() -> None:
1408
- """Launch the main TUI application."""
1409
- # Set up terminal cleanup handlers before starting the app
1410
- _setup_signal_handlers()
1411
-
1412
- try:
1413
- app = MainTUIApp()
1414
- app.run()
1415
- except KeyboardInterrupt:
1416
- # Handle Ctrl+C gracefully
1417
- _restore_terminal()
1418
- sys.exit(0)
1419
- except Exception as e:
1420
- # Handle any other exceptions
1421
- _restore_terminal()
1422
- logger.error(f"TUI application error: {e}")
1423
- raise
1424
- finally:
1425
- # Always restore terminal state
1426
- _restore_terminal()
1427
-
1428
-
1429
- def interactive_package_selection(outdated_packages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1430
- """
1431
- Run interactive package selection using Textual TUI.
1432
-
1433
- :param outdated_packages: List of outdated package dictionaries
1434
- :returns: List of selected package dictionaries
1435
- """
1436
- if not outdated_packages:
1437
- return []
1438
-
1439
- # Set up terminal cleanup handlers before starting the app
1440
- _setup_signal_handlers()
1441
-
1442
- try:
1443
- app = PackageSelectionApp(outdated_packages)
1444
- app.run()
1445
-
1446
- if app.confirmed:
1447
- return app.selected_packages
1448
- else:
1449
- return []
1450
- except KeyboardInterrupt:
1451
- # Handle Ctrl+C gracefully
1452
- _restore_terminal()
1453
- return []
1454
- except Exception:
1455
- # Handle any other exceptions
1456
- _restore_terminal()
1457
- raise
1458
- finally:
1459
- # Always restore terminal state
1460
- _restore_terminal()