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,291 @@
|
|
|
1
|
+
"""Project prepared history changes into read-only table rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
|
11
|
+
from PySide6.QtGui import QColor, QFont, QPalette
|
|
12
|
+
from PySide6.QtWidgets import QStyle, QStyledItemDelegate
|
|
13
|
+
|
|
14
|
+
from gitpane.config import ERROR_DARK_COLOR, ERROR_LIGHT_COLOR, LOCAL_PREFIX, SHORT_OID_LENGTH, SPACING_XS, UNKNOWN_DARK_COLOR, UNKNOWN_LIGHT_COLOR
|
|
15
|
+
from gitpane.history_rewrite.value import RewriteStatus
|
|
16
|
+
from gitpane.ui.look.color_contrast import contrast_ratio
|
|
17
|
+
from gitpane.ui.look.theme_color import is_dark_palette
|
|
18
|
+
from gitpane.ui.widget.plain_tooltip import plain_text_tooltip
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from PySide6.QtGui import QPainter
|
|
22
|
+
from PySide6.QtWidgets import QStyleOptionViewItem, QWidget
|
|
23
|
+
|
|
24
|
+
from gitpane.history_rewrite.value import BranchRewrite, RewriteRow
|
|
25
|
+
|
|
26
|
+
STATUS_MINIMUM_CONTRAST = 4.5
|
|
27
|
+
STATUS_CONTRAST_STEP_PERCENT = 110
|
|
28
|
+
STATUS_CONTRAST_MAXIMUM_STEPS = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BranchColumn(Enum):
|
|
32
|
+
"""Name the branch comparison columns in their presentation order."""
|
|
33
|
+
|
|
34
|
+
BRANCH = "Branch"
|
|
35
|
+
CURRENT_TIP = "Current tip"
|
|
36
|
+
NEW_TIP = "New tip"
|
|
37
|
+
PUBLICATION = "Known publication"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def index(self) -> int:
|
|
41
|
+
"""Locate this column without separate integer mappings."""
|
|
42
|
+
return tuple(type(self)).index(self)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CommitColumn(Enum):
|
|
46
|
+
"""Name the contribution columns in their presentation order."""
|
|
47
|
+
|
|
48
|
+
STATUS = "Status"
|
|
49
|
+
COMMIT = "Commit"
|
|
50
|
+
MESSAGE = "Message"
|
|
51
|
+
AUTHOR = "Author"
|
|
52
|
+
DATE = "Date"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def index(self) -> int:
|
|
56
|
+
"""Locate this column without separate integer mappings."""
|
|
57
|
+
return tuple(type(self)).index(self)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ModelText(Enum):
|
|
61
|
+
"""Keep presentation labels shared by tables and their details."""
|
|
62
|
+
|
|
63
|
+
CURRENT = " (current)"
|
|
64
|
+
TIP_DELETED = "Previous tip deleted"
|
|
65
|
+
KNOWN = "Affected commits found in {remote_refs} — last fetch"
|
|
66
|
+
UNKNOWN = "Unknown — no matching remote reference known"
|
|
67
|
+
NO_UPSTREAM = "No upstream is configured. This does not mean the branch has never been published."
|
|
68
|
+
UPSTREAM = "Upstream: {upstream}"
|
|
69
|
+
NO_MESSAGE = "(No commit message)"
|
|
70
|
+
NO_SUBJECT = "(No commit subject)"
|
|
71
|
+
MODIFIED = "Modified commit"
|
|
72
|
+
DELETED = "Deleted commit"
|
|
73
|
+
DATE_UNREADABLE = "Unreadable author date: {date}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def safe_text(value: str | bytes, *, multiline: bool = False) -> str:
|
|
77
|
+
"""Expose controls and undecodable bytes without letting them disguise displayed identities."""
|
|
78
|
+
if isinstance(value, bytes):
|
|
79
|
+
value = value.decode("utf-8", "surrogateescape")
|
|
80
|
+
parts = []
|
|
81
|
+
for character in value:
|
|
82
|
+
code = ord(character)
|
|
83
|
+
if character == "\\":
|
|
84
|
+
parts.append("\\\\")
|
|
85
|
+
elif character == "\n" and multiline:
|
|
86
|
+
parts.append(character)
|
|
87
|
+
elif character == "\n":
|
|
88
|
+
parts.append(r"\n")
|
|
89
|
+
elif character == "\t":
|
|
90
|
+
parts.append(r"\t")
|
|
91
|
+
elif character == "\r":
|
|
92
|
+
parts.append(r"\r")
|
|
93
|
+
elif 0xDC80 <= code <= 0xDCFF:
|
|
94
|
+
parts.append(f"\\x{code - 0xDC00:02x}")
|
|
95
|
+
elif unicodedata.category(character).startswith("C") or not character.isprintable():
|
|
96
|
+
parts.append(f"\\x{code:02x}" if code <= 0xFF else f"\\u{code:04x}")
|
|
97
|
+
else:
|
|
98
|
+
parts.append(character)
|
|
99
|
+
return "".join(parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def author_date(value: str) -> str:
|
|
103
|
+
"""Render the original author offset instead of silently using the computer's timezone."""
|
|
104
|
+
timestamp, offset = value.split()
|
|
105
|
+
sign_index, hour_start, hour_end, minute_end = 0, 1, 3, 5
|
|
106
|
+
if len(offset) != minute_end or offset[sign_index] not in "+-" or not offset[hour_start:].isdigit():
|
|
107
|
+
raise ValueError(ModelText.DATE_UNREADABLE.value.format(date=safe_text(value)))
|
|
108
|
+
hour, minute = int(offset[hour_start:hour_end]), int(offset[hour_end:minute_end])
|
|
109
|
+
minutes_per_hour = 60
|
|
110
|
+
if minute >= minutes_per_hour:
|
|
111
|
+
raise ValueError(ModelText.DATE_UNREADABLE.value.format(date=safe_text(value)))
|
|
112
|
+
delta = timedelta(minutes=(hour * minutes_per_hour + minute) * (-1 if offset.startswith("-") else 1))
|
|
113
|
+
date = datetime.fromtimestamp(int(timestamp), UTC).astimezone(timezone(delta))
|
|
114
|
+
return date.strftime("%Y-%m-%d %H:%M %z")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def publication_text(branch: BranchRewrite) -> str:
|
|
118
|
+
"""Describe only publication observed in locally known remote references."""
|
|
119
|
+
if not branch.remote_refs:
|
|
120
|
+
return ModelText.UNKNOWN.value
|
|
121
|
+
refs = []
|
|
122
|
+
for ref in sorted(branch.remote_refs):
|
|
123
|
+
refs.append(safe_text(ref))
|
|
124
|
+
return ModelText.KNOWN.value.format(remote_refs=", ".join(refs))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BranchPreviewModel(QAbstractTableModel):
|
|
128
|
+
"""Present every moved local branch with its full identity available in details."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, branches: tuple[BranchRewrite, ...] = (), active_branch: str | None = None) -> None:
|
|
131
|
+
"""Sort the current branch first, then use the complete reference names."""
|
|
132
|
+
super().__init__()
|
|
133
|
+
self.active_branch = active_branch
|
|
134
|
+
self.branches = tuple(sorted(branches, key=lambda branch: (branch.name != active_branch, branch.name)))
|
|
135
|
+
|
|
136
|
+
def rowCount(self, parent: QModelIndex | None = None) -> int:
|
|
137
|
+
"""Expose all branches without creating child rows."""
|
|
138
|
+
return 0 if parent is not None and parent.isValid() else len(self.branches)
|
|
139
|
+
|
|
140
|
+
def columnCount(self, parent: QModelIndex | None = None) -> int:
|
|
141
|
+
"""Keep the comparison columns even when no branch is present."""
|
|
142
|
+
return 0 if parent is not None and parent.isValid() else len(BranchColumn)
|
|
143
|
+
|
|
144
|
+
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> object:
|
|
145
|
+
"""Name horizontal columns using the shared enum."""
|
|
146
|
+
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole and 0 <= section < len(BranchColumn):
|
|
147
|
+
return tuple(BranchColumn)[section].value
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> object:
|
|
151
|
+
"""Return plain text and complete details for the requested branch."""
|
|
152
|
+
if not index.isValid() or not 0 <= index.row() < len(self.branches):
|
|
153
|
+
return None
|
|
154
|
+
if role == Qt.ItemDataRole.ToolTipRole:
|
|
155
|
+
return plain_text_tooltip(self.details(index.row()).splitlines())
|
|
156
|
+
if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.AccessibleTextRole):
|
|
157
|
+
return None
|
|
158
|
+
branch = self.branches[index.row()]
|
|
159
|
+
column = tuple(BranchColumn)[index.column()]
|
|
160
|
+
if column == BranchColumn.BRANCH:
|
|
161
|
+
return safe_text(branch.name.removeprefix(LOCAL_PREFIX)) + (ModelText.CURRENT.value if branch.name == self.active_branch else "")
|
|
162
|
+
if column == BranchColumn.CURRENT_TIP:
|
|
163
|
+
return branch.old_oid[:SHORT_OID_LENGTH]
|
|
164
|
+
if column == BranchColumn.NEW_TIP:
|
|
165
|
+
return branch.new_oid[:SHORT_OID_LENGTH] + (f" — {ModelText.TIP_DELETED.value}" if branch.tip_deleted else "")
|
|
166
|
+
return publication_text(branch)
|
|
167
|
+
|
|
168
|
+
def details(self, row: int) -> str:
|
|
169
|
+
"""Expose complete branch names and tips for keyboard access and copying."""
|
|
170
|
+
branch = self.branches[row]
|
|
171
|
+
lines = [f"{BranchColumn.BRANCH.value}: {safe_text(branch.name)}", f"{BranchColumn.CURRENT_TIP.value}: {branch.old_oid}",
|
|
172
|
+
f"{BranchColumn.NEW_TIP.value}: {branch.new_oid}", publication_text(branch)]
|
|
173
|
+
if branch.tip_deleted:
|
|
174
|
+
lines.append(ModelText.TIP_DELETED.value)
|
|
175
|
+
lines.append(ModelText.UPSTREAM.value.format(upstream=safe_text(branch.upstream)) if branch.upstream else ModelText.NO_UPSTREAM.value)
|
|
176
|
+
return "\n".join(lines)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class CommitPreviewModel(QAbstractTableModel):
|
|
180
|
+
"""Present only the contribution rows supplied by the domain in its stable order."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, rows: tuple[RewriteRow, ...] = ()) -> None:
|
|
183
|
+
"""Keep the immutable rows without instantiating per-cell widgets."""
|
|
184
|
+
super().__init__()
|
|
185
|
+
self.rows = rows
|
|
186
|
+
self.deleted_font = QFont()
|
|
187
|
+
self.deleted_font.setBold(True)
|
|
188
|
+
|
|
189
|
+
def rowCount(self, parent: QModelIndex | None = None) -> int:
|
|
190
|
+
"""Expose every contribution, including rows beyond the viewport."""
|
|
191
|
+
return 0 if parent is not None and parent.isValid() else len(self.rows)
|
|
192
|
+
|
|
193
|
+
def columnCount(self, parent: QModelIndex | None = None) -> int:
|
|
194
|
+
"""Keep the same five columns in every state."""
|
|
195
|
+
return 0 if parent is not None and parent.isValid() else len(CommitColumn)
|
|
196
|
+
|
|
197
|
+
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> object:
|
|
198
|
+
"""Return the English column labels."""
|
|
199
|
+
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole and 0 <= section < len(CommitColumn):
|
|
200
|
+
return tuple(CommitColumn)[section].value
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> object:
|
|
204
|
+
"""Project text without inventing contributions from rewritten descendants."""
|
|
205
|
+
if not index.isValid() or not 0 <= index.row() < len(self.rows):
|
|
206
|
+
return None
|
|
207
|
+
row = self.rows[index.row()]
|
|
208
|
+
column = tuple(CommitColumn)[index.column()]
|
|
209
|
+
if role == Qt.ItemDataRole.ToolTipRole:
|
|
210
|
+
text = row.oid if column == CommitColumn.COMMIT else self.data(index)
|
|
211
|
+
return plain_text_tooltip([str(text)])
|
|
212
|
+
if role == Qt.ItemDataRole.UserRole:
|
|
213
|
+
return row
|
|
214
|
+
if column == CommitColumn.STATUS:
|
|
215
|
+
if role == Qt.ItemDataRole.FontRole and row.status == RewriteStatus.DELETED:
|
|
216
|
+
return self.deleted_font
|
|
217
|
+
if role == Qt.ItemDataRole.AccessibleTextRole:
|
|
218
|
+
return (ModelText.DELETED if row.status == RewriteStatus.DELETED else ModelText.MODIFIED).value
|
|
219
|
+
if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.AccessibleTextRole):
|
|
220
|
+
return None
|
|
221
|
+
if column == CommitColumn.STATUS:
|
|
222
|
+
return row.status.value
|
|
223
|
+
if column == CommitColumn.COMMIT:
|
|
224
|
+
return row.oid[:SHORT_OID_LENGTH]
|
|
225
|
+
if column == CommitColumn.MESSAGE:
|
|
226
|
+
if row.message:
|
|
227
|
+
return safe_text(row.message)
|
|
228
|
+
return (ModelText.NO_SUBJECT if row.message_length else ModelText.NO_MESSAGE).value
|
|
229
|
+
if column == CommitColumn.AUTHOR:
|
|
230
|
+
return safe_text(row.author)
|
|
231
|
+
try:
|
|
232
|
+
return author_date(row.date)
|
|
233
|
+
except (ValueError, OverflowError, OSError):
|
|
234
|
+
return ModelText.DATE_UNREADABLE.value.format(date=safe_text(row.date))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def status_color(status: RewriteStatus, palette: QPalette, *, high_contrast: bool = False) -> QColor:
|
|
238
|
+
"""Use semantic colors on Base, or the explicit contrast palette when requested."""
|
|
239
|
+
if high_contrast:
|
|
240
|
+
return palette.color(QPalette.ColorRole.Text)
|
|
241
|
+
dark = is_dark_palette(palette)
|
|
242
|
+
if status == RewriteStatus.DELETED:
|
|
243
|
+
color = QColor(ERROR_DARK_COLOR if dark else ERROR_LIGHT_COLOR)
|
|
244
|
+
else:
|
|
245
|
+
color = QColor(UNKNOWN_DARK_COLOR if dark else UNKNOWN_LIGHT_COLOR)
|
|
246
|
+
for _step in range(STATUS_CONTRAST_MAXIMUM_STEPS):
|
|
247
|
+
if contrast_ratio(color, palette.color(QPalette.ColorRole.Base)) >= STATUS_MINIMUM_CONTRAST:
|
|
248
|
+
return color
|
|
249
|
+
color = color.lighter(STATUS_CONTRAST_STEP_PERCENT) if dark else color.darker(STATUS_CONTRAST_STEP_PERCENT)
|
|
250
|
+
return palette.color(QPalette.ColorRole.Text)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class StatusDelegate(QStyledItemDelegate):
|
|
254
|
+
"""Keep the status letter readable against its own Base patch during selection."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, parent: QWidget) -> None:
|
|
257
|
+
"""Retain the contrast preference without caching theme-dependent colors."""
|
|
258
|
+
super().__init__(parent)
|
|
259
|
+
self.high_contrast = False
|
|
260
|
+
self.refresh_theme()
|
|
261
|
+
|
|
262
|
+
def refresh_theme(self) -> None:
|
|
263
|
+
"""Cache colors and fonts outside painting so theme changes cannot allocate per cell."""
|
|
264
|
+
self.normal_font = self.parent().font()
|
|
265
|
+
self.deleted_font = QFont(self.normal_font)
|
|
266
|
+
self.deleted_font.setBold(True)
|
|
267
|
+
palette = self.parent().palette()
|
|
268
|
+
self.base_by_group = {}
|
|
269
|
+
self.color_by_key = {}
|
|
270
|
+
for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive):
|
|
271
|
+
palette.setCurrentColorGroup(group)
|
|
272
|
+
self.base_by_group[group] = palette.brush(QPalette.ColorRole.Base)
|
|
273
|
+
for status in RewriteStatus:
|
|
274
|
+
self.color_by_key[status, group] = status_color(status, palette, high_contrast=self.high_contrast)
|
|
275
|
+
|
|
276
|
+
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
|
277
|
+
"""Leave native selection and focus intact behind a small semantic status patch."""
|
|
278
|
+
super().paint(painter, option, index)
|
|
279
|
+
row = index.data(Qt.ItemDataRole.UserRole)
|
|
280
|
+
if row is None:
|
|
281
|
+
return
|
|
282
|
+
painter.save()
|
|
283
|
+
painter.setFont(self.deleted_font if row.status == RewriteStatus.DELETED else self.normal_font)
|
|
284
|
+
patch = option.rect.adjusted(0, 0, 0, 0)
|
|
285
|
+
patch.setWidth(painter.fontMetrics().horizontalAdvance(row.status.value) + SPACING_XS * 2)
|
|
286
|
+
rect = patch.adjusted(SPACING_XS, 0, 0, 0)
|
|
287
|
+
group = QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else QPalette.ColorGroup.Inactive
|
|
288
|
+
painter.fillRect(patch, self.base_by_group[group])
|
|
289
|
+
painter.setPen(self.color_by_key[row.status, group])
|
|
290
|
+
painter.drawText(rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, row.status.value)
|
|
291
|
+
painter.restore()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Request explicit consent to prepare a history removal preview."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Qt, Signal
|
|
9
|
+
from PySide6.QtWidgets import QMessageBox
|
|
10
|
+
|
|
11
|
+
from gitpane.config import LOCAL_PREFIX
|
|
12
|
+
from gitpane.history_rewrite.value import RewriteTargetKind
|
|
13
|
+
from gitpane.ui.history_rewrite.preview_model import safe_text
|
|
14
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from PySide6.QtWidgets import QAbstractButton, QWidget
|
|
18
|
+
|
|
19
|
+
from gitpane.history_rewrite.value import RewriteTarget
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QuestionText(Enum):
|
|
23
|
+
"""Keep the initial question independent of the operation's final confirmation."""
|
|
24
|
+
|
|
25
|
+
TITLE = "Remove from history"
|
|
26
|
+
BUILD = "Build preview"
|
|
27
|
+
ABORT = "Abort"
|
|
28
|
+
BODY = ("GitPane will scan all ancestors of {branch} and every affected local branch, including all merge parents, to remove {path} from their history. "
|
|
29
|
+
"Current files on disk will be kept exactly as they are. Build the preview?")
|
|
30
|
+
FOLDER = "Folder and all files below it: {path}"
|
|
31
|
+
FILE = "File: {path}"
|
|
32
|
+
NO_TARGET = "No valid file or folder is selected. Choose Abort, select one path, and open Remove from history again."
|
|
33
|
+
NO_BRANCH = "HEAD is detached. Check out a local branch, then build a new preview."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HistoryRewriteQuestion(QMessageBox):
|
|
37
|
+
"""Show the captured path without performing preparation or any Git access."""
|
|
38
|
+
|
|
39
|
+
build_requested = Signal(object)
|
|
40
|
+
abort_requested = Signal()
|
|
41
|
+
|
|
42
|
+
def __init__(self, target: RewriteTarget | None, branch: str | None, parent: QWidget | None = None) -> None:
|
|
43
|
+
"""Default to Abort and retain the exact typed target for the explicit choice."""
|
|
44
|
+
super().__init__(parent)
|
|
45
|
+
self.target = target
|
|
46
|
+
self.answered = False
|
|
47
|
+
self.setIcon(QMessageBox.Icon.Question)
|
|
48
|
+
prepare_message_box(self, QuestionText.TITLE.value)
|
|
49
|
+
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
50
|
+
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
|
|
51
|
+
self.build_button = self.addButton(QuestionText.BUILD.value, QMessageBox.ButtonRole.AcceptRole)
|
|
52
|
+
self.abort_button = self.addButton(QuestionText.ABORT.value, QMessageBox.ButtonRole.RejectRole)
|
|
53
|
+
self.build_button.setAutoDefault(False)
|
|
54
|
+
self.setDefaultButton(self.abort_button)
|
|
55
|
+
self.setEscapeButton(self.abort_button)
|
|
56
|
+
if target is None:
|
|
57
|
+
self.setText(QuestionText.NO_TARGET.value)
|
|
58
|
+
elif not branch:
|
|
59
|
+
self.setText(QuestionText.NO_BRANCH.value)
|
|
60
|
+
else:
|
|
61
|
+
path = safe_text(target.path)
|
|
62
|
+
self.setText(QuestionText.BODY.value.format(branch=safe_text(branch.removeprefix(LOCAL_PREFIX)), path=path))
|
|
63
|
+
self.setInformativeText((QuestionText.FOLDER if target.kind == RewriteTargetKind.FOLDER else QuestionText.FILE).value.format(path=path))
|
|
64
|
+
self.build_button.setEnabled(target is not None and bool(branch))
|
|
65
|
+
self.buttonClicked.connect(self._choose)
|
|
66
|
+
self.finished.connect(self._dismiss)
|
|
67
|
+
|
|
68
|
+
def _choose(self, button: QAbstractButton) -> None:
|
|
69
|
+
"""Emit the original target only for the first explicit Build preview choice."""
|
|
70
|
+
if self.answered:
|
|
71
|
+
return
|
|
72
|
+
self.answered = True
|
|
73
|
+
if button == self.build_button and self.target is not None:
|
|
74
|
+
self.build_button.setEnabled(False)
|
|
75
|
+
self.build_requested.emit(self.target)
|
|
76
|
+
else:
|
|
77
|
+
self.abort_requested.emit()
|
|
78
|
+
|
|
79
|
+
def _dismiss(self, _result: int) -> None:
|
|
80
|
+
"""Treat window closure as Abort when no button supplied a choice."""
|
|
81
|
+
if not self.answered:
|
|
82
|
+
self.answered = True
|
|
83
|
+
self.abort_requested.emit()
|
|
84
|
+
|
|
85
|
+
def show_modal(self) -> None:
|
|
86
|
+
"""Use the common native-window policy without starting preparation."""
|
|
87
|
+
open_window_modal(self)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Explain an interrupted history publication using observed repository facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Qt, Signal
|
|
9
|
+
from PySide6.QtGui import QKeySequence, QShortcut
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QDialog, QPlainTextEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout
|
|
11
|
+
|
|
12
|
+
from gitpane.config import fit_window
|
|
13
|
+
from gitpane.history_rewrite.recovery import RewritePublicationState
|
|
14
|
+
from gitpane.ui.history_rewrite.preview_model import safe_text
|
|
15
|
+
from gitpane.ui.widget.dialog_layout import install_banded_layout
|
|
16
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from PySide6.QtWidgets import QWidget
|
|
20
|
+
|
|
21
|
+
from gitpane.history_rewrite.recovery import RecoveryDiscovery, RecoveryObservation, RewriteRecoveryRecord
|
|
22
|
+
|
|
23
|
+
RECOVERY_WIDTH_CHARACTERS = 100
|
|
24
|
+
RECOVERY_HEIGHT_LINES = 28
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RestoreText(Enum):
|
|
28
|
+
"""Describe the exact collective restoration being separately confirmed."""
|
|
29
|
+
|
|
30
|
+
TITLE = "Restore previous branches?"
|
|
31
|
+
BODY = ("Restore every local branch listed below from its current tip to its previous tip? The original index is still in place. "
|
|
32
|
+
"Current files on disk will not be changed. GitPane will check the repository again before restoring these branches.")
|
|
33
|
+
BRANCH = "Branch"
|
|
34
|
+
CURRENT = "Current tip"
|
|
35
|
+
PREVIOUS = "Previous tip to restore"
|
|
36
|
+
INDEX = "Index: original index still in place."
|
|
37
|
+
RESTORE = "Restore previous branches"
|
|
38
|
+
KEEP = "Keep current state"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RecoveryText(Enum):
|
|
42
|
+
"""Name diagnostics without presenting journal entries as observed facts."""
|
|
43
|
+
|
|
44
|
+
TITLE = "History rewrite needs recovery"
|
|
45
|
+
BODY = ("The local branch updates and the index update did not finish together. Review the verified state below before making further changes. "
|
|
46
|
+
"Current files on disk were not rewritten by this operation.")
|
|
47
|
+
UNAVAILABLE = ("The repository changed after the interrupted operation. Automatic recovery is unavailable to avoid overwriting those changes. "
|
|
48
|
+
"Copy the details and inspect the repository before continuing.")
|
|
49
|
+
COPY = "Copy details"
|
|
50
|
+
REFRESH = "Refresh status"
|
|
51
|
+
CLOSE = "Close diagnostic"
|
|
52
|
+
LOADING = "Reading current repository state…"
|
|
53
|
+
UNKNOWN = "Not observed"
|
|
54
|
+
ABSENT = "Absent"
|
|
55
|
+
NOT_RECORDED = "Not recorded"
|
|
56
|
+
REPOSITORY = "Repository"
|
|
57
|
+
DIRECTORY = "Recovery folder"
|
|
58
|
+
PREVIOUS = "Previous tip"
|
|
59
|
+
NEW = "Proposed tip"
|
|
60
|
+
OBSERVED = "Observed tip"
|
|
61
|
+
HEAD = "Observed HEAD"
|
|
62
|
+
INDEX = "Observed index digest"
|
|
63
|
+
ORIGINAL_INDEX = "Recorded original index digest"
|
|
64
|
+
CANDIDATE_INDEX = "Recorded candidate index digest"
|
|
65
|
+
LOCK = "Observed index lock"
|
|
66
|
+
STATE = "Last recorded state"
|
|
67
|
+
MISSING = "Recovery data is incomplete. Copy the details and inspect the recovery folder before continuing."
|
|
68
|
+
RESTORED = "The previous branches and original index are in place. Recovery is complete; working files were retained."
|
|
69
|
+
COMPLETE = "The recorded operation is complete. Recovery does not undo completed operations."
|
|
70
|
+
EXECUTABLE_CHANGED = "The Git executable differs from the interrupted operation. Reopen this repository with the original executable before restoring branches."
|
|
71
|
+
OBSERVED_PUBLISHED = "Observed branches and index match the completed update. No branch restoration is needed."
|
|
72
|
+
OBSERVED_PREVIOUS = "Observed branches and index match the previous state. No branch restoration is needed."
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def can_restore(record: RewriteRecoveryRecord | None, observation: RecoveryObservation | None) -> bool:
|
|
76
|
+
"""Require complete matching facts before presenting any restoration choice."""
|
|
77
|
+
return bool(record is not None and observation is not None and record.old_oid_by_ref and observation.restorable and not observation.detail
|
|
78
|
+
and record.state not in (RewritePublicationState.COMPLETE, RewritePublicationState.RESTORED)
|
|
79
|
+
and record.candidate_index_identity is not None and set(record.old_oid_by_ref) == set(record.new_oid_by_ref)
|
|
80
|
+
and observation.ref_oid_by_name == record.new_oid_by_ref and observation.head_ref == record.head_ref
|
|
81
|
+
and observation.index_identity == record.original_index_identity and observation.index_identity != record.candidate_index_identity)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def branch_table(headers: list[str], rows: list[list[str]], parent: QWidget) -> QTableWidget:
|
|
85
|
+
"""Make full identities selectable with native scrolling and a copy shortcut."""
|
|
86
|
+
table = QTableWidget(len(rows), len(headers), parent)
|
|
87
|
+
table.setHorizontalHeaderLabels(headers)
|
|
88
|
+
table.setAccessibleName(RestoreText.BRANCH.value)
|
|
89
|
+
table.setWordWrap(False)
|
|
90
|
+
table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
|
91
|
+
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
92
|
+
table.verticalHeader().hide()
|
|
93
|
+
for row_index, values in enumerate(rows):
|
|
94
|
+
for column_index, value in enumerate(values):
|
|
95
|
+
table.setItem(row_index, column_index, QTableWidgetItem(value))
|
|
96
|
+
table.resizeColumnsToContents()
|
|
97
|
+
shortcut = QShortcut(QKeySequence.StandardKey.Copy, table)
|
|
98
|
+
shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
|
99
|
+
|
|
100
|
+
def copy_selection() -> None:
|
|
101
|
+
"""Copy selected cells in display order without truncating any identity."""
|
|
102
|
+
lines = []
|
|
103
|
+
for selection in table.selectedRanges():
|
|
104
|
+
for row_index in range(selection.topRow(), selection.bottomRow() + 1):
|
|
105
|
+
values = []
|
|
106
|
+
for column_index in range(selection.leftColumn(), selection.rightColumn() + 1):
|
|
107
|
+
item = table.item(row_index, column_index)
|
|
108
|
+
values.append(item.text() if item is not None else "")
|
|
109
|
+
lines.append("\t".join(values))
|
|
110
|
+
QApplication.clipboard().setText("\n".join(lines))
|
|
111
|
+
|
|
112
|
+
shortcut.activated.connect(copy_selection)
|
|
113
|
+
return table
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fit_recovery_dialog(dialog: QDialog) -> None:
|
|
117
|
+
"""Size the scrollable content from the current font while keeping actions on screen."""
|
|
118
|
+
metrics = dialog.fontMetrics()
|
|
119
|
+
dialog.resize(metrics.horizontalAdvance("M") * RECOVERY_WIDTH_CHARACTERS, metrics.lineSpacing() * RECOVERY_HEIGHT_LINES)
|
|
120
|
+
fit_window(dialog, 0, 0)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class HistoryRewriteRecoveryDialog(QDialog):
|
|
124
|
+
"""Keep incomplete publication evidence visible without performing repository writes."""
|
|
125
|
+
|
|
126
|
+
refresh_requested = Signal()
|
|
127
|
+
restore_requested = Signal()
|
|
128
|
+
|
|
129
|
+
def __init__(self, repository_dir: str, discovery: RecoveryDiscovery, parent: QWidget | None = None) -> None:
|
|
130
|
+
"""Present durable evidence with restoration disabled until an observation arrives."""
|
|
131
|
+
super().__init__(parent)
|
|
132
|
+
self.repository_dir = repository_dir
|
|
133
|
+
self.operation_dir = discovery.operation_dir
|
|
134
|
+
self.record = discovery.record
|
|
135
|
+
self.observation: RecoveryObservation | None = None
|
|
136
|
+
self.setWindowTitle(RecoveryText.TITLE.value)
|
|
137
|
+
self.details = QPlainTextEdit()
|
|
138
|
+
self.details.setReadOnly(True)
|
|
139
|
+
self.details.setAccessibleName(RecoveryText.TITLE.value)
|
|
140
|
+
self.branch_table = branch_table([RestoreText.BRANCH.value, RecoveryText.PREVIOUS.value, RecoveryText.NEW.value, RecoveryText.OBSERVED.value], [], self)
|
|
141
|
+
self.copy_button = QPushButton(RecoveryText.COPY.value)
|
|
142
|
+
self.refresh_button = QPushButton(RecoveryText.REFRESH.value)
|
|
143
|
+
self.restore_button = QPushButton(RestoreText.RESTORE.value)
|
|
144
|
+
self.close_button = QPushButton(RecoveryText.CLOSE.value)
|
|
145
|
+
buttons = [self.copy_button, self.refresh_button, self.restore_button, self.close_button]
|
|
146
|
+
for button in buttons:
|
|
147
|
+
button.setAutoDefault(False)
|
|
148
|
+
self.close_button.setDefault(True)
|
|
149
|
+
self.copy_button.clicked.connect(self.copy_details)
|
|
150
|
+
self.refresh_button.clicked.connect(self.refresh_requested)
|
|
151
|
+
self.restore_button.clicked.connect(self.restore_requested)
|
|
152
|
+
self.close_button.clicked.connect(self.reject)
|
|
153
|
+
body = QVBoxLayout()
|
|
154
|
+
body.addWidget(self.details)
|
|
155
|
+
body.addWidget(self.branch_table)
|
|
156
|
+
self.button_band = install_banded_layout(self, body, buttons)
|
|
157
|
+
self.set_observation(discovery.record, None, discovery.detail)
|
|
158
|
+
fit_recovery_dialog(self)
|
|
159
|
+
|
|
160
|
+
def set_observation(self, record: RewriteRecoveryRecord | None, observation: RecoveryObservation | None, detail: str = "") -> None:
|
|
161
|
+
"""Replace displayed facts atomically, keeping absent data explicitly unknown."""
|
|
162
|
+
self.record = record
|
|
163
|
+
self.observation = observation
|
|
164
|
+
self.restore_button.setEnabled(can_restore(record, observation))
|
|
165
|
+
lines = [(RecoveryText.BODY if record is not None else RecoveryText.MISSING).value, f"{RecoveryText.REPOSITORY.value}: {safe_text(self.repository_dir)}",
|
|
166
|
+
f"{RecoveryText.DIRECTORY.value}: {safe_text(self.operation_dir)}"]
|
|
167
|
+
if record is not None:
|
|
168
|
+
lines.extend([f"{RecoveryText.STATE.value}: {record.state.value}",
|
|
169
|
+
f"{RecoveryText.ORIGINAL_INDEX.value}: {record.original_index_identity or RecoveryText.ABSENT.value}",
|
|
170
|
+
f"{RecoveryText.CANDIDATE_INDEX.value}: {record.candidate_index_identity or RecoveryText.NOT_RECORDED.value}", safe_text(record.detail, multiline=True)])
|
|
171
|
+
if observation is None:
|
|
172
|
+
lines.append(RecoveryText.UNKNOWN.value)
|
|
173
|
+
else:
|
|
174
|
+
lines.extend([f"{RecoveryText.HEAD.value}: {safe_text(observation.head_ref) if observation.head_ref else RecoveryText.ABSENT.value}",
|
|
175
|
+
f"{RecoveryText.INDEX.value}: {observation.index_identity or RecoveryText.ABSENT.value}",
|
|
176
|
+
f"{RecoveryText.LOCK.value}: {observation.lock_identity or RecoveryText.ABSENT.value}", safe_text(observation.detail, multiline=True)])
|
|
177
|
+
if not can_restore(record, observation):
|
|
178
|
+
state = record.state if record is not None else None
|
|
179
|
+
restored = (state == RewritePublicationState.RESTORED and observation.ref_oid_by_name == record.old_oid_by_ref
|
|
180
|
+
and observation.index_identity == record.original_index_identity and not observation.detail)
|
|
181
|
+
published = (record is not None and not observation.detail and observation.ref_oid_by_name == record.new_oid_by_ref
|
|
182
|
+
and observation.index_identity == record.candidate_index_identity and record.candidate_index_identity is not None)
|
|
183
|
+
previous = (record is not None and not observation.detail and observation.ref_oid_by_name == record.old_oid_by_ref
|
|
184
|
+
and observation.index_identity == record.original_index_identity)
|
|
185
|
+
lines.append((RecoveryText.RESTORED if restored else
|
|
186
|
+
RecoveryText.COMPLETE if state == RewritePublicationState.COMPLETE else RecoveryText.OBSERVED_PUBLISHED if published else
|
|
187
|
+
RecoveryText.OBSERVED_PREVIOUS if previous else RecoveryText.UNAVAILABLE).value)
|
|
188
|
+
lines.append(safe_text(detail, multiline=True))
|
|
189
|
+
name_set = set(record.old_oid_by_ref) | set(record.new_oid_by_ref) if record is not None else set()
|
|
190
|
+
if observation is not None:
|
|
191
|
+
name_set.update(observation.ref_oid_by_name)
|
|
192
|
+
self.branch_table.setRowCount(len(name_set))
|
|
193
|
+
for row_index, name in enumerate(sorted(name_set)):
|
|
194
|
+
values = [safe_text(name), record.old_oid_by_ref.get(name, RecoveryText.NOT_RECORDED.value) if record else RecoveryText.NOT_RECORDED.value,
|
|
195
|
+
record.new_oid_by_ref.get(name, RecoveryText.NOT_RECORDED.value) if record else RecoveryText.NOT_RECORDED.value,
|
|
196
|
+
observation.ref_oid_by_name.get(name, RecoveryText.ABSENT.value) if observation else RecoveryText.UNKNOWN.value]
|
|
197
|
+
lines.append("\t".join(values))
|
|
198
|
+
for column_index, value in enumerate(values):
|
|
199
|
+
self.branch_table.setItem(row_index, column_index, QTableWidgetItem(value))
|
|
200
|
+
self.branch_table.resizeColumnsToContents()
|
|
201
|
+
self.details.setPlainText("\n".join(lines))
|
|
202
|
+
|
|
203
|
+
def set_loading(self) -> None:
|
|
204
|
+
"""Revoke restoration while current facts are being reread."""
|
|
205
|
+
self.set_observation(self.record, None, RecoveryText.LOADING.value)
|
|
206
|
+
|
|
207
|
+
def set_error(self, detail: str) -> None:
|
|
208
|
+
"""Keep the recorded facts but revoke an observation that could not be refreshed."""
|
|
209
|
+
self.set_observation(self.record, None, detail)
|
|
210
|
+
|
|
211
|
+
def copy_details(self) -> None:
|
|
212
|
+
"""Copy all displayed evidence, including every full branch identity."""
|
|
213
|
+
QApplication.clipboard().setText(self.details.toPlainText())
|
|
214
|
+
|
|
215
|
+
def show_modal(self) -> None:
|
|
216
|
+
"""Open the diagnostic through the shared native modality policy."""
|
|
217
|
+
open_window_modal(self)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class RestoreBranchesQuestion(QDialog):
|
|
221
|
+
"""Request one explicit consent to restore the exact observed branch set."""
|
|
222
|
+
|
|
223
|
+
restore_requested = Signal()
|
|
224
|
+
|
|
225
|
+
def __init__(self, repository_dir: str, record: RewriteRecoveryRecord, observation: RecoveryObservation, parent: QWidget | None = None) -> None:
|
|
226
|
+
"""Show complete current/previous tips with Keep current state as the default."""
|
|
227
|
+
super().__init__(parent)
|
|
228
|
+
self.setWindowTitle(RestoreText.TITLE.value)
|
|
229
|
+
self.details = QPlainTextEdit()
|
|
230
|
+
self.details.setReadOnly(True)
|
|
231
|
+
valid = can_restore(record, observation)
|
|
232
|
+
self.details.setPlainText("\n".join((RestoreText.BODY.value if valid else RecoveryText.MISSING.value,
|
|
233
|
+
f"{RecoveryText.REPOSITORY.value}: {safe_text(repository_dir)}", RestoreText.INDEX.value if valid else RecoveryText.UNKNOWN.value)))
|
|
234
|
+
rows = []
|
|
235
|
+
for name in sorted(record.old_oid_by_ref):
|
|
236
|
+
rows.append([safe_text(name), observation.ref_oid_by_name.get(name, RecoveryText.UNKNOWN.value), record.old_oid_by_ref[name]])
|
|
237
|
+
self.branch_table = branch_table([RestoreText.BRANCH.value, RestoreText.CURRENT.value, RestoreText.PREVIOUS.value], rows, self)
|
|
238
|
+
self.restore_button = QPushButton(RestoreText.RESTORE.value)
|
|
239
|
+
self.restore_button.setAutoDefault(False)
|
|
240
|
+
self.restore_button.setEnabled(can_restore(record, observation))
|
|
241
|
+
self.keep_button = QPushButton(RestoreText.KEEP.value)
|
|
242
|
+
self.keep_button.setAutoDefault(False)
|
|
243
|
+
self.keep_button.setDefault(True)
|
|
244
|
+
self.keep_button.clicked.connect(self.reject)
|
|
245
|
+
self.restore_button.clicked.connect(self._confirm)
|
|
246
|
+
body = QVBoxLayout()
|
|
247
|
+
body.addWidget(self.details)
|
|
248
|
+
body.addWidget(self.branch_table)
|
|
249
|
+
self.button_band = install_banded_layout(self, body, [self.restore_button, self.keep_button])
|
|
250
|
+
fit_recovery_dialog(self)
|
|
251
|
+
|
|
252
|
+
def _confirm(self) -> None:
|
|
253
|
+
"""Consume the explicit choice before any connected operation can start."""
|
|
254
|
+
if not self.restore_button.isEnabled():
|
|
255
|
+
return
|
|
256
|
+
self.restore_button.setEnabled(False)
|
|
257
|
+
self.accept()
|
|
258
|
+
self.restore_requested.emit()
|
|
259
|
+
|
|
260
|
+
def show_modal(self) -> None:
|
|
261
|
+
"""Open the confirmation through the shared native modality policy."""
|
|
262
|
+
open_window_modal(self)
|
|
File without changes
|