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