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,263 @@
|
|
|
1
|
+
"""Present the reset choices of the current branch for one captured commit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QRect, Qt, Signal
|
|
9
|
+
from PySide6.QtWidgets import QButtonGroup, QDialog, QGroupBox, QHBoxLayout, QLabel, QLayout, QMessageBox, QPushButton, QRadioButton, QScrollArea, QVBoxLayout, QWidget
|
|
10
|
+
from shiboken6 import isValid
|
|
11
|
+
|
|
12
|
+
from gitpane.config import LOCAL_PREFIX, SPACING_SM
|
|
13
|
+
from gitpane.history.repository_value import ReferenceKind
|
|
14
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
15
|
+
from gitpane.reference.reference_edit import ResetIntent, ResetMode
|
|
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 WrappingLabel, WrappingRadioButton
|
|
20
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from gitpane.operation.repository_operation import OperationOutcome
|
|
24
|
+
from gitpane.reference.reference_snapshot import ReferenceSnapshot
|
|
25
|
+
from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
|
|
26
|
+
|
|
27
|
+
RESET_DIALOG_WIDTH = 640
|
|
28
|
+
RESET_DIALOG_HEIGHT = 640
|
|
29
|
+
RESET_TITLE = "Reset current branch"
|
|
30
|
+
# The band colour of each reset choice: green for the mode that touches no file, red for the one that discards local changes.
|
|
31
|
+
RESET_MODE_COLOR_BY_MODE: dict[ResetMode, str] = {
|
|
32
|
+
ResetMode.SOFT: "#80ff80",
|
|
33
|
+
ResetMode.MIXED: "#ffff80",
|
|
34
|
+
ResetMode.KEEP: "#ffff80",
|
|
35
|
+
ResetMode.MERGE: "#ffff80",
|
|
36
|
+
ResetMode.HARD: "#ff8080",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ResetDialog(ContentGrowthMixin, QDialog):
|
|
41
|
+
"""Keep reset choices bound to one branch, commit and local-state capture."""
|
|
42
|
+
|
|
43
|
+
reset_requested = Signal(object)
|
|
44
|
+
preparation_requested = Signal()
|
|
45
|
+
|
|
46
|
+
def __init__(self, session_id: str, target_oid: str, parent: QWidget | None = None) -> None:
|
|
47
|
+
"""Build scrolling metadata and five fixed-color choices above permanent actions."""
|
|
48
|
+
super().__init__(parent)
|
|
49
|
+
self.session_id = session_id
|
|
50
|
+
self.target_oid = target_oid
|
|
51
|
+
self.snapshot: ReferenceSnapshot | None = None
|
|
52
|
+
self.worktree_snapshot: WorktreeSnapshot | None = None
|
|
53
|
+
self.working_signature: tuple = ()
|
|
54
|
+
self.working_paths: tuple[bytes, ...] | None = None
|
|
55
|
+
self.reset_target_paths: tuple[bytes, ...] = ()
|
|
56
|
+
self.intent: ResetIntent | None = None
|
|
57
|
+
self.mode = ResetMode.SOFT
|
|
58
|
+
self.closed = False
|
|
59
|
+
self.busy = False
|
|
60
|
+
self.completed = False
|
|
61
|
+
self.metadata_ready = False
|
|
62
|
+
self.confirmation: QMessageBox | None = None
|
|
63
|
+
self.confirmation_intent: ResetIntent | None = None
|
|
64
|
+
self.return_focus = parent.focusWidget() if parent is not None else None
|
|
65
|
+
self.setWindowTitle(RESET_TITLE)
|
|
66
|
+
self.resize(RESET_DIALOG_WIDTH, RESET_DIALOG_HEIGHT)
|
|
67
|
+
layout = QVBoxLayout(self)
|
|
68
|
+
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
69
|
+
self.scroll_area = QScrollArea()
|
|
70
|
+
self.scroll_area.setWidgetResizable(True)
|
|
71
|
+
content = QWidget()
|
|
72
|
+
form = QVBoxLayout(content)
|
|
73
|
+
form.setSpacing(SPACING_SM)
|
|
74
|
+
self.branch_label = self._label("Loading current branch…")
|
|
75
|
+
self.branch_label.setAccessibleName("Current branch")
|
|
76
|
+
form.addWidget(self.branch_label)
|
|
77
|
+
metadata_box = QGroupBox("Target revision")
|
|
78
|
+
metadata_layout = QVBoxLayout(metadata_box)
|
|
79
|
+
self.target_label = self._label(target_oid)
|
|
80
|
+
self.subject_label = self._label("Loading commit metadata…")
|
|
81
|
+
font = self.subject_label.font()
|
|
82
|
+
font.setBold(True)
|
|
83
|
+
self.subject_label.setFont(font)
|
|
84
|
+
self.metadata_label = self._label("Loading…")
|
|
85
|
+
for label in (self.target_label, self.subject_label, self.metadata_label):
|
|
86
|
+
metadata_layout.addWidget(label)
|
|
87
|
+
form.addWidget(metadata_box)
|
|
88
|
+
self.mode_box = QGroupBox("Reset type")
|
|
89
|
+
mode_layout = QVBoxLayout(self.mode_box)
|
|
90
|
+
self.mode_group = QButtonGroup(self)
|
|
91
|
+
self.button_by_mode: dict[ResetMode, QRadioButton] = {}
|
|
92
|
+
for mode in ResetMode:
|
|
93
|
+
button = WrappingRadioButton(mode.label)
|
|
94
|
+
button.setAccessibleName(mode.label)
|
|
95
|
+
button.setStyleSheet("QRadioButton { background-color: " + RESET_MODE_COLOR_BY_MODE[mode] + "; color: black; padding: " + str(SPACING_SM) + "px; }")
|
|
96
|
+
button.description_label.setStyleSheet("color: black; background: transparent;")
|
|
97
|
+
self.mode_group.addButton(button)
|
|
98
|
+
self.button_by_mode[mode] = button
|
|
99
|
+
button.setChecked(mode == ResetMode.SOFT)
|
|
100
|
+
button.toggled.connect(lambda checked, mode=mode: self._select_mode(mode) if checked else None)
|
|
101
|
+
mode_layout.addWidget(button)
|
|
102
|
+
form.addWidget(self.mode_box)
|
|
103
|
+
self.status_label = self._label("")
|
|
104
|
+
form.addWidget(self.status_label)
|
|
105
|
+
form.addStretch()
|
|
106
|
+
self.scroll_area.setWidget(content)
|
|
107
|
+
# The commit details read by Git, a refusal with its diagnostic and "Running Git…" arrive in the form after the
|
|
108
|
+
# window opened, and the window follows them both ways by the rule it shares with the other forms, never below
|
|
109
|
+
# the height it opened at or the one the user set. The starting height was chosen for Windows' smaller system
|
|
110
|
+
# text: a larger font grows the window once its first show is laid out, the scroll area staying the last resort
|
|
111
|
+
# for a screen too small for the form, with OK and Cancel always in view under it.
|
|
112
|
+
self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.GROW_ONLY, RESET_DIALOG_HEIGHT)
|
|
113
|
+
layout.addWidget(self.scroll_area, 1)
|
|
114
|
+
actions = QHBoxLayout()
|
|
115
|
+
actions.addStretch()
|
|
116
|
+
self.submit_button = QPushButton("OK")
|
|
117
|
+
self.submit_button.setAutoDefault(False)
|
|
118
|
+
self.submit_button.setEnabled(False)
|
|
119
|
+
self.cancel_button = QPushButton("Cancel")
|
|
120
|
+
actions.addWidget(self.submit_button)
|
|
121
|
+
actions.addWidget(self.cancel_button)
|
|
122
|
+
layout.addLayout(actions)
|
|
123
|
+
self.submit_button.clicked.connect(self._submit)
|
|
124
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
125
|
+
self.finished.connect(self._closed)
|
|
126
|
+
self.refresh_metrics()
|
|
127
|
+
|
|
128
|
+
def _label(self, text: str) -> QLabel:
|
|
129
|
+
"""Keep repository text literal and available in full at narrow widths."""
|
|
130
|
+
label = WrappingLabel(text)
|
|
131
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
132
|
+
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
133
|
+
label.setWordWrap(True)
|
|
134
|
+
return label
|
|
135
|
+
|
|
136
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
137
|
+
"""Fit the form to the screen while keeping its actions reachable."""
|
|
138
|
+
fit_form(self, available)
|
|
139
|
+
|
|
140
|
+
def populate(self, snapshot: ReferenceSnapshot, local: WorktreeSnapshot | None = None, signature: tuple = (), paths: tuple[bytes, ...] = (), target_paths: tuple[bytes, ...] = ()) -> None:
|
|
141
|
+
"""Publish the exact branch and local capture without changing the selected mode."""
|
|
142
|
+
self.snapshot = snapshot
|
|
143
|
+
self.worktree_snapshot = local
|
|
144
|
+
self.working_signature = signature
|
|
145
|
+
self.working_paths = paths
|
|
146
|
+
self.reset_target_paths = target_paths
|
|
147
|
+
branch_name = (snapshot.head_ref or "").removeprefix(LOCAL_PREFIX)
|
|
148
|
+
self.branch_label.setText("Reset branch '" + branch_name + "' to revision:")
|
|
149
|
+
self._update_intent()
|
|
150
|
+
|
|
151
|
+
def show_metadata(self, fields: tuple[str, ...] | None, cause: str = "") -> None:
|
|
152
|
+
"""Distinguish missing metadata from a failed read of the captured commit."""
|
|
153
|
+
if fields is None:
|
|
154
|
+
self.metadata_ready = False
|
|
155
|
+
self.metadata_label.setText("Commit metadata unavailable")
|
|
156
|
+
self.subject_label.setText("Commit metadata unavailable")
|
|
157
|
+
self.show_error(cause)
|
|
158
|
+
return
|
|
159
|
+
oid, author, _author_date, _committer, commit_date, message = fields
|
|
160
|
+
if oid != self.target_oid or self.snapshot is None:
|
|
161
|
+
return
|
|
162
|
+
subject, _, _body = message.partition("\n")
|
|
163
|
+
self.subject_label.setText(subject or "n/a")
|
|
164
|
+
branches: list[str] = []
|
|
165
|
+
tags: list[str] = []
|
|
166
|
+
for reference in sorted(self.snapshot.references, key=lambda reference: reference.full_name):
|
|
167
|
+
if reference.target_oid != oid:
|
|
168
|
+
continue
|
|
169
|
+
if reference.kind == ReferenceKind.TAGS:
|
|
170
|
+
tags.append(reference.full_name.removeprefix(ReferenceKind.TAGS.prefix))
|
|
171
|
+
else:
|
|
172
|
+
branches.append(reference.full_name.removeprefix(LOCAL_PREFIX))
|
|
173
|
+
self.metadata_label.setText("Author: " + (author or "n/a") + "\nCommit date: " + (commit_date or "n/a")
|
|
174
|
+
+ "\nBranch(es): " + (", ".join(branches) or "n/a") + "\nTag(s): " + (", ".join(tags) or "n/a"))
|
|
175
|
+
self.metadata_ready = True
|
|
176
|
+
self._update_intent()
|
|
177
|
+
|
|
178
|
+
def _select_mode(self, mode: ResetMode) -> None:
|
|
179
|
+
"""Prepare file-changing modes on demand while Soft needs only reference authority."""
|
|
180
|
+
self.mode = mode
|
|
181
|
+
self._update_intent()
|
|
182
|
+
if mode != ResetMode.SOFT and self.worktree_snapshot is None:
|
|
183
|
+
self.preparation_requested.emit()
|
|
184
|
+
|
|
185
|
+
def _update_intent(self) -> None:
|
|
186
|
+
"""Enable submission only after complete authority and metadata reads."""
|
|
187
|
+
self.intent = None
|
|
188
|
+
if self.snapshot is not None and (self.mode == ResetMode.SOFT or self.worktree_snapshot is not None) and not self.busy and not self.completed:
|
|
189
|
+
try:
|
|
190
|
+
local = None if self.mode == ResetMode.SOFT else self.worktree_snapshot
|
|
191
|
+
self.intent = ResetIntent.capture(self.snapshot, self.target_oid, local, self.working_signature, session_id=self.session_id, mode=self.mode)
|
|
192
|
+
self.intent = replace(self.intent, working_paths=self.working_paths, reset_target_paths=self.reset_target_paths)
|
|
193
|
+
except ValueError as error:
|
|
194
|
+
self.status_label.setText(str(error))
|
|
195
|
+
self.submit_button.setEnabled(self.intent is not None and self.metadata_ready)
|
|
196
|
+
|
|
197
|
+
def _submit(self) -> None:
|
|
198
|
+
"""Emit the displayed reset once; the operation owner retains its native lifetime."""
|
|
199
|
+
if self.intent is None or self.busy or not self.metadata_ready or self.confirmation is not None:
|
|
200
|
+
return
|
|
201
|
+
if self.mode == ResetMode.HARD:
|
|
202
|
+
question = QMessageBox(QMessageBox.Icon.Warning, RESET_TITLE, "You are about to discard ALL local changes, are you sure?",
|
|
203
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self)
|
|
204
|
+
prepare_message_box(question, RESET_TITLE)
|
|
205
|
+
question.setTextFormat(Qt.TextFormat.PlainText)
|
|
206
|
+
question.setDefaultButton(QMessageBox.StandardButton.No)
|
|
207
|
+
question.setEscapeButton(QMessageBox.StandardButton.No)
|
|
208
|
+
self.confirmation = question
|
|
209
|
+
self.confirmation_intent = self.intent
|
|
210
|
+
question.finished.connect(self._answer_hard)
|
|
211
|
+
open_window_modal(question)
|
|
212
|
+
return
|
|
213
|
+
self._launch()
|
|
214
|
+
|
|
215
|
+
def _answer_hard(self, result: int) -> None:
|
|
216
|
+
"""Only explicit Yes authorizes the captured Hard intent, without rereading over it."""
|
|
217
|
+
question = self.confirmation
|
|
218
|
+
captured = self.confirmation_intent
|
|
219
|
+
self.confirmation = None
|
|
220
|
+
self.confirmation_intent = None
|
|
221
|
+
if question is not None:
|
|
222
|
+
question.deleteLater()
|
|
223
|
+
if self.closed or result != QMessageBox.StandardButton.Yes or captured is None or self.intent != captured:
|
|
224
|
+
return
|
|
225
|
+
self.intent = replace(captured, hard_confirmed=True)
|
|
226
|
+
self._launch()
|
|
227
|
+
|
|
228
|
+
def _launch(self) -> None:
|
|
229
|
+
"""Freeze controls after any required confirmation and submit to the common owner."""
|
|
230
|
+
self.busy = True
|
|
231
|
+
self.submit_button.setEnabled(False)
|
|
232
|
+
self.mode_box.setEnabled(False)
|
|
233
|
+
self.status_label.setText("Running Git…")
|
|
234
|
+
self.reset_requested.emit(self.intent)
|
|
235
|
+
|
|
236
|
+
def show_error(self, cause: str) -> None:
|
|
237
|
+
"""Keep a read error visible without turning it into absent metadata."""
|
|
238
|
+
self.status_label.setText(cause)
|
|
239
|
+
self.submit_button.setEnabled(False)
|
|
240
|
+
|
|
241
|
+
def show_outcome(self, outcome: OperationOutcome) -> None:
|
|
242
|
+
"""Retain native refusal details and the chosen mode without destructive fallback."""
|
|
243
|
+
self.busy = False
|
|
244
|
+
self.completed = outcome.status in (OperationStatus.SUCCESS, OperationStatus.PARTIAL, OperationStatus.UNKNOWN)
|
|
245
|
+
lines: list[str] = []
|
|
246
|
+
for step in outcome.steps:
|
|
247
|
+
lines.append(step.operation + ": " + step.status.value + " — " + step.effect)
|
|
248
|
+
if outcome.diagnostic is not None:
|
|
249
|
+
lines.append(outcome.diagnostic.detail)
|
|
250
|
+
self.status_label.setText("\n".join(lines))
|
|
251
|
+
self.mode_box.setEnabled(not self.completed)
|
|
252
|
+
self.cancel_button.setText("Close")
|
|
253
|
+
self._update_intent()
|
|
254
|
+
if outcome.status == OperationStatus.SUCCESS:
|
|
255
|
+
self.accept()
|
|
256
|
+
|
|
257
|
+
def _closed(self) -> None:
|
|
258
|
+
"""Invalidate late reads and return keyboard focus to the launching window."""
|
|
259
|
+
self.closed = True
|
|
260
|
+
if self.confirmation is not None:
|
|
261
|
+
self.confirmation.reject()
|
|
262
|
+
if self.return_focus is not None and isValid(self.return_focus):
|
|
263
|
+
self.return_focus.setFocus()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Reproduce the Git Extensions "Choose another revision" window, which Cherry-pick shares with the reference forms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent, QObject, Qt, Signal
|
|
8
|
+
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget
|
|
9
|
+
|
|
10
|
+
from gitpane.config import REPLAY_DIALOG_HEIGHT, REPLAY_DIALOG_WIDTH
|
|
11
|
+
from gitpane.text.typed_text import typed_value
|
|
12
|
+
from gitpane.ui.widget.dialog_layout import fit_form
|
|
13
|
+
from gitpane.ui.widget.long_text import WrappingLabel
|
|
14
|
+
from gitpane.ui.window_control.item_activation import activate_current_row
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gitpane.history.repository_value import Commit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RevisionChoiceDialog(QDialog):
|
|
21
|
+
"""Search the commits already loaded, or take any typed revision, to choose another commit for a form.
|
|
22
|
+
|
|
23
|
+
The window never starts Git: a click in the list writes the commit's full ID, and Apply hands the text to its
|
|
24
|
+
owner, which resolves it and answers with ``accept`` or ``show_error``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
revision_requested = Signal(str)
|
|
28
|
+
|
|
29
|
+
def __init__(self, commits: list[Commit], parent: QWidget) -> None:
|
|
30
|
+
"""Keep revision validation asynchronous and cancellation independent of its result."""
|
|
31
|
+
super().__init__(parent)
|
|
32
|
+
self.setWindowTitle("Choose another revision")
|
|
33
|
+
self.setModal(True)
|
|
34
|
+
self.resize(REPLAY_DIALOG_WIDTH, REPLAY_DIALOG_HEIGHT)
|
|
35
|
+
self.generation = 0
|
|
36
|
+
self.closed = False
|
|
37
|
+
self.commits = tuple(commits)
|
|
38
|
+
layout = QVBoxLayout(self)
|
|
39
|
+
label = QLabel("Revision or commit hash:")
|
|
40
|
+
self.revision_edit = QLineEdit()
|
|
41
|
+
label.setBuddy(self.revision_edit)
|
|
42
|
+
layout.addWidget(label)
|
|
43
|
+
layout.addWidget(self.revision_edit)
|
|
44
|
+
self.commit_list = QListWidget()
|
|
45
|
+
self.commit_list.setAccessibleName("Available commits")
|
|
46
|
+
layout.addWidget(self.commit_list)
|
|
47
|
+
self.status_label = WrappingLabel("")
|
|
48
|
+
layout.addWidget(self.status_label)
|
|
49
|
+
actions = QHBoxLayout()
|
|
50
|
+
actions.addStretch()
|
|
51
|
+
self.apply_button = QPushButton("Apply")
|
|
52
|
+
self.apply_button.setDefault(True)
|
|
53
|
+
self.apply_button.setEnabled(False)
|
|
54
|
+
self.cancel_button = QPushButton("Cancel")
|
|
55
|
+
actions.addWidget(self.apply_button)
|
|
56
|
+
actions.addWidget(self.cancel_button)
|
|
57
|
+
layout.addLayout(actions)
|
|
58
|
+
self.revision_edit.textChanged.connect(self._revision_changed)
|
|
59
|
+
self.commit_list.itemClicked.connect(self._choose_commit)
|
|
60
|
+
self.commit_list.itemActivated.connect(self._choose_commit)
|
|
61
|
+
self.commit_list.installEventFilter(self)
|
|
62
|
+
self.apply_button.clicked.connect(self._apply)
|
|
63
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
64
|
+
self.finished.connect(self._closed)
|
|
65
|
+
self._revision_changed("")
|
|
66
|
+
fit_form(self)
|
|
67
|
+
self.revision_edit.setFocus()
|
|
68
|
+
|
|
69
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
70
|
+
"""Choose the current commit on Enter in the list, then let the key reach the default Apply, on every system.
|
|
71
|
+
|
|
72
|
+
Qt for macOS does not activate a list row on Enter, so Apply would read the revision the field held before.
|
|
73
|
+
"""
|
|
74
|
+
if watched is self.commit_list and activate_current_row(self.commit_list, event):
|
|
75
|
+
return True
|
|
76
|
+
return super().eventFilter(watched, event)
|
|
77
|
+
|
|
78
|
+
def _revision_changed(self, text: str) -> None:
|
|
79
|
+
"""Invalidate older reads immediately and filter local captions without contacting Git.
|
|
80
|
+
|
|
81
|
+
The field holds a revision, not a free search, so the list is filtered on the revision without the blanks
|
|
82
|
+
around it, the same text Apply asks for.
|
|
83
|
+
"""
|
|
84
|
+
self.generation += 1
|
|
85
|
+
self.status_label.setText("")
|
|
86
|
+
revision = typed_value(text)
|
|
87
|
+
self.apply_button.setEnabled(bool(revision))
|
|
88
|
+
self.commit_list.clear()
|
|
89
|
+
search = revision.casefold()
|
|
90
|
+
for commit in self.commits:
|
|
91
|
+
subject, _separator, _body = commit.message.partition("\n")
|
|
92
|
+
caption = commit.oid + " " + subject
|
|
93
|
+
if search in caption.casefold():
|
|
94
|
+
item = QListWidgetItem(caption)
|
|
95
|
+
item.setData(Qt.ItemDataRole.UserRole, commit.oid)
|
|
96
|
+
item.setToolTip(caption)
|
|
97
|
+
self.commit_list.addItem(item)
|
|
98
|
+
|
|
99
|
+
def _choose_commit(self, item: QListWidgetItem) -> None:
|
|
100
|
+
"""Use the full identity of a selected search result, never its caption."""
|
|
101
|
+
self.revision_edit.setText(item.data(Qt.ItemDataRole.UserRole))
|
|
102
|
+
|
|
103
|
+
def _apply(self) -> None:
|
|
104
|
+
"""Request one read while allowing edits or cancellation to invalidate it."""
|
|
105
|
+
revision = typed_value(self.revision_edit.text())
|
|
106
|
+
if self.closed or not revision or not self.apply_button.isEnabled():
|
|
107
|
+
return
|
|
108
|
+
self.generation += 1
|
|
109
|
+
self.apply_button.setEnabled(False)
|
|
110
|
+
self.status_label.setText("Reading revision…")
|
|
111
|
+
self.revision_requested.emit(revision)
|
|
112
|
+
|
|
113
|
+
def show_error(self, cause: str = "Cannot read this local commit. Check the revision or hash and try again.") -> None:
|
|
114
|
+
"""Keep the previous source and allow correction without exposing raw Git output."""
|
|
115
|
+
self.status_label.setText(cause)
|
|
116
|
+
self.apply_button.setEnabled(bool(typed_value(self.revision_edit.text())))
|
|
117
|
+
|
|
118
|
+
def _closed(self) -> None:
|
|
119
|
+
"""Invalidate outstanding validation when the selector finishes."""
|
|
120
|
+
self.closed = True
|
|
121
|
+
self.generation += 1
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Show a target commit the way the Git Extensions revision field does: [short hash][choose button] -N."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer, Signal
|
|
8
|
+
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
|
9
|
+
|
|
10
|
+
from gitpane.config import ICON_SIDE, REVISION_INPUT_PAUSE_MS
|
|
11
|
+
from gitpane.text.typed_text import typed_value
|
|
12
|
+
from gitpane.ui.look.action_icon import action_icon
|
|
13
|
+
from gitpane.ui.look.windows_control_look import apply_secondary_text_color, apply_windows_button_look, apply_windows_line_edit_look
|
|
14
|
+
from gitpane.ui.reference.commit_summary import GIT_EXTENSIONS_REVISION_PREFIX_LENGTH
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gitpane.ui.reference.revision_dialog import RevisionChoiceDialog
|
|
18
|
+
|
|
19
|
+
# The small "choose another revision" button of the Git Extensions forms (visible size, border included) and its name.
|
|
20
|
+
REVISION_BUTTON_SIZE = QSize(23, 22)
|
|
21
|
+
REVISION_CHOICE_NAME = "Choose another revision"
|
|
22
|
+
# Spacing measured on the Checkout revision capture: the button sits 4 px after the field and 1 px higher than it,
|
|
23
|
+
# and the commit counter starts 10 px after the button.
|
|
24
|
+
FIELD_TO_BUTTON_SPACE = 4
|
|
25
|
+
BUTTON_RAISE = 1
|
|
26
|
+
BUTTON_TO_COUNT_SPACE = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RevisionPicker(QWidget):
|
|
30
|
+
"""Row that shows one commit as its short hash, a button to choose another one, and its position from HEAD.
|
|
31
|
+
|
|
32
|
+
The widget only displays and reports; it never starts Git. The reference form controller resolves what the user types or
|
|
33
|
+
chooses and answers with ``show_revision`` and ``show_count``.
|
|
34
|
+
|
|
35
|
+
Typing does not resolve anything on each key: the text is reported once the user pauses, and again when the
|
|
36
|
+
field is left with a text that has no proven commit yet. ``revision_edited`` carries the text and whether the
|
|
37
|
+
field was left. While a typed text is unresolved the widget has no target, so the form cannot act on a commit
|
|
38
|
+
the field no longer shows. ``resolution_generation`` changes with each key and each shown target, so an answer
|
|
39
|
+
for an older text can be recognized and dropped. Leaving the field while the text reported at the pause is still
|
|
40
|
+
being resolved reports nothing more: ``settle_generation`` asks the coming answer to settle the field instead.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
revision_edited = Signal(str, bool)
|
|
44
|
+
choose_requested = Signal()
|
|
45
|
+
target_changed = Signal(object)
|
|
46
|
+
|
|
47
|
+
def __init__(self, field_width: int, parent: QWidget | None = None) -> None:
|
|
48
|
+
"""Build the field, the choose button and the counter at the sizes and colors of the reproduced window."""
|
|
49
|
+
super().__init__(parent)
|
|
50
|
+
self.target_oid: str | None = None
|
|
51
|
+
# The "Choose another revision" window the choose button opened, while it is open.
|
|
52
|
+
self.revision_dialog: RevisionChoiceDialog | None = None
|
|
53
|
+
# The last commit this field proved, shown again when the user leaves the field with a text that is none.
|
|
54
|
+
self.last_target_oid: str | None = None
|
|
55
|
+
self.resolution_generation = 0
|
|
56
|
+
# The last text the reference form controller answered and its commit, None when that text is not a revision.
|
|
57
|
+
self.answered_text: str | None = None
|
|
58
|
+
self.answered_oid: str | None = None
|
|
59
|
+
# The last text reported at a typing pause, and the resolution generation it was reported under.
|
|
60
|
+
self.reported_text: str | None = None
|
|
61
|
+
self.reported_generation: int | None = None
|
|
62
|
+
# The resolution generation whose answer, still awaited when the field was left, must settle the field.
|
|
63
|
+
self.settle_generation: int | None = None
|
|
64
|
+
self.pause_timer = QTimer(self)
|
|
65
|
+
self.pause_timer.setSingleShot(True)
|
|
66
|
+
self.pause_timer.setInterval(REVISION_INPUT_PAUSE_MS)
|
|
67
|
+
self.pause_timer.timeout.connect(self._typing_paused)
|
|
68
|
+
self.revision_edit = QLineEdit()
|
|
69
|
+
self.revision_edit.setFixedWidth(field_width)
|
|
70
|
+
self.revision_edit.setAccessibleName("Revision")
|
|
71
|
+
self.choose_button = QPushButton()
|
|
72
|
+
self.choose_button.setIcon(action_icon("choose_revision", self.devicePixelRatioF()))
|
|
73
|
+
self.choose_button.setIconSize(QSize(ICON_SIDE, ICON_SIDE))
|
|
74
|
+
self.choose_button.setFixedSize(REVISION_BUTTON_SIZE)
|
|
75
|
+
self.choose_button.setAccessibleName(REVISION_CHOICE_NAME)
|
|
76
|
+
self.choose_button.setToolTip(REVISION_CHOICE_NAME)
|
|
77
|
+
# Only the form's submit button may be the default one; see the Enter handling of the reference forms.
|
|
78
|
+
self.choose_button.setAutoDefault(False)
|
|
79
|
+
self.choose_button.clicked.connect(self.choose_requested)
|
|
80
|
+
self.count_label = QLabel()
|
|
81
|
+
self.count_label.setAccessibleName("Commits relative to HEAD")
|
|
82
|
+
self.apply_theme_look()
|
|
83
|
+
# The field and the counter start one pixel lower than the button, which is how the capture places them.
|
|
84
|
+
field_column = QVBoxLayout()
|
|
85
|
+
field_column.setContentsMargins(0, BUTTON_RAISE, 0, 0)
|
|
86
|
+
field_column.setSpacing(0)
|
|
87
|
+
field_column.addWidget(self.revision_edit)
|
|
88
|
+
count_column = QVBoxLayout()
|
|
89
|
+
count_column.setContentsMargins(0, BUTTON_RAISE, 0, 0)
|
|
90
|
+
count_column.setSpacing(0)
|
|
91
|
+
count_column.addWidget(self.count_label)
|
|
92
|
+
row = QHBoxLayout(self)
|
|
93
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
94
|
+
row.setSpacing(0)
|
|
95
|
+
row.addLayout(field_column)
|
|
96
|
+
row.addSpacing(FIELD_TO_BUTTON_SPACE)
|
|
97
|
+
row.addWidget(self.choose_button, 0, Qt.AlignmentFlag.AlignTop)
|
|
98
|
+
row.addSpacing(BUTTON_TO_COUNT_SPACE)
|
|
99
|
+
row.addLayout(count_column)
|
|
100
|
+
row.addStretch(1)
|
|
101
|
+
# textEdited only reports the user's own changes: showing a resolved commit never starts another resolution.
|
|
102
|
+
self.revision_edit.textEdited.connect(self._text_edited)
|
|
103
|
+
self.revision_edit.editingFinished.connect(self._editing_finished)
|
|
104
|
+
self.revision_edit.installEventFilter(self)
|
|
105
|
+
|
|
106
|
+
def apply_theme_look(self) -> None:
|
|
107
|
+
"""Lay the light or dark look of the field, the choose button and the counter, after the application palette.
|
|
108
|
+
|
|
109
|
+
The picker does not follow the theme on its own: it is not in its window yet when it is built, so the form that
|
|
110
|
+
holds it calls this from its own theme look. Only style sheets and palettes change; the text and target stay.
|
|
111
|
+
"""
|
|
112
|
+
apply_windows_line_edit_look(self.revision_edit)
|
|
113
|
+
apply_windows_button_look(self.choose_button)
|
|
114
|
+
apply_secondary_text_color(self.count_label)
|
|
115
|
+
|
|
116
|
+
def show_revision(self, oid: str, *, rewrite_text: bool = True) -> None:
|
|
117
|
+
"""Target a full commit ID and remember it as the last proven one; its position is cleared until counted.
|
|
118
|
+
|
|
119
|
+
With ``rewrite_text`` the field shows the commit's first eight digits, all selected so typing replaces them;
|
|
120
|
+
without it the text the user is typing stays as it is, since the commit only answers a typing pause.
|
|
121
|
+
"""
|
|
122
|
+
self.pause_timer.stop()
|
|
123
|
+
self.resolution_generation += 1
|
|
124
|
+
self.target_oid = oid
|
|
125
|
+
self.last_target_oid = oid
|
|
126
|
+
if rewrite_text:
|
|
127
|
+
self.revision_edit.setText(oid[:GIT_EXTENSIONS_REVISION_PREFIX_LENGTH])
|
|
128
|
+
self.revision_edit.selectAll()
|
|
129
|
+
self.answered_text = typed_value(self.revision_edit.text())
|
|
130
|
+
self.answered_oid = oid
|
|
131
|
+
self.show_count("")
|
|
132
|
+
self.target_changed.emit(oid)
|
|
133
|
+
|
|
134
|
+
def show_invalid(self) -> None:
|
|
135
|
+
"""Show that the typed text names no commit: no target, no position, and the refusal kept for that text."""
|
|
136
|
+
self.target_oid = None
|
|
137
|
+
self.answered_text = typed_value(self.revision_edit.text())
|
|
138
|
+
self.answered_oid = None
|
|
139
|
+
self.show_count("")
|
|
140
|
+
self.target_changed.emit(None)
|
|
141
|
+
|
|
142
|
+
def restore_last_target(self) -> None:
|
|
143
|
+
"""Show the last proven commit again, as its short hash, after the field was left with a text naming none."""
|
|
144
|
+
if self.last_target_oid is None:
|
|
145
|
+
return
|
|
146
|
+
self.show_revision(self.last_target_oid)
|
|
147
|
+
|
|
148
|
+
def show_count(self, text: str) -> None:
|
|
149
|
+
"""Show the commit's position from HEAD, for example "-13", "+2", "=" or nothing when it is unknown."""
|
|
150
|
+
self.count_label.setText(text)
|
|
151
|
+
|
|
152
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
153
|
+
"""Let Enter settle a text that has no commit yet without also running the form's action.
|
|
154
|
+
|
|
155
|
+
The field would pass Enter on to the form, whose default button would then act on whatever commit the field
|
|
156
|
+
had just been settled on, or restored to, while the user was still correcting it. Enter on a proven commit
|
|
157
|
+
still reaches the form.
|
|
158
|
+
"""
|
|
159
|
+
if (watched is self.revision_edit and event.type() == QEvent.Type.KeyPress and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
|
|
160
|
+
and self.target_oid is None):
|
|
161
|
+
self._editing_finished()
|
|
162
|
+
return True
|
|
163
|
+
return super().eventFilter(watched, event)
|
|
164
|
+
|
|
165
|
+
def _text_edited(self, _text: str) -> None:
|
|
166
|
+
"""Forget the target the field no longer shows and wait for a pause in typing before reporting the text."""
|
|
167
|
+
self.resolution_generation += 1
|
|
168
|
+
self.pause_timer.start()
|
|
169
|
+
if self.target_oid is None:
|
|
170
|
+
return
|
|
171
|
+
self.target_oid = None
|
|
172
|
+
self.show_count("")
|
|
173
|
+
self.target_changed.emit(None)
|
|
174
|
+
|
|
175
|
+
def _typing_paused(self) -> None:
|
|
176
|
+
"""Report the typed text once the user has stopped typing, unless it is the text answered last.
|
|
177
|
+
|
|
178
|
+
Typing a character and erasing it gives back a text whose answer is already known: that answer is shown again
|
|
179
|
+
without asking the reference form controller, so no Git process is started for a text resolved a moment ago.
|
|
180
|
+
"""
|
|
181
|
+
text = typed_value(self.revision_edit.text())
|
|
182
|
+
if text != self.answered_text:
|
|
183
|
+
self.reported_text = text
|
|
184
|
+
self.reported_generation = self.resolution_generation
|
|
185
|
+
self.revision_edited.emit(text, False)
|
|
186
|
+
return
|
|
187
|
+
if self.answered_oid is not None:
|
|
188
|
+
self.show_revision(self.answered_oid, rewrite_text=False)
|
|
189
|
+
|
|
190
|
+
def _editing_finished(self) -> None:
|
|
191
|
+
"""Settle the field when the user leaves it or presses Enter.
|
|
192
|
+
|
|
193
|
+
A commit already proven for the typed text is shown as its short hash, like Git Extensions does. A text still
|
|
194
|
+
without a commit is reported as final, so the reference form controller either takes it or restores the last commit. That
|
|
195
|
+
report is remembered like a pause's report, so leaving the field right after Enter waits for its answer instead
|
|
196
|
+
of resolving the same text a second time.
|
|
197
|
+
"""
|
|
198
|
+
self.pause_timer.stop()
|
|
199
|
+
text = typed_value(self.revision_edit.text())
|
|
200
|
+
if self.target_oid is None and text == self.answered_text and self.answered_oid is not None:
|
|
201
|
+
self.show_revision(self.answered_oid)
|
|
202
|
+
return
|
|
203
|
+
if self.target_oid is not None:
|
|
204
|
+
# Only the text changes: the target, and the position already shown or still being counted, stay.
|
|
205
|
+
short_text = self.target_oid[:GIT_EXTENSIONS_REVISION_PREFIX_LENGTH]
|
|
206
|
+
if text != short_text:
|
|
207
|
+
self.revision_edit.setText(short_text)
|
|
208
|
+
self.answered_text = short_text
|
|
209
|
+
return
|
|
210
|
+
if self.is_awaiting_answer(text):
|
|
211
|
+
# The text reported at the pause is still being resolved: its answer settles the field when it arrives, so
|
|
212
|
+
# the same text is never resolved a second time.
|
|
213
|
+
self.settle_generation = self.resolution_generation
|
|
214
|
+
return
|
|
215
|
+
self.resolution_generation += 1
|
|
216
|
+
self.reported_text = text
|
|
217
|
+
self.reported_generation = self.resolution_generation
|
|
218
|
+
self.settle_generation = self.resolution_generation
|
|
219
|
+
self.revision_edited.emit(text, True)
|
|
220
|
+
|
|
221
|
+
def is_awaiting_answer(self, text: str) -> bool:
|
|
222
|
+
"""Tell whether this text was reported at the last typing pause and its answer has not arrived yet.
|
|
223
|
+
|
|
224
|
+
Any key typed since, and any target shown since, changes the resolution generation, so an older report never
|
|
225
|
+
counts as awaited.
|
|
226
|
+
"""
|
|
227
|
+
return text == self.reported_text and self.reported_generation == self.resolution_generation and text != self.answered_text
|