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,419 @@
|
|
|
1
|
+
"""Present captured branches without writing until the explicit Checkout action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QRect, Qt, Signal
|
|
8
|
+
from PySide6.QtWidgets import QButtonGroup, QDialog, QFrame, QHBoxLayout, QLayout, QLineEdit, QPushButton, QRadioButton, QScrollArea, QVBoxLayout, QWidget
|
|
9
|
+
from shiboken6 import isValid
|
|
10
|
+
|
|
11
|
+
from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX, SPACING_SM
|
|
12
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
13
|
+
from gitpane.reference.reference_edit import CheckoutIntent, CheckoutMode, remote_branch_name
|
|
14
|
+
from gitpane.text.typed_text import typed_value
|
|
15
|
+
from gitpane.ui.reference.branch_selector import BranchSelector
|
|
16
|
+
from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
|
|
17
|
+
from gitpane.ui.widget.content_measure import ScrolledContentMeasure
|
|
18
|
+
from gitpane.ui.widget.dialog_layout import fit_form
|
|
19
|
+
from gitpane.ui.widget.long_text import SelectionSummaryRow, WrappingLabel
|
|
20
|
+
from gitpane.ui.widget.refresh_advice import refresh_advice_text
|
|
21
|
+
from gitpane.ui.widget.result_label import ResultLabel
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from PySide6.QtGui import QCloseEvent
|
|
25
|
+
|
|
26
|
+
from gitpane.operation.repository_operation import OperationOutcome
|
|
27
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
28
|
+
|
|
29
|
+
CHECKOUT_WIDTH = 800
|
|
30
|
+
# The height asked before the first show; once shown, the window takes the exact height of its content instead.
|
|
31
|
+
CHECKOUT_HEIGHT = 280
|
|
32
|
+
# The first remote choice: create the local branch, or, when a local branch of that name already exists, move it onto
|
|
33
|
+
# the remote branch the way Git Extensions words it.
|
|
34
|
+
CREATE_SAME_NAME_TEXT = "Create local branch with same name:"
|
|
35
|
+
RESET_LOCAL_BRANCH_TEXT = "Reset local branch with the name:"
|
|
36
|
+
# The orange text under the choices telling how many local commits a reset would lose; the click on Checkout confirms.
|
|
37
|
+
LOST_COMMITS_WARNING_TEMPLATE = "{branch} has {count} commits that are not in {remote}. They will be lost."
|
|
38
|
+
LOST_COMMIT_WARNING_TEMPLATE = "{branch} has 1 commit that is not in {remote}. It will be lost."
|
|
39
|
+
LOST_COMMIT_FAILURE_TEMPLATE = "Commits a reset would lose: unavailable ({cause})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CheckoutDialog(ContentGrowthMixin, QDialog):
|
|
43
|
+
"""Collect an explicit branch checkout while the reference form controller owns asynchronous Git work.
|
|
44
|
+
|
|
45
|
+
Checking out a single commit has its own Checkout revision window; this one only lists branches.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
selection_changed = Signal(str)
|
|
49
|
+
# Local commit ID, remote commit ID: asks how many commits the local branch has that the remote one lacks.
|
|
50
|
+
lost_commit_count_requested = Signal(str, str)
|
|
51
|
+
checkout_requested = Signal(object)
|
|
52
|
+
|
|
53
|
+
def __init__(self, session_id: str, parent: QWidget | None = None) -> None:
|
|
54
|
+
"""Keep actions outside the scrolling content and preserve focus on closure."""
|
|
55
|
+
super().__init__(parent)
|
|
56
|
+
self.session_id = session_id
|
|
57
|
+
self.snapshot: ReferenceSnapshot | None = None
|
|
58
|
+
self.intent: CheckoutIntent | None = None
|
|
59
|
+
self.closed = False
|
|
60
|
+
self.busy = False
|
|
61
|
+
self.read_generation = 0
|
|
62
|
+
self.comparison_ready = False
|
|
63
|
+
# True while the selected remote branch has a local branch of the same name, which the first choice resets.
|
|
64
|
+
self.resets_local_branch = False
|
|
65
|
+
# How many commits the reset would lose, None while unknown for the current selection.
|
|
66
|
+
self.lost_commit_count: int | None = None
|
|
67
|
+
# Why that count could not be read, shown in orange under the choices and in the comparison tooltip; empty when nothing failed.
|
|
68
|
+
self.lost_commit_cause = ""
|
|
69
|
+
self.difference_tooltip = ""
|
|
70
|
+
self.return_focus = parent.focusWidget() if parent is not None else None
|
|
71
|
+
self.setWindowTitle("Checkout branch")
|
|
72
|
+
self.resize(CHECKOUT_WIDTH, CHECKOUT_HEIGHT)
|
|
73
|
+
layout = QVBoxLayout(self)
|
|
74
|
+
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
75
|
+
self.scroll_area = QScrollArea()
|
|
76
|
+
self.scroll_area.setWidgetResizable(True)
|
|
77
|
+
content = QWidget()
|
|
78
|
+
form = QVBoxLayout(content)
|
|
79
|
+
form.setSpacing(SPACING_SM)
|
|
80
|
+
kinds = QHBoxLayout()
|
|
81
|
+
self.local_radio = QRadioButton("Local branch")
|
|
82
|
+
self.remote_radio = QRadioButton("Remote branch")
|
|
83
|
+
self.kind_group = QButtonGroup(self)
|
|
84
|
+
self.kind_group.addButton(self.local_radio)
|
|
85
|
+
self.kind_group.addButton(self.remote_radio)
|
|
86
|
+
self.local_radio.setChecked(True)
|
|
87
|
+
kinds.addWidget(self.local_radio)
|
|
88
|
+
kinds.addWidget(self.remote_radio)
|
|
89
|
+
kinds.addStretch()
|
|
90
|
+
form.addLayout(kinds)
|
|
91
|
+
self.branch_selector = BranchSelector()
|
|
92
|
+
label = WrappingLabel("Select branch")
|
|
93
|
+
label.setBuddy(self.branch_selector)
|
|
94
|
+
self.difference_label = WrappingLabel("Commit difference unavailable")
|
|
95
|
+
self.selection_row = SelectionSummaryRow(label, self.branch_selector, self.difference_label)
|
|
96
|
+
form.addWidget(self.selection_row)
|
|
97
|
+
self.target_label = WrappingLabel("Loading references…")
|
|
98
|
+
self.target_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
99
|
+
self.target_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
100
|
+
self.target_label.setWordWrap(True)
|
|
101
|
+
form.addWidget(self.target_label)
|
|
102
|
+
self.separator = QFrame()
|
|
103
|
+
self.separator.setFrameShape(QFrame.Shape.HLine)
|
|
104
|
+
self.separator.setFrameShadow(QFrame.Shadow.Sunken)
|
|
105
|
+
form.addWidget(self.separator)
|
|
106
|
+
self.remote_options = QWidget()
|
|
107
|
+
options = QVBoxLayout(self.remote_options)
|
|
108
|
+
options.setContentsMargins(0, 0, 0, 0)
|
|
109
|
+
self.same_radio = QRadioButton(CREATE_SAME_NAME_TEXT)
|
|
110
|
+
self.same_name_label = WrappingLabel()
|
|
111
|
+
self.same_name_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
112
|
+
self.same_name_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
113
|
+
self.same_name_label.setWordWrap(True)
|
|
114
|
+
same_row = QHBoxLayout()
|
|
115
|
+
same_row.addWidget(self.same_radio)
|
|
116
|
+
same_row.addWidget(self.same_name_label, 1)
|
|
117
|
+
options.addLayout(same_row)
|
|
118
|
+
self.custom_radio = QRadioButton("Create local branch with custom name:")
|
|
119
|
+
self.custom_edit = QLineEdit()
|
|
120
|
+
self.custom_edit.setAccessibleName("Custom local branch name")
|
|
121
|
+
self.custom_edit.setEnabled(False)
|
|
122
|
+
custom_row = QHBoxLayout()
|
|
123
|
+
custom_row.addWidget(self.custom_radio)
|
|
124
|
+
custom_row.addWidget(self.custom_edit, 1)
|
|
125
|
+
options.addLayout(custom_row)
|
|
126
|
+
self.detached_radio = QRadioButton("Checkout the commit (in detached head)")
|
|
127
|
+
options.addWidget(self.detached_radio)
|
|
128
|
+
self.lost_commit_label = ResultLabel()
|
|
129
|
+
self.lost_commit_label.hide()
|
|
130
|
+
options.addWidget(self.lost_commit_label)
|
|
131
|
+
self.mode_group = QButtonGroup(self)
|
|
132
|
+
for radio in (self.same_radio, self.custom_radio, self.detached_radio):
|
|
133
|
+
self.mode_group.addButton(radio)
|
|
134
|
+
self.same_radio.setChecked(True)
|
|
135
|
+
self.remote_options.hide()
|
|
136
|
+
form.addWidget(self.remote_options)
|
|
137
|
+
self.status_label = WrappingLabel()
|
|
138
|
+
self.status_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
139
|
+
self.status_label.setWordWrap(True)
|
|
140
|
+
self.status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
141
|
+
form.addWidget(self.status_label)
|
|
142
|
+
form.addStretch()
|
|
143
|
+
self.scroll_area.setWidget(content)
|
|
144
|
+
# The content changes after the window opened (references read, commit difference, remote choices, orange lost
|
|
145
|
+
# commit text, error) and the window follows it both ways by the rule it shares with Clone, never below the height
|
|
146
|
+
# it opened at or the one the user set; once shown, it takes the exact height of the content it opens with,
|
|
147
|
+
# without empty room under the choices.
|
|
148
|
+
self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.EXACT, CHECKOUT_HEIGHT)
|
|
149
|
+
layout.addWidget(self.scroll_area, 1)
|
|
150
|
+
actions = QHBoxLayout()
|
|
151
|
+
actions.addStretch()
|
|
152
|
+
self.checkout_button = QPushButton("Checkout")
|
|
153
|
+
self.checkout_button.setEnabled(False)
|
|
154
|
+
self.checkout_button.setAutoDefault(False)
|
|
155
|
+
self.cancel_button = QPushButton("Cancel")
|
|
156
|
+
actions.addWidget(self.checkout_button)
|
|
157
|
+
actions.addWidget(self.cancel_button)
|
|
158
|
+
layout.addLayout(actions)
|
|
159
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
160
|
+
self.checkout_button.clicked.connect(self._submit)
|
|
161
|
+
self.branch_selector.currentIndexChanged.connect(self._selection_changed)
|
|
162
|
+
self.local_radio.clicked.connect(lambda: self._select_kind(LOCAL_PREFIX))
|
|
163
|
+
self.remote_radio.clicked.connect(lambda: self._select_kind(REMOTE_PREFIX))
|
|
164
|
+
self.mode_group.buttonClicked.connect(self._mode_changed)
|
|
165
|
+
self.custom_edit.textChanged.connect(self._mode_changed)
|
|
166
|
+
self.finished.connect(self._closed)
|
|
167
|
+
self.refresh_metrics()
|
|
168
|
+
|
|
169
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
170
|
+
"""Apply the shared form metrics and keep the window inside the usable screen area.
|
|
171
|
+
|
|
172
|
+
Called when the window is built, shown, and moved to another screen or given another font. The height follows
|
|
173
|
+
the content through the growth rule shared with Clone, not here.
|
|
174
|
+
"""
|
|
175
|
+
fit_form(self, available)
|
|
176
|
+
|
|
177
|
+
def populate(self, snapshot: ReferenceSnapshot, full_ref: str) -> None:
|
|
178
|
+
"""Publish only the captured selection and begin its independent comparison."""
|
|
179
|
+
self.snapshot = snapshot
|
|
180
|
+
self.branch_selector.populate(snapshot, full_ref)
|
|
181
|
+
self._selection_changed()
|
|
182
|
+
|
|
183
|
+
def _select_kind(self, prefix: str) -> None:
|
|
184
|
+
"""Choose an actual reference of the requested kind, or an explicit empty selection."""
|
|
185
|
+
for index in range(self.branch_selector.count()):
|
|
186
|
+
full_ref = self.branch_selector.itemData(index)
|
|
187
|
+
if full_ref and full_ref.startswith(prefix):
|
|
188
|
+
self.branch_selector.setCurrentIndex(index)
|
|
189
|
+
return
|
|
190
|
+
self.branch_selector.setCurrentIndex(-1)
|
|
191
|
+
|
|
192
|
+
def _mode_changed(self) -> None:
|
|
193
|
+
"""Update the draft mode without replacing the selected reference or OID comparison."""
|
|
194
|
+
self.custom_edit.setEnabled(self.custom_radio.isChecked() and not self.busy)
|
|
195
|
+
self._show_lost_commit_warning()
|
|
196
|
+
self._make_intent()
|
|
197
|
+
self._update_checkout_button()
|
|
198
|
+
|
|
199
|
+
def _reset_count_pending(self) -> bool:
|
|
200
|
+
"""Tell that the reset of the local branch is chosen while neither the number of commits it loses nor why that number failed has arrived."""
|
|
201
|
+
return self.resets_local_branch and self.same_radio.isChecked() and self.lost_commit_count is None and not self.lost_commit_cause
|
|
202
|
+
|
|
203
|
+
def _update_checkout_button(self) -> None:
|
|
204
|
+
"""Allow Checkout only for a complete choice whose commits were compared, and for a reset only once its lost commits are shown.
|
|
205
|
+
|
|
206
|
+
A reset moves the local branch and drops its own commits, so the click must come after the user could read
|
|
207
|
+
how many there are, or why that number is unknown.
|
|
208
|
+
"""
|
|
209
|
+
self.checkout_button.setEnabled(self.intent is not None and self.comparison_ready and not self.busy and not self._reset_count_pending())
|
|
210
|
+
|
|
211
|
+
def _make_intent(self) -> None:
|
|
212
|
+
"""Build an immutable intention from exact item data and the explicit remote mode.
|
|
213
|
+
|
|
214
|
+
The local name is taken without the blanks around it; the field keeps what was typed.
|
|
215
|
+
"""
|
|
216
|
+
self.intent = None
|
|
217
|
+
full_ref = self.branch_selector.currentData()
|
|
218
|
+
if self.snapshot is None or not full_ref:
|
|
219
|
+
return
|
|
220
|
+
mode = CheckoutMode.LOCAL
|
|
221
|
+
if full_ref.startswith(REMOTE_PREFIX):
|
|
222
|
+
mode = CheckoutMode.CUSTOM if self.custom_radio.isChecked() else CheckoutMode.DETACHED if self.detached_radio.isChecked() else CheckoutMode.RESET_LOCAL if self.resets_local_branch else CheckoutMode.SAME
|
|
223
|
+
local_name = typed_value(self.custom_edit.text())
|
|
224
|
+
try:
|
|
225
|
+
self.intent = CheckoutIntent.capture(self.snapshot, full_ref, refresh_advice=refresh_advice_text, session_id=self.session_id, mode=mode, local_name=local_name)
|
|
226
|
+
if mode == CheckoutMode.CUSTOM and not local_name:
|
|
227
|
+
self.intent = None
|
|
228
|
+
except ValueError as error:
|
|
229
|
+
self.show_error(str(error))
|
|
230
|
+
|
|
231
|
+
def _selection_changed(self) -> None:
|
|
232
|
+
"""Invalidate old comparison output whenever a different reference is selected."""
|
|
233
|
+
self.intent = None
|
|
234
|
+
self.comparison_ready = False
|
|
235
|
+
self.read_generation += 1
|
|
236
|
+
self.checkout_button.setEnabled(False)
|
|
237
|
+
self.difference_label.setText("Commit difference unavailable")
|
|
238
|
+
self._set_difference_tooltip("")
|
|
239
|
+
self.lost_commit_count = None
|
|
240
|
+
self.lost_commit_cause = ""
|
|
241
|
+
self._show_lost_commit_warning()
|
|
242
|
+
full_ref = self.branch_selector.currentData()
|
|
243
|
+
if self.snapshot is None or not full_ref:
|
|
244
|
+
self.remote_options.hide()
|
|
245
|
+
self.target_label.setText("Select a branch to check out")
|
|
246
|
+
return
|
|
247
|
+
remote = full_ref.startswith(REMOTE_PREFIX)
|
|
248
|
+
self.remote_options.setVisible(remote)
|
|
249
|
+
self.remote_radio.setChecked(remote)
|
|
250
|
+
self.local_radio.setChecked(not remote)
|
|
251
|
+
self.target_label.setText(full_ref)
|
|
252
|
+
self.resets_local_branch = False
|
|
253
|
+
if remote:
|
|
254
|
+
self._show_local_branch_choice(full_ref)
|
|
255
|
+
self._make_intent()
|
|
256
|
+
self.difference_label.setText("Comparing commits…")
|
|
257
|
+
if self.intent is not None:
|
|
258
|
+
self.selection_changed.emit(self.intent.target_oid)
|
|
259
|
+
if self.resets_local_branch and self.intent is not None:
|
|
260
|
+
local_oid = self.snapshot.object_oid_by_name[LOCAL_PREFIX + remote_branch_name(self.snapshot, full_ref)]
|
|
261
|
+
self.lost_commit_count_requested.emit(local_oid, self.intent.target_oid)
|
|
262
|
+
|
|
263
|
+
def show_lost_commits(self, count: int) -> None:
|
|
264
|
+
"""Keep how many commits the reset would lose, show the warning when there are some, and allow Checkout once known."""
|
|
265
|
+
self.lost_commit_count = count
|
|
266
|
+
self._show_lost_commit_warning()
|
|
267
|
+
self._update_checkout_button()
|
|
268
|
+
|
|
269
|
+
def show_lost_commit_failure(self, cause: str) -> None:
|
|
270
|
+
"""Show under the choices, in orange, why Git could not count the commits the reset would lose, then allow Checkout.
|
|
271
|
+
|
|
272
|
+
The cause also stays in the comparison tooltip. The user has read that the number is unknown, so the click on
|
|
273
|
+
Checkout is an informed one.
|
|
274
|
+
"""
|
|
275
|
+
self.lost_commit_cause = cause
|
|
276
|
+
self._set_difference_tooltip(self.difference_tooltip)
|
|
277
|
+
self._show_lost_commit_warning()
|
|
278
|
+
self._update_checkout_button()
|
|
279
|
+
|
|
280
|
+
def _show_lost_commit_warning(self) -> None:
|
|
281
|
+
"""Show or hide the orange lost commit text; the window grows to show it whole, never needing to scroll for it."""
|
|
282
|
+
text = self._lost_commit_warning_text()
|
|
283
|
+
if text:
|
|
284
|
+
self.lost_commit_label.show_warning(text)
|
|
285
|
+
self.lost_commit_label.show()
|
|
286
|
+
else:
|
|
287
|
+
self.lost_commit_label.clear()
|
|
288
|
+
self.lost_commit_label.hide()
|
|
289
|
+
|
|
290
|
+
def _lost_commit_warning_text(self) -> str:
|
|
291
|
+
"""Return the orange text while the reset choice is checked: the cause of a failed count, or a known non-zero count; empty otherwise."""
|
|
292
|
+
if not self.resets_local_branch or not self.same_radio.isChecked():
|
|
293
|
+
return ""
|
|
294
|
+
if self.lost_commit_cause:
|
|
295
|
+
return LOST_COMMIT_FAILURE_TEMPLATE.format(cause=self.lost_commit_cause)
|
|
296
|
+
count = self.lost_commit_count
|
|
297
|
+
if not count:
|
|
298
|
+
return ""
|
|
299
|
+
full_ref = self.branch_selector.currentData() or ""
|
|
300
|
+
remote = full_ref.removeprefix(REMOTE_PREFIX)
|
|
301
|
+
template = LOST_COMMIT_WARNING_TEMPLATE if count == 1 else LOST_COMMITS_WARNING_TEMPLATE
|
|
302
|
+
return template.format(branch=self.same_name_label.text(), count=count, remote=remote)
|
|
303
|
+
|
|
304
|
+
def _set_difference_tooltip(self, text: str) -> None:
|
|
305
|
+
"""Set the comparison tooltip, followed by why the lost commit count is unknown when it failed."""
|
|
306
|
+
self.difference_tooltip = text
|
|
307
|
+
lines = [text] if text else []
|
|
308
|
+
if self.lost_commit_cause:
|
|
309
|
+
lines.append(LOST_COMMIT_FAILURE_TEMPLATE.format(cause=self.lost_commit_cause))
|
|
310
|
+
self.difference_label.setToolTip("\n".join(lines))
|
|
311
|
+
|
|
312
|
+
def _show_local_branch_choice(self, full_ref: str) -> None:
|
|
313
|
+
"""Word the first remote choice after the local branch of the same name, and choose the reset when that branch exists.
|
|
314
|
+
|
|
315
|
+
Git Extensions does the same: checking out ``origin/X`` while ``X`` exists moves ``X`` onto it rather than
|
|
316
|
+
failing on a name already taken. A branch without a local one keeps whatever choice the user made.
|
|
317
|
+
"""
|
|
318
|
+
self.same_radio.setText(CREATE_SAME_NAME_TEXT)
|
|
319
|
+
try:
|
|
320
|
+
name = remote_branch_name(self.snapshot, full_ref)
|
|
321
|
+
except ValueError as error:
|
|
322
|
+
self.same_name_label.setText(str(error))
|
|
323
|
+
return
|
|
324
|
+
self.same_name_label.setText(name)
|
|
325
|
+
if LOCAL_PREFIX + name not in self.snapshot.object_oid_by_name:
|
|
326
|
+
return
|
|
327
|
+
self.resets_local_branch = True
|
|
328
|
+
self.same_radio.setText(RESET_LOCAL_BRANCH_TEXT)
|
|
329
|
+
self.same_radio.setChecked(True)
|
|
330
|
+
self.custom_edit.setEnabled(False)
|
|
331
|
+
|
|
332
|
+
def show_difference(self, difference: tuple[int, int] | None, cause: str = "") -> None:
|
|
333
|
+
"""Distinguish an unknown comparison from two real zero counts.
|
|
334
|
+
|
|
335
|
+
The explanation of a first checkout arrives once the window is open and wraps over several lines at a large text
|
|
336
|
+
size; the window grows to show it, like it does for the other content arriving late.
|
|
337
|
+
"""
|
|
338
|
+
if difference is None:
|
|
339
|
+
self.comparison_ready = False
|
|
340
|
+
self.difference_label.setText("Commit difference unavailable")
|
|
341
|
+
self._set_difference_tooltip(cause)
|
|
342
|
+
self.checkout_button.setEnabled(False)
|
|
343
|
+
return
|
|
344
|
+
left, right = difference
|
|
345
|
+
self.comparison_ready = True
|
|
346
|
+
self.difference_label.setText(f"−{left} / +{right}")
|
|
347
|
+
direction = "Commits only in current HEAD / commits only in selected target"
|
|
348
|
+
if self.snapshot is not None and self.snapshot.head_oid is None:
|
|
349
|
+
self.difference_label.setText(f"Initial checkout: +{right} commits (HEAD has no commit)")
|
|
350
|
+
direction = "Commits in the selected target, starting from empty history"
|
|
351
|
+
self._set_difference_tooltip(direction)
|
|
352
|
+
self.difference_label.setAccessibleName(direction)
|
|
353
|
+
self._update_checkout_button()
|
|
354
|
+
|
|
355
|
+
def show_error(self, cause: str) -> None:
|
|
356
|
+
"""Expose the real error; the window grows to show it, up to its share of the screen, so ordinary refusals need no scrolling."""
|
|
357
|
+
self.status_label.setText(cause)
|
|
358
|
+
self.checkout_button.setEnabled(False)
|
|
359
|
+
|
|
360
|
+
def show_outcome(self, outcome: OperationOutcome) -> None:
|
|
361
|
+
"""Close after a successful checkout, give the form back after a refusal it can retry, freeze it after any other result.
|
|
362
|
+
|
|
363
|
+
A refused checkout stopped before Git ran, so nothing was written and the form becomes usable again, like
|
|
364
|
+
Checkout revision, unless its capture no longer matches the repository: a second click would only be refused
|
|
365
|
+
again, and the console says to reopen the form or refresh. A failed, partial or unknown checkout may have
|
|
366
|
+
changed files: the request stays visible, greyed, while the console explains what happened.
|
|
367
|
+
"""
|
|
368
|
+
self.busy = False
|
|
369
|
+
self.cancel_button.setEnabled(True)
|
|
370
|
+
self.status_label.clear()
|
|
371
|
+
if outcome.status == OperationStatus.SUCCESS:
|
|
372
|
+
self.accept()
|
|
373
|
+
return
|
|
374
|
+
if outcome.status == OperationStatus.REFUSED and not outcome.stale_capture:
|
|
375
|
+
self._set_choices_enabled(True)
|
|
376
|
+
self._mode_changed()
|
|
377
|
+
else:
|
|
378
|
+
self._set_choices_enabled(False)
|
|
379
|
+
self.checkout_button.setEnabled(False)
|
|
380
|
+
|
|
381
|
+
def _set_choices_enabled(self, enabled: bool) -> None:
|
|
382
|
+
"""Enable or disable every choice of the form together; Checkout and Cancel are handled apart."""
|
|
383
|
+
for widget in (self.branch_selector, self.remote_options, self.local_radio, self.remote_radio):
|
|
384
|
+
widget.setEnabled(enabled)
|
|
385
|
+
|
|
386
|
+
def _submit(self) -> None:
|
|
387
|
+
"""Emit a valid explicit request once without performing Git work in the widget."""
|
|
388
|
+
if self.closed or self.busy or self.intent is None or not self.checkout_button.isEnabled():
|
|
389
|
+
return
|
|
390
|
+
self.busy = True
|
|
391
|
+
self._set_choices_enabled(False)
|
|
392
|
+
self.checkout_button.setEnabled(False)
|
|
393
|
+
self.cancel_button.setEnabled(False)
|
|
394
|
+
self.status_label.clear()
|
|
395
|
+
self.checkout_requested.emit(self.intent)
|
|
396
|
+
|
|
397
|
+
def reject(self) -> None:
|
|
398
|
+
"""Keep the captured form visible until its native operation finishes."""
|
|
399
|
+
if not self.busy:
|
|
400
|
+
super().reject()
|
|
401
|
+
|
|
402
|
+
def accept(self) -> None:
|
|
403
|
+
"""Prevent direct acceptance while the native operation still owns the form."""
|
|
404
|
+
if not self.busy:
|
|
405
|
+
super().accept()
|
|
406
|
+
|
|
407
|
+
def closeEvent(self, event: QCloseEvent) -> None:
|
|
408
|
+
"""Ignore window closure while Git is still using the captured choices."""
|
|
409
|
+
if self.busy:
|
|
410
|
+
event.ignore()
|
|
411
|
+
return
|
|
412
|
+
super().closeEvent(event)
|
|
413
|
+
|
|
414
|
+
def _closed(self) -> None:
|
|
415
|
+
"""Invalidate pending callbacks and return focus to the still-live launching control."""
|
|
416
|
+
self.closed = True
|
|
417
|
+
self.read_generation += 1
|
|
418
|
+
if self.return_focus is not None and isValid(self.return_focus):
|
|
419
|
+
self.return_focus.setFocus()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Reproduce the Git Extensions "Checkout revision" window: one commit, an optional force, one Checkout button."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QMargins, QSize, Qt, Signal
|
|
8
|
+
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
|
9
|
+
|
|
10
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
11
|
+
from gitpane.reference.reference_edit import CheckoutIntent, CheckoutMode
|
|
12
|
+
from gitpane.ui.look.action_icon import action_icon
|
|
13
|
+
from gitpane.ui.look.theme_look import follow_theme
|
|
14
|
+
from gitpane.ui.reference.reference_form import ReferenceFormDialog
|
|
15
|
+
from gitpane.ui.reference.revision_picker import RevisionPicker
|
|
16
|
+
from gitpane.ui.widget.dialog_layout import BandButtonSizing, fit_caption_column, fit_capture_control, install_banded_layout
|
|
17
|
+
from gitpane.ui.widget.refresh_advice import refresh_advice_text
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from gitpane.operation.repository_operation import OperationOutcome
|
|
21
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
22
|
+
|
|
23
|
+
CHECKOUT_REVISION_TITLE = "Checkout revision"
|
|
24
|
+
CHECKOUT_REVISION_CAPTION = "Checkout this revision"
|
|
25
|
+
FORCE_CHECKOUT_LABEL = "Force (reset local changes)"
|
|
26
|
+
CHECKOUT_BUTTON_TEXT = "Checkout"
|
|
27
|
+
# Client area of the Git Extensions capture (481 x 131 inside the window borders and title bar).
|
|
28
|
+
CHECKOUT_REVISION_CLIENT_SIZE = QSize(481, 131)
|
|
29
|
+
# Measured on the capture in client pixels at 100 %: white body y 0-89, band line y 90, band y 91-130; caption ink
|
|
30
|
+
# x 18-134 and y 26-34; revision field (145, 17, 260, 23) with its choose button (409, 16, 23, 22) and the "-13" ink
|
|
31
|
+
# from x 442; Force square (145, 49, 13, 13); Checkout button (385, 99, 82, 23) in the band.
|
|
32
|
+
# The body margins put the caption label at x 17 (a label writes its text about 1 px after its edge) and the top of
|
|
33
|
+
# the revision row, whose choose button is its highest control, at y 16. The band sits at the bottom through a final
|
|
34
|
+
# stretch, so enlarging the resizable window only grows the empty body.
|
|
35
|
+
CHECKOUT_REVISION_BODY_MARGINS = QMargins(17, 16, 14, 0)
|
|
36
|
+
# Caption width from its left edge to the revision field (145 - 17); a wider caption text widens it.
|
|
37
|
+
REVISION_CAPTION_WIDTH = 128
|
|
38
|
+
REVISION_FIELD_WIDTH = 260
|
|
39
|
+
# The caption's text sits 2 px below the middle of the revision row in the capture; a top margin twice as high
|
|
40
|
+
# lowers the vertically centered label by that much.
|
|
41
|
+
CAPTION_TEXT_DROP = 2
|
|
42
|
+
# Space between the revision row (y 16-39) and the Force check box, whose square then lands on y 49.
|
|
43
|
+
REVISION_TO_FORCE_SPACE = 6
|
|
44
|
+
CHECKOUT_BUTTON_SIZE = QSize(82, 23)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CheckoutRevisionDialog(ReferenceFormDialog):
|
|
48
|
+
"""Check out one commit in detached HEAD, optionally discarding local changes, like Git Extensions.
|
|
49
|
+
|
|
50
|
+
The window only freezes a checkout request; the reference form controller reads the references, counts the commit's position
|
|
51
|
+
from HEAD and runs Git.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
checkout_requested = Signal(object)
|
|
55
|
+
|
|
56
|
+
def __init__(self, session_id: str, parent: QWidget | None = None) -> None:
|
|
57
|
+
"""Build the caption, the revision field, the Force check box and the button band of the capture."""
|
|
58
|
+
super().__init__(session_id, parent)
|
|
59
|
+
self.intent: CheckoutIntent | None = None
|
|
60
|
+
self.setWindowTitle(CHECKOUT_REVISION_TITLE)
|
|
61
|
+
layout = QVBoxLayout()
|
|
62
|
+
layout.setSpacing(0)
|
|
63
|
+
revision_row = QHBoxLayout()
|
|
64
|
+
revision_row.setContentsMargins(0, 0, 0, 0)
|
|
65
|
+
revision_row.setSpacing(0)
|
|
66
|
+
self.caption_label = QLabel(CHECKOUT_REVISION_CAPTION)
|
|
67
|
+
self.caption_label.setContentsMargins(0, CAPTION_TEXT_DROP * 2, 0, 0)
|
|
68
|
+
self.picker = RevisionPicker(REVISION_FIELD_WIDTH)
|
|
69
|
+
self.caption_label.setBuddy(self.picker.revision_edit)
|
|
70
|
+
revision_row.addWidget(self.caption_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
71
|
+
revision_row.addWidget(self.picker, 1)
|
|
72
|
+
layout.addLayout(revision_row)
|
|
73
|
+
layout.addSpacing(REVISION_TO_FORCE_SPACE)
|
|
74
|
+
self.force_checkbox = QCheckBox(FORCE_CHECKOUT_LABEL)
|
|
75
|
+
# Indented by the caption's width, which follows the caption's text.
|
|
76
|
+
self.force_row = QHBoxLayout()
|
|
77
|
+
self.force_row.setSpacing(0)
|
|
78
|
+
self.force_row.addWidget(self.force_checkbox)
|
|
79
|
+
self.force_row.addStretch(1)
|
|
80
|
+
layout.addLayout(self.force_row)
|
|
81
|
+
layout.addStretch(1)
|
|
82
|
+
self.submit_button.setText(CHECKOUT_BUTTON_TEXT)
|
|
83
|
+
self.submit_button.setIcon(action_icon("checkout_commit", self.devicePixelRatioF()))
|
|
84
|
+
self.submit_button.setEnabled(False)
|
|
85
|
+
# Git Extensions shows this form as a white body over a grey band holding its single button; there is no
|
|
86
|
+
# Cancel button, Escape and the title bar close it.
|
|
87
|
+
self.button_band = install_banded_layout(self, layout, [self.submit_button], body_margins=CHECKOUT_REVISION_BODY_MARGINS, button_sizing=BandButtonSizing.WINDOW)
|
|
88
|
+
# The revision picker laid its own look when it was built; the form only has it laid again at each switch.
|
|
89
|
+
follow_theme(self, self.apply_theme_look)
|
|
90
|
+
# Enter runs Checkout from anywhere in the form. The role is set only now, once the button belongs to the
|
|
91
|
+
# dialog: Qt then records it as the dialog's main default and gives it back whenever the focus moves, instead
|
|
92
|
+
# of dropping it the first time the button loses the focus.
|
|
93
|
+
self.submit_button.setDefault(True)
|
|
94
|
+
self.submit_button.clicked.connect(self._submit)
|
|
95
|
+
self.force_checkbox.toggled.connect(self._update_intent)
|
|
96
|
+
# Connected before the reference form controller binds the field, so a new target's position is invalidated before it is
|
|
97
|
+
# counted again.
|
|
98
|
+
self.picker.target_changed.connect(self._target_changed)
|
|
99
|
+
self.resize(CHECKOUT_REVISION_CLIENT_SIZE)
|
|
100
|
+
self.refresh_metrics()
|
|
101
|
+
self.picker.revision_edit.setFocus()
|
|
102
|
+
|
|
103
|
+
def apply_theme_look(self) -> None:
|
|
104
|
+
"""Lay the light or dark look of the form's own controls, decided on the application palette; called again at each switch.
|
|
105
|
+
|
|
106
|
+
The body and the band follow the switch on their own; the revision field, its choose button and its counter are
|
|
107
|
+
the revision picker's. Only style sheets and palettes change: the revision shown and Force stay as they are.
|
|
108
|
+
"""
|
|
109
|
+
self.picker.apply_theme_look()
|
|
110
|
+
|
|
111
|
+
def fit_capture_controls(self) -> None:
|
|
112
|
+
"""Give the caption and the Checkout button the capture's sizes, or more when their text needs it.
|
|
113
|
+
|
|
114
|
+
A wider caption moves the revision field and the Force check box right by the same amount, so they stay lined up.
|
|
115
|
+
"""
|
|
116
|
+
caption_width = fit_caption_column((self.caption_label,), REVISION_CAPTION_WIDTH)
|
|
117
|
+
self.force_row.setContentsMargins(caption_width, 0, 0, 0)
|
|
118
|
+
fit_capture_control(self.submit_button, CHECKOUT_BUTTON_SIZE)
|
|
119
|
+
|
|
120
|
+
def populate(self, snapshot: ReferenceSnapshot, target_oid: str) -> None:
|
|
121
|
+
"""Show the commit to check out against the references the window was opened on."""
|
|
122
|
+
if self.closed or self.busy or self.completed:
|
|
123
|
+
return
|
|
124
|
+
self.snapshot = snapshot
|
|
125
|
+
self.set_target(target_oid)
|
|
126
|
+
self.picker.revision_edit.setFocus()
|
|
127
|
+
|
|
128
|
+
def set_target(self, oid: str, *, rewrite_text: bool = True) -> None:
|
|
129
|
+
"""Target another commit; its short hash replaces the field unless the user's typed text is kept."""
|
|
130
|
+
if self.closed or self.busy or self.completed:
|
|
131
|
+
return
|
|
132
|
+
self.picker.show_revision(oid, rewrite_text=rewrite_text)
|
|
133
|
+
|
|
134
|
+
def _target_changed(self, _oid: str | None) -> None:
|
|
135
|
+
"""Drop any position still being counted for the previous target and freeze the new request, if any."""
|
|
136
|
+
self.position_generation += 1
|
|
137
|
+
self._update_intent()
|
|
138
|
+
|
|
139
|
+
def _update_intent(self) -> None:
|
|
140
|
+
"""Freeze the current commit and Force choice into the request Checkout would send."""
|
|
141
|
+
self.intent = None
|
|
142
|
+
target_oid = self.picker.target_oid
|
|
143
|
+
if self.snapshot is not None and target_oid is not None:
|
|
144
|
+
try:
|
|
145
|
+
self.intent = CheckoutIntent.capture(self.snapshot, target_oid, refresh_advice=refresh_advice_text, session_id=self.session_id, mode=CheckoutMode.DETACHED,
|
|
146
|
+
force_checkout=self.force_checkbox.isChecked())
|
|
147
|
+
except ValueError as error:
|
|
148
|
+
self.show_error(str(error))
|
|
149
|
+
return
|
|
150
|
+
self.submit_button.setEnabled(self.intent is not None and not self.busy and not self.completed and not self.closed)
|
|
151
|
+
|
|
152
|
+
def _submit(self) -> None:
|
|
153
|
+
"""Freeze the form and hand its request to the reference form controller once."""
|
|
154
|
+
if self.closed or self.busy or self.completed or self.intent is None or not self.submit_button.isEnabled():
|
|
155
|
+
return
|
|
156
|
+
self.busy = True
|
|
157
|
+
self._set_controls_enabled(False)
|
|
158
|
+
self.checkout_requested.emit(self.intent)
|
|
159
|
+
|
|
160
|
+
def show_outcome(self, outcome: OperationOutcome) -> None:
|
|
161
|
+
"""Close after a successful checkout, give the form back after a refusal it can retry, freeze it after any other result.
|
|
162
|
+
|
|
163
|
+
A refused checkout stopped before Git ran, so nothing was written and the form becomes usable again, like the
|
|
164
|
+
other reference forms, unless its capture no longer matches the repository: a second click would only be
|
|
165
|
+
refused again, and the console says to reopen the form or refresh. A failed, partial or unknown checkout may
|
|
166
|
+
have changed files: the request stays visible, greyed, while the console explains what happened.
|
|
167
|
+
"""
|
|
168
|
+
self.busy = False
|
|
169
|
+
if outcome.status == OperationStatus.SUCCESS:
|
|
170
|
+
self.accept()
|
|
171
|
+
return
|
|
172
|
+
if outcome.status == OperationStatus.REFUSED and not outcome.stale_capture:
|
|
173
|
+
self._set_controls_enabled(True)
|
|
174
|
+
self._update_intent()
|
|
175
|
+
return
|
|
176
|
+
self.completed = True
|
|
177
|
+
self._set_controls_enabled(False)
|
|
178
|
+
|
|
179
|
+
def _set_controls_enabled(self, enabled: bool) -> None:
|
|
180
|
+
"""Enable or disable every choice of the form together."""
|
|
181
|
+
for widget in (self.picker, self.force_checkbox, self.submit_button):
|
|
182
|
+
widget.setEnabled(enabled)
|