gitpane 1.0.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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Delete one captured remote branch on its server, independently of graph navigation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QRect, QSignalBlocker, Qt, Signal
|
|
10
|
+
from PySide6.QtWidgets import QCheckBox, QDialog, QHBoxLayout, QLayout, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget
|
|
11
|
+
from shiboken6 import isValid
|
|
12
|
+
|
|
13
|
+
from gitpane.config import DELETE_BRANCH_TITLE, LOCAL_PREFIX, SharedText
|
|
14
|
+
from gitpane.history.repository_value import ReferenceKind
|
|
15
|
+
from gitpane.operation.diagnostic import SensitiveTextFilter
|
|
16
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
17
|
+
from gitpane.reference.reference_edit import ReferenceDeletionIntent, occupied_branch_set, remote_branch_destination, remote_delete_candidates
|
|
18
|
+
from gitpane.ui.reference.branch_selector import BranchSelector
|
|
19
|
+
from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
|
|
20
|
+
from gitpane.ui.widget.content_measure import ScrolledContentMeasure
|
|
21
|
+
from gitpane.ui.widget.dialog_layout import fit_form
|
|
22
|
+
from gitpane.ui.widget.long_text import WrappingLabel
|
|
23
|
+
from gitpane.ui.widget.wheel_safe_drop_down import UrlSelector
|
|
24
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from gitpane.operation.repository_operation import OperationOutcome
|
|
28
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
29
|
+
from gitpane.transport.branch_exchange import PushTarget
|
|
30
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
31
|
+
|
|
32
|
+
DELETE_DIALOG_WIDTH = 640
|
|
33
|
+
DELETE_DIALOG_HEIGHT = 360
|
|
34
|
+
# Button that confirms the deletion of a branch that is not fully merged, kept apart from the window title so that each can change alone.
|
|
35
|
+
CONFIRM_BRANCH_DELETION_BUTTON_TEXT = "Delete branch"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DeleteBranchDialogText(Enum):
|
|
39
|
+
"""Texts only the Delete branch window shows, one member per text so a misspelt name is an error."""
|
|
40
|
+
|
|
41
|
+
SUCCEEDED = "Succeeded"
|
|
42
|
+
DELETE_LOCAL_BRANCH = "Delete local branch (if available)"
|
|
43
|
+
"""The check box that also deletes the local branch of the same name.
|
|
44
|
+
|
|
45
|
+
Not "local tracking branch": that is Git's word for a local branch that follows a remote one, but it reads like
|
|
46
|
+
"remote-tracking branch", the remote branch's local copy, which is not what this box deletes.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Wording of the step statuses that have one in the result list. Any other status is written as its own word, the
|
|
51
|
+
# way Git and the transport name it: that choice is explicit, and a test lists the statuses it covers.
|
|
52
|
+
STEP_WORD_BY_STATUS: dict[OperationStatus, DeleteBranchDialogText | SharedText] = {
|
|
53
|
+
OperationStatus.SUCCESS: DeleteBranchDialogText.SUCCEEDED,
|
|
54
|
+
OperationStatus.FAILED: SharedText.FAILED,
|
|
55
|
+
OperationStatus.REFUSED: SharedText.FAILED,
|
|
56
|
+
OperationStatus.NOT_STARTED: SharedText.NOT_STARTED,
|
|
57
|
+
OperationStatus.UNKNOWN: SharedText.OUTCOME_UNKNOWN,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def step_word(status: OperationStatus) -> str:
|
|
62
|
+
"""Give the word of a step status in the result list: its wording in the table, or else the status's own word."""
|
|
63
|
+
text = STEP_WORD_BY_STATUS.get(status)
|
|
64
|
+
return status.value if text is None else text.value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DeleteBranchDialog(ContentGrowthMixin, QDialog):
|
|
68
|
+
"""Delete one captured remote branch on its server, with an explicit remote choice and optional local tracking branches.
|
|
69
|
+
|
|
70
|
+
Local branches and tags have their own Git Extensions windows; this form only deletes a branch of a remote. The
|
|
71
|
+
window grows when its remote fields or its messages appear, so the list of local branches is never pushed out of
|
|
72
|
+
sight, and takes back its height when they go, never below the height it opened at or the one the user set.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
prepare_requested = Signal(object)
|
|
76
|
+
delete_requested = Signal(object)
|
|
77
|
+
destination_changed = Signal()
|
|
78
|
+
retry_requested = Signal()
|
|
79
|
+
|
|
80
|
+
def __init__(self, session_id: str, full_ref: str, parent: QWidget | None = None) -> None:
|
|
81
|
+
"""Keep the branch choice visible above permanent deletion and cancel actions."""
|
|
82
|
+
super().__init__(parent)
|
|
83
|
+
self.session_id = session_id
|
|
84
|
+
self.full_ref = full_ref
|
|
85
|
+
# Read by the reference form controller, which also runs the Delete tag window: this form deletes a remote branch.
|
|
86
|
+
self.remote_branch = True
|
|
87
|
+
self.snapshot: ReferenceSnapshot | None = None
|
|
88
|
+
self.intent: ReferenceDeletionIntent | None = None
|
|
89
|
+
self.closed = False
|
|
90
|
+
self.busy = False
|
|
91
|
+
self.confirmation: QMessageBox | None = None
|
|
92
|
+
self.remote_snapshot: RemoteSnapshot | None = None
|
|
93
|
+
self.push_target: PushTarget | None = None
|
|
94
|
+
self.remote_request: tuple | None = None
|
|
95
|
+
self.outcome: OperationOutcome | None = None
|
|
96
|
+
self.retry_operation_id = ""
|
|
97
|
+
self.retry_remote_success = False
|
|
98
|
+
self.candidates: tuple[ReferenceDeletionIntent, ...] = ()
|
|
99
|
+
self.local_intents: tuple[ReferenceDeletionIntent, ...] = ()
|
|
100
|
+
self.warning_refs: list[str] = []
|
|
101
|
+
self.text_filter = SensitiveTextFilter()
|
|
102
|
+
self.return_focus = parent.focusWidget() if parent is not None else None
|
|
103
|
+
self.setWindowTitle(DELETE_BRANCH_TITLE)
|
|
104
|
+
self.resize(DELETE_DIALOG_WIDTH, DELETE_DIALOG_HEIGHT)
|
|
105
|
+
layout = QVBoxLayout(self)
|
|
106
|
+
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
107
|
+
self.scroll_area = QScrollArea()
|
|
108
|
+
self.scroll_area.setWidgetResizable(True)
|
|
109
|
+
content = QWidget()
|
|
110
|
+
self.form = QVBoxLayout(content)
|
|
111
|
+
self.heading_label = WrappingLabel("Select branches")
|
|
112
|
+
self.branch_selector = BranchSelector()
|
|
113
|
+
self.branch_selector.setAccessibleName("Select branches")
|
|
114
|
+
self.heading_label.setBuddy(self.branch_selector)
|
|
115
|
+
self.form.addWidget(self.heading_label)
|
|
116
|
+
self.form.addWidget(self.branch_selector)
|
|
117
|
+
self.remote_checkbox = QCheckBox("Delete branch(es) from remote repository")
|
|
118
|
+
self.form.addWidget(self.remote_checkbox)
|
|
119
|
+
self.local_checkbox = QCheckBox(DeleteBranchDialogText.DELETE_LOCAL_BRANCH.value)
|
|
120
|
+
self.local_checkbox.setEnabled(False)
|
|
121
|
+
self.form.addWidget(self.local_checkbox)
|
|
122
|
+
self.remote_box = QWidget()
|
|
123
|
+
remote_form = QVBoxLayout(self.remote_box)
|
|
124
|
+
self.remote_selector = UrlSelector()
|
|
125
|
+
self.endpoint_selector = UrlSelector()
|
|
126
|
+
self.destination_edit = QLineEdit()
|
|
127
|
+
# The remote and the branch on it follow from the remote branch's Fetch mapping; only the endpoint is chosen.
|
|
128
|
+
self.destination_edit.setReadOnly(True)
|
|
129
|
+
self.remote_selector.setEnabled(False)
|
|
130
|
+
for label_text, widget in (("Remote repository", self.remote_selector), ("Remote branch", self.destination_edit), ("Push endpoint", self.endpoint_selector)):
|
|
131
|
+
label = WrappingLabel(label_text)
|
|
132
|
+
label.setBuddy(widget)
|
|
133
|
+
widget.setAccessibleName(label_text)
|
|
134
|
+
remote_form.addWidget(label)
|
|
135
|
+
remote_form.addWidget(widget)
|
|
136
|
+
self.remote_box.hide()
|
|
137
|
+
self.form.addWidget(self.remote_box)
|
|
138
|
+
self.candidate_label = WrappingLabel("Local tracking branch(es) candidate to deletion:")
|
|
139
|
+
self.candidate_list = QListWidget()
|
|
140
|
+
self.candidate_list.setAccessibleName(self.candidate_label.text())
|
|
141
|
+
self.candidate_label.setBuddy(self.candidate_list)
|
|
142
|
+
self.form.addWidget(self.candidate_label)
|
|
143
|
+
self.form.addWidget(self.candidate_list)
|
|
144
|
+
self.status_label = WrappingLabel()
|
|
145
|
+
self.status_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
146
|
+
self.status_label.setWordWrap(True)
|
|
147
|
+
self.status_label.setText("Reading references and remote tracking…")
|
|
148
|
+
self.form.addWidget(self.status_label)
|
|
149
|
+
self.form.addStretch()
|
|
150
|
+
self.scroll_area.setWidget(content)
|
|
151
|
+
# The remote fields shown by the remote deletion box, a failure or the result of each step arrive after the
|
|
152
|
+
# window opened, and the window follows them both ways by the rule it shares with Clone and Checkout branch, never
|
|
153
|
+
# below the height it opened at or the one the user set. It opens at its usual height, taller only when its rows
|
|
154
|
+
# need more, which keeps room for the result.
|
|
155
|
+
self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.GROW_ONLY, DELETE_DIALOG_HEIGHT)
|
|
156
|
+
layout.addWidget(self.scroll_area, 1)
|
|
157
|
+
actions = QHBoxLayout()
|
|
158
|
+
self.retry_button = QPushButton("Retry failed step")
|
|
159
|
+
self.retry_button.setAutoDefault(False)
|
|
160
|
+
self.retry_button.hide()
|
|
161
|
+
actions.addWidget(self.retry_button)
|
|
162
|
+
actions.addStretch()
|
|
163
|
+
self.submit_button = QPushButton("Delete")
|
|
164
|
+
actions.addWidget(self.submit_button)
|
|
165
|
+
self.submit_button.setAutoDefault(False)
|
|
166
|
+
self.submit_button.setEnabled(False)
|
|
167
|
+
self.cancel_button = QPushButton("Cancel")
|
|
168
|
+
actions.addWidget(self.cancel_button)
|
|
169
|
+
layout.addLayout(actions)
|
|
170
|
+
self.branch_selector.currentIndexChanged.connect(self._branch_changed)
|
|
171
|
+
self.remote_checkbox.toggled.connect(self._remote_toggled)
|
|
172
|
+
self.remote_selector.currentIndexChanged.connect(self._remote_changed)
|
|
173
|
+
self.endpoint_selector.currentIndexChanged.connect(self._destination_changed)
|
|
174
|
+
self.destination_edit.textChanged.connect(self._destination_changed)
|
|
175
|
+
self.retry_button.clicked.connect(self.retry_requested.emit)
|
|
176
|
+
self.submit_button.clicked.connect(self._submit)
|
|
177
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
178
|
+
self.finished.connect(self._closed)
|
|
179
|
+
self.refresh_metrics()
|
|
180
|
+
|
|
181
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
182
|
+
"""Fit deletion choices while keeping action buttons outside scrolling content."""
|
|
183
|
+
fit_form(self, available)
|
|
184
|
+
|
|
185
|
+
def populate(self, snapshot: ReferenceSnapshot) -> None:
|
|
186
|
+
"""Select the exact clicked ref without choosing a replacement on disappearance.
|
|
187
|
+
|
|
188
|
+
A capture arriving after the click or after the result changes nothing: it would build a new intention and
|
|
189
|
+
offer a deletion that was already asked or done.
|
|
190
|
+
"""
|
|
191
|
+
if self.closed or self.busy or self.outcome is not None:
|
|
192
|
+
return
|
|
193
|
+
self.snapshot = snapshot
|
|
194
|
+
self.status_label.setText("Reading remote tracking…")
|
|
195
|
+
with QSignalBlocker(self.branch_selector):
|
|
196
|
+
self.branch_selector.clear()
|
|
197
|
+
for reference in sorted(snapshot.references, key=lambda reference: reference.full_name):
|
|
198
|
+
if reference.kind == ReferenceKind.REMOTES and not reference.symbolic_target and reference.target_oid:
|
|
199
|
+
self.branch_selector.addItem(reference.full_name.removeprefix(LOCAL_PREFIX), reference.full_name)
|
|
200
|
+
self.branch_selector.setCurrentIndex(self.branch_selector.findData(self.full_ref))
|
|
201
|
+
self.branch_selector.setEnabled(self.branch_selector.count() > 0 and not self.busy and not self.closed)
|
|
202
|
+
self._update_intent()
|
|
203
|
+
|
|
204
|
+
def _update_intent(self) -> None:
|
|
205
|
+
"""Invalidate prior answers whenever the selected branch changes, and offer Delete only with a complete remote destination."""
|
|
206
|
+
if self.busy or self.closed:
|
|
207
|
+
return
|
|
208
|
+
self.intent = None
|
|
209
|
+
self.full_ref = self.branch_selector.currentData() or ""
|
|
210
|
+
if self.snapshot is not None and self.full_ref:
|
|
211
|
+
try:
|
|
212
|
+
self.intent = ReferenceDeletionIntent.capture(self.snapshot, self.full_ref, session_id=self.session_id)
|
|
213
|
+
except ValueError as error:
|
|
214
|
+
self.status_label.setText(str(error))
|
|
215
|
+
self.remote_request = None
|
|
216
|
+
if (self.remote_checkbox.isChecked() and self.remote_snapshot is not None and self.remote_selector.currentData()
|
|
217
|
+
and self.endpoint_selector.currentData() and self.destination_edit.text().startswith(LOCAL_PREFIX) and self.push_target is not None):
|
|
218
|
+
self.remote_request = (self.remote_snapshot, self.remote_selector.currentData(), self.destination_edit.text(), self.endpoint_selector.currentData(), self.push_target)
|
|
219
|
+
remote_ready = self.remote_checkbox.isChecked() and self.remote_request is not None
|
|
220
|
+
self.submit_button.setEnabled(self.intent is not None and remote_ready)
|
|
221
|
+
|
|
222
|
+
def populate_remote(self, snapshot: RemoteSnapshot) -> None:
|
|
223
|
+
"""Show real tracking only when it names a unique remote, branch and effective URL, and only before the click."""
|
|
224
|
+
if self.busy or self.closed or self.outcome is not None:
|
|
225
|
+
return
|
|
226
|
+
self.remote_snapshot = snapshot
|
|
227
|
+
self.status_label.clear()
|
|
228
|
+
with QSignalBlocker(self.remote_selector):
|
|
229
|
+
self.remote_selector.clear()
|
|
230
|
+
for name, entry in sorted(snapshot.entry_by_name.items()):
|
|
231
|
+
if entry.active and not entry.collision:
|
|
232
|
+
self.remote_selector.addItem(self.text_filter.redact(name), name)
|
|
233
|
+
self.remote_selector.setCurrentIndex(-1)
|
|
234
|
+
self._branch_changed()
|
|
235
|
+
|
|
236
|
+
def _branch_changed(self) -> None:
|
|
237
|
+
"""Derive the remote and its branch from the selected remote branch's Fetch mapping, and list its local candidates."""
|
|
238
|
+
if self.busy:
|
|
239
|
+
return
|
|
240
|
+
self.full_ref = self.branch_selector.currentData() or ""
|
|
241
|
+
remote_name, destination = "", ""
|
|
242
|
+
if self.remote_snapshot is not None:
|
|
243
|
+
try:
|
|
244
|
+
remote_name, destination = remote_branch_destination(self.remote_snapshot, self.full_ref)
|
|
245
|
+
except ValueError as error:
|
|
246
|
+
self.status_label.setText(str(error))
|
|
247
|
+
with QSignalBlocker(self.remote_selector), QSignalBlocker(self.destination_edit):
|
|
248
|
+
self.remote_selector.setCurrentIndex(self.remote_selector.findData(remote_name))
|
|
249
|
+
self.destination_edit.setText(destination if remote_name and destination.startswith(LOCAL_PREFIX) else "")
|
|
250
|
+
self._remote_changed()
|
|
251
|
+
self._populate_candidates()
|
|
252
|
+
|
|
253
|
+
def _populate_candidates(self) -> None:
|
|
254
|
+
"""Show exact upstream candidates and disable branches occupied by worktrees."""
|
|
255
|
+
self.candidate_list.clear()
|
|
256
|
+
self.candidates = ()
|
|
257
|
+
self.local_checkbox.setChecked(False)
|
|
258
|
+
if self.snapshot is not None and self.remote_snapshot is not None:
|
|
259
|
+
try:
|
|
260
|
+
self.candidates = remote_delete_candidates(self.snapshot, self.remote_snapshot, self.full_ref, self.session_id)
|
|
261
|
+
except ValueError as error:
|
|
262
|
+
self.status_label.setText(str(error))
|
|
263
|
+
protected_ref_set = occupied_branch_set(self.snapshot) if self.snapshot is not None else set()
|
|
264
|
+
selectable = False
|
|
265
|
+
for candidate in self.candidates:
|
|
266
|
+
protected = candidate.full_ref in protected_ref_set
|
|
267
|
+
item = QListWidgetItem(candidate.full_ref + (" — checked out in a worktree" if protected else ""))
|
|
268
|
+
item.setData(Qt.ItemDataRole.UserRole, candidate)
|
|
269
|
+
item.setToolTip(item.text())
|
|
270
|
+
if protected:
|
|
271
|
+
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
|
|
272
|
+
item.setCheckState(Qt.CheckState.Unchecked)
|
|
273
|
+
else:
|
|
274
|
+
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
275
|
+
item.setCheckState(Qt.CheckState.Checked)
|
|
276
|
+
selectable = True
|
|
277
|
+
self.candidate_list.addItem(item)
|
|
278
|
+
self.local_checkbox.setEnabled(selectable)
|
|
279
|
+
|
|
280
|
+
def selected_candidates(self) -> tuple[ReferenceDeletionIntent, ...]:
|
|
281
|
+
"""Return only explicitly enabled local candidates from the captured form."""
|
|
282
|
+
candidates: list[ReferenceDeletionIntent] = []
|
|
283
|
+
if not self.local_checkbox.isChecked():
|
|
284
|
+
return ()
|
|
285
|
+
for index in range(self.candidate_list.count()):
|
|
286
|
+
item = self.candidate_list.item(index)
|
|
287
|
+
if item.flags() & Qt.ItemFlag.ItemIsEnabled and item.checkState() == Qt.CheckState.Checked:
|
|
288
|
+
candidates.append(item.data(Qt.ItemDataRole.UserRole))
|
|
289
|
+
return tuple(candidates)
|
|
290
|
+
|
|
291
|
+
def _remote_changed(self) -> None:
|
|
292
|
+
"""Require explicit selection for multiple endpoints instead of invoking native fan-out."""
|
|
293
|
+
if self.busy:
|
|
294
|
+
return
|
|
295
|
+
name = self.remote_selector.currentData()
|
|
296
|
+
entry = self.remote_snapshot.entry_by_name.get(name) if self.remote_snapshot is not None else None
|
|
297
|
+
with QSignalBlocker(self.endpoint_selector):
|
|
298
|
+
self.endpoint_selector.clear()
|
|
299
|
+
if entry is not None:
|
|
300
|
+
for endpoint in sorted(set(entry.push_urls)):
|
|
301
|
+
self.endpoint_selector.addItem(self.text_filter.redact(endpoint), endpoint)
|
|
302
|
+
initial_index = 0 if self.endpoint_selector.count() == 1 else -1
|
|
303
|
+
self.endpoint_selector.setCurrentIndex(initial_index)
|
|
304
|
+
self._destination_changed()
|
|
305
|
+
|
|
306
|
+
def _remote_toggled(self, checked: bool) -> None:
|
|
307
|
+
"""Expose the exact remote destination only after the explicit opt-in."""
|
|
308
|
+
self.remote_box.setVisible(checked)
|
|
309
|
+
self._destination_changed()
|
|
310
|
+
|
|
311
|
+
def _destination_changed(self) -> None:
|
|
312
|
+
"""Discard captured authentication whenever a destination field changes."""
|
|
313
|
+
if self.busy:
|
|
314
|
+
return
|
|
315
|
+
self.push_target = None
|
|
316
|
+
self.destination_changed.emit()
|
|
317
|
+
self._update_intent()
|
|
318
|
+
|
|
319
|
+
def set_push_target(self, target: PushTarget | None, cause: str = "") -> None:
|
|
320
|
+
"""Bind the selected endpoint to its current non-secret account configuration."""
|
|
321
|
+
self.push_target = target
|
|
322
|
+
if cause:
|
|
323
|
+
self.status_label.setText(self.text_filter.redact(cause))
|
|
324
|
+
self._update_intent()
|
|
325
|
+
|
|
326
|
+
def _submit(self) -> None:
|
|
327
|
+
"""Check the captured branch before asking only the necessary unmerged question."""
|
|
328
|
+
if self.intent is None or self.busy or self.closed or not self.submit_button.isEnabled():
|
|
329
|
+
return
|
|
330
|
+
self.busy = True
|
|
331
|
+
self.local_intents = self.selected_candidates()
|
|
332
|
+
self.warning_refs.clear()
|
|
333
|
+
self.submit_button.setEnabled(False)
|
|
334
|
+
self.branch_selector.setEnabled(False)
|
|
335
|
+
self.remote_checkbox.setEnabled(False)
|
|
336
|
+
self.remote_box.setEnabled(False)
|
|
337
|
+
self.local_checkbox.setEnabled(False)
|
|
338
|
+
self.candidate_list.setEnabled(False)
|
|
339
|
+
self.retry_button.setEnabled(False)
|
|
340
|
+
self.prepare_requested.emit(self.intent)
|
|
341
|
+
|
|
342
|
+
def prepared(self, merged: bool) -> None:
|
|
343
|
+
"""Use Delete as authorization for merged branches and Cancel as the warning default."""
|
|
344
|
+
if self.closed or self.intent is None:
|
|
345
|
+
return
|
|
346
|
+
if merged:
|
|
347
|
+
self.delete_requested.emit(self.intent)
|
|
348
|
+
return
|
|
349
|
+
branch = (self.warning_refs.pop(0) if self.warning_refs else self.full_ref).removeprefix(LOCAL_PREFIX)
|
|
350
|
+
current_branch = (self.snapshot.head_ref or "HEAD").removeprefix(LOCAL_PREFIX)
|
|
351
|
+
question = QMessageBox(QMessageBox.Icon.Warning, DELETE_BRANCH_TITLE, f"Branch '{branch}' is not fully merged into '{current_branch}'. Delete it anyway?",
|
|
352
|
+
QMessageBox.StandardButton.NoButton, self)
|
|
353
|
+
prepare_message_box(question, DELETE_BRANCH_TITLE)
|
|
354
|
+
question.setTextFormat(Qt.TextFormat.PlainText)
|
|
355
|
+
delete_button = question.addButton(CONFIRM_BRANCH_DELETION_BUTTON_TEXT, QMessageBox.ButtonRole.AcceptRole)
|
|
356
|
+
cancel_button = question.addButton("Cancel deletion", QMessageBox.ButtonRole.RejectRole)
|
|
357
|
+
question.setDefaultButton(cancel_button)
|
|
358
|
+
question.setEscapeButton(cancel_button)
|
|
359
|
+
self.confirmation = question
|
|
360
|
+
captured = self.intent
|
|
361
|
+
|
|
362
|
+
def answer() -> None:
|
|
363
|
+
"""Bind the affirmative button to the original intent and invalidate late answers."""
|
|
364
|
+
self.confirmation = None
|
|
365
|
+
accepted = question.clickedButton() is delete_button
|
|
366
|
+
question.deleteLater()
|
|
367
|
+
if self.closed or self.intent != captured:
|
|
368
|
+
return
|
|
369
|
+
if accepted:
|
|
370
|
+
if self.warning_refs:
|
|
371
|
+
self.prepared(False)
|
|
372
|
+
return
|
|
373
|
+
self.intent = replace(captured, unmerged_confirmed=True)
|
|
374
|
+
self.delete_requested.emit(self.intent)
|
|
375
|
+
else:
|
|
376
|
+
self.busy = False
|
|
377
|
+
self.branch_selector.setEnabled(True)
|
|
378
|
+
self.remote_checkbox.setEnabled(True)
|
|
379
|
+
self.remote_box.setEnabled(True)
|
|
380
|
+
self.local_checkbox.setEnabled(bool(self.candidates))
|
|
381
|
+
self.candidate_list.setEnabled(True)
|
|
382
|
+
self._update_intent()
|
|
383
|
+
question.finished.connect(answer)
|
|
384
|
+
open_window_modal(question)
|
|
385
|
+
|
|
386
|
+
def show_error(self, cause: str) -> None:
|
|
387
|
+
"""Retain the exact failure without permitting stale capture reuse."""
|
|
388
|
+
self.busy = False
|
|
389
|
+
self.status_label.setText(self.text_filter.redact(cause))
|
|
390
|
+
self.branch_selector.setEnabled(False)
|
|
391
|
+
self.submit_button.setEnabled(False)
|
|
392
|
+
if self.outcome is not None:
|
|
393
|
+
self.retry_button.setEnabled(True)
|
|
394
|
+
|
|
395
|
+
def show_outcome(self, outcome: OperationOutcome) -> None:
|
|
396
|
+
"""Keep each deletion result visible until the user closes the form."""
|
|
397
|
+
self.busy = False
|
|
398
|
+
self.outcome = outcome
|
|
399
|
+
lines: list[str] = []
|
|
400
|
+
for step in outcome.steps:
|
|
401
|
+
lines.append(step.operation + (" " + step.full_ref if step.full_ref else "") + ": " + step_word(step.status) + " — " + step.effect)
|
|
402
|
+
if outcome.diagnostic is not None:
|
|
403
|
+
lines.append(outcome.diagnostic.detail)
|
|
404
|
+
self.status_label.setText("\n".join(lines))
|
|
405
|
+
self.cancel_button.setText("Close")
|
|
406
|
+
self.submit_button.setEnabled(False)
|
|
407
|
+
retryable = outcome.status in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.PARTIAL) and any(step.status in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.NOT_STARTED) for step in outcome.steps)
|
|
408
|
+
self.retry_button.setVisible(retryable)
|
|
409
|
+
self.retry_button.setEnabled(retryable)
|
|
410
|
+
|
|
411
|
+
def _closed(self) -> None:
|
|
412
|
+
"""Invalidate pending reads and questions and restore the launching widget focus."""
|
|
413
|
+
self.closed = True
|
|
414
|
+
if self.confirmation is not None:
|
|
415
|
+
self.confirmation.reject()
|
|
416
|
+
if self.return_focus is not None and isValid(self.return_focus):
|
|
417
|
+
self.return_focus.setFocus()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Render bounded operation feedback without owning Git work or interpreting its output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent, Qt
|
|
8
|
+
from PySide6.QtGui import QPalette
|
|
9
|
+
from PySide6.QtWidgets import QWidget
|
|
10
|
+
|
|
11
|
+
from gitpane.config import DARK_BACKGROUND_LIGHTNESS_LIMIT, ERROR_DARK_COLOR, ERROR_LIGHT_COLOR
|
|
12
|
+
from gitpane.operation.diagnostic import SensitiveTextFilter
|
|
13
|
+
from gitpane.ui.widget.long_text import WrappingLabel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_reference_summary(lines: list[tuple[str, bool]], palette: QPalette) -> str:
|
|
17
|
+
"""Escape filtered observation lines and emphasize only errors against the current theme."""
|
|
18
|
+
dark = palette.color(QPalette.ColorRole.Window).lightnessF() < DARK_BACKGROUND_LIGHTNESS_LIMIT
|
|
19
|
+
color = ERROR_DARK_COLOR if dark else ERROR_LIGHT_COLOR
|
|
20
|
+
text_filter = SensitiveTextFilter()
|
|
21
|
+
rendered_lines: list[str] = []
|
|
22
|
+
for text, error in lines:
|
|
23
|
+
literal = escape(text_filter.redact(text)).replace("\n", "<br>")
|
|
24
|
+
rendered_lines.append(f'<span style="color: {color}"><b>{literal}</b></span>' if error else literal)
|
|
25
|
+
return "<br>".join(rendered_lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReferenceSummaryLabel(WrappingLabel):
|
|
29
|
+
"""Keep ordinary messages literal and redraw rich results when the palette changes."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
32
|
+
"""Retain filtered lines only while an operation summary is visible."""
|
|
33
|
+
super().__init__(parent=parent)
|
|
34
|
+
self.summary_lines: list[tuple[str, bool]] = []
|
|
35
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
36
|
+
self.setWordWrap(True)
|
|
37
|
+
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
38
|
+
|
|
39
|
+
def setText(self, text: str) -> None:
|
|
40
|
+
"""Display non-summary messages as literal filtered text."""
|
|
41
|
+
self.summary_lines = []
|
|
42
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
43
|
+
super().setText(SensitiveTextFilter().redact(text))
|
|
44
|
+
|
|
45
|
+
def clear(self) -> None:
|
|
46
|
+
"""Forget a previous summary when a new operation begins."""
|
|
47
|
+
self.setText("")
|
|
48
|
+
|
|
49
|
+
def show_summary(self, lines: list[tuple[str, bool]]) -> None:
|
|
50
|
+
"""Retain safe observations for palette updates and text selection."""
|
|
51
|
+
text_filter = SensitiveTextFilter()
|
|
52
|
+
self.summary_lines = []
|
|
53
|
+
for text, error in lines:
|
|
54
|
+
self.summary_lines.append((text_filter.redact(text), error))
|
|
55
|
+
self.setTextFormat(Qt.TextFormat.RichText)
|
|
56
|
+
super().setText(format_reference_summary(self.summary_lines, self.palette()))
|
|
57
|
+
|
|
58
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
59
|
+
"""Refresh error contrast after a live theme change."""
|
|
60
|
+
super().changeEvent(event)
|
|
61
|
+
if event.type() == QEvent.Type.PaletteChange and self.summary_lines:
|
|
62
|
+
super().setText(format_reference_summary(self.summary_lines, self.palette()))
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Share the life cycle of the reference windows that reproduce Git Extensions forms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt
|
|
8
|
+
from PySide6.QtWidgets import QDialog, QMessageBox, QPushButton, QWidget
|
|
9
|
+
from shiboken6 import isValid
|
|
10
|
+
|
|
11
|
+
from gitpane.config import fit_window
|
|
12
|
+
from gitpane.operation.diagnostic import SensitiveTextFilter
|
|
13
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from PySide6.QtCore import QRect
|
|
17
|
+
from PySide6.QtGui import QCloseEvent, QKeyEvent
|
|
18
|
+
|
|
19
|
+
from gitpane.reference.reference_edit import BranchDeletionIntent, BranchIntent, CheckoutIntent, ReferenceDeletionIntent, ResetIntent, TagIntent
|
|
20
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
21
|
+
from gitpane.ui.reference.commit_summary import CommitSummary
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def reference_warning_box(title: str, cause: str, parent: QWidget | None, text_filter: SensitiveTextFilter | None = None) -> QMessageBox:
|
|
25
|
+
"""Build the warning box of a reference form: the form's title, a warning icon, OK, and the cause as plain text with anything sensitive removed.
|
|
26
|
+
|
|
27
|
+
Both the error of a form in use and the refusal of a form at its opening are built here, so neither can drop the
|
|
28
|
+
removal of secrets nor differ in look from the other. A form in use passes its own filter, which keeps the secrets
|
|
29
|
+
it already met.
|
|
30
|
+
"""
|
|
31
|
+
redacted = (text_filter or SensitiveTextFilter()).redact(cause)
|
|
32
|
+
box = QMessageBox(QMessageBox.Icon.Warning, title, redacted, QMessageBox.StandardButton.Ok, parent)
|
|
33
|
+
prepare_message_box(box, title)
|
|
34
|
+
box.setTextFormat(Qt.TextFormat.PlainText)
|
|
35
|
+
return box
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReferenceFormDialog(QDialog):
|
|
39
|
+
"""Base of the reference forms: frozen while Git runs, errors in a message box, Enter on the focused button.
|
|
40
|
+
|
|
41
|
+
A subclass builds its body and sets ``submit_button``; the reference form controller owns every Git read and write. While
|
|
42
|
+
``busy`` the form cannot be closed, so the captured request stays visible until its operation ends.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, session_id: str, parent: QWidget | None = None) -> None:
|
|
46
|
+
"""Start idle, without a capture, and remember which control to give the focus back to on closing."""
|
|
47
|
+
super().__init__(parent)
|
|
48
|
+
self.session_id = session_id
|
|
49
|
+
self.snapshot: ReferenceSnapshot | None = None
|
|
50
|
+
# Each form freezes its own kind of request; the Delete branch window of local branches freezes a group deletion.
|
|
51
|
+
self.intent: TagIntent | BranchIntent | CheckoutIntent | ResetIntent | ReferenceDeletionIntent | BranchDeletionIntent | None = None
|
|
52
|
+
self.busy = False
|
|
53
|
+
self.closed = False
|
|
54
|
+
self.completed = False
|
|
55
|
+
self.return_focus = parent.focusWidget() if parent is not None else None
|
|
56
|
+
self.error_box: QMessageBox | None = None
|
|
57
|
+
self.text_filter = SensitiveTextFilter()
|
|
58
|
+
# Raised each time the form targets another commit, so a late position answer for an older target is ignored.
|
|
59
|
+
self.position_generation = 0
|
|
60
|
+
# The Git Extensions commit box of the forms that show one; the reference form controller fills it for each new target.
|
|
61
|
+
self.commit_summary: CommitSummary | None = None
|
|
62
|
+
# Height of the forms made of a single row, which Git Extensions lets grow in width only; None lets the height change.
|
|
63
|
+
self.single_row_height: int | None = None
|
|
64
|
+
self.submit_button = QPushButton()
|
|
65
|
+
# The Git Extensions forms have no help button in their title bar, and there is no help to open.
|
|
66
|
+
self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
|
|
67
|
+
self.finished.connect(self._closed)
|
|
68
|
+
|
|
69
|
+
def show_error(self, cause: str) -> None:
|
|
70
|
+
"""Refuse submission and explain why in a Windows-style warning box, as Git Extensions does.
|
|
71
|
+
|
|
72
|
+
The forms have no status area, so the cause goes to a non-blocking message box, and to the submit button's
|
|
73
|
+
tooltip so it can still be read after the box is closed. A second error updates the box already open instead
|
|
74
|
+
of stacking another one.
|
|
75
|
+
"""
|
|
76
|
+
text = self.text_filter.redact(cause)
|
|
77
|
+
self.submit_button.setEnabled(False)
|
|
78
|
+
self.submit_button.setToolTip(text)
|
|
79
|
+
if self.error_box is not None and isValid(self.error_box):
|
|
80
|
+
self.error_box.setText(text)
|
|
81
|
+
open_window_modal(self.error_box)
|
|
82
|
+
return
|
|
83
|
+
self.error_box = reference_warning_box(self.windowTitle(), cause, self, self.text_filter)
|
|
84
|
+
open_window_modal(self.error_box)
|
|
85
|
+
|
|
86
|
+
def fit_capture_controls(self) -> None:
|
|
87
|
+
"""Size the captions and buttons copied from the form's capture for the current font; forms that have some override it.
|
|
88
|
+
|
|
89
|
+
The captures were taken with Segoe UI 9 pt: each control keeps its capture's size where its text fits, and takes
|
|
90
|
+
the size its text needs with a wider font, so no caption or button text is cut.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
94
|
+
"""Size the controls copied from the capture, then keep the form on the usable screen area without turning its starting size into a minimum.
|
|
95
|
+
|
|
96
|
+
A single-row form gets its fixed height back afterwards: fitting the window rewrites its minimum size, which
|
|
97
|
+
would otherwise let the height change again. That height is the capture's, or the height its row needs when a
|
|
98
|
+
larger text size makes the caption, the field or the button taller, so no text is cut.
|
|
99
|
+
"""
|
|
100
|
+
self.fit_capture_controls()
|
|
101
|
+
fit_window(self, 0, 0, available)
|
|
102
|
+
if self.single_row_height is not None:
|
|
103
|
+
self.setFixedHeight(max(self.single_row_height, self.layout().totalMinimumSize().height()))
|
|
104
|
+
|
|
105
|
+
def reject(self) -> None:
|
|
106
|
+
"""Close an idle form; a form whose request is running stays open."""
|
|
107
|
+
if not self.busy:
|
|
108
|
+
super().reject()
|
|
109
|
+
|
|
110
|
+
def accept(self) -> None:
|
|
111
|
+
"""Ignore acceptance while the native operation still owns the captured request."""
|
|
112
|
+
if not self.busy:
|
|
113
|
+
super().accept()
|
|
114
|
+
|
|
115
|
+
def closeEvent(self, event: QCloseEvent) -> None:
|
|
116
|
+
"""Ignore the title bar close button while Git is still using the captured choices."""
|
|
117
|
+
if self.busy:
|
|
118
|
+
event.ignore()
|
|
119
|
+
return
|
|
120
|
+
super().closeEvent(event)
|
|
121
|
+
|
|
122
|
+
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
123
|
+
"""Press the focused button on Enter, as Windows does; anywhere else Enter runs the default submit button."""
|
|
124
|
+
focused = self.focusWidget()
|
|
125
|
+
if (event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and isinstance(focused, QPushButton) and focused is not self.submit_button
|
|
126
|
+
and self.isAncestorOf(focused)):
|
|
127
|
+
if focused.isEnabled():
|
|
128
|
+
focused.click()
|
|
129
|
+
return
|
|
130
|
+
super().keyPressEvent(event)
|
|
131
|
+
|
|
132
|
+
def _closed(self) -> None:
|
|
133
|
+
"""Invalidate late answers, close a pending error box and give the focus back to the launching control."""
|
|
134
|
+
self.closed = True
|
|
135
|
+
self.position_generation += 1
|
|
136
|
+
if self.error_box is not None and isValid(self.error_box):
|
|
137
|
+
self.error_box.close()
|
|
138
|
+
if self.return_focus is not None and isValid(self.return_focus):
|
|
139
|
+
self.return_focus.setFocus()
|