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,532 @@
|
|
|
1
|
+
"""Present the unresolved paths of a repository, with the questions their resolution asks the user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QEvent, QItemSelectionModel, Qt, Signal
|
|
8
|
+
from PySide6.QtWidgets import QAbstractItemView, QCheckBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QScrollArea, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
|
9
|
+
|
|
10
|
+
from gitpane.config import FIRST_ROW, OPERATION_HEIGHT, OPERATION_WIDTH, control_height
|
|
11
|
+
from gitpane.operation.diagnostic import SensitiveTextFilter
|
|
12
|
+
from gitpane.preference.preference_store import CONFLICT_PROMPT_PREFERENCE_KEY
|
|
13
|
+
from gitpane.sequencer.conflict import ConflictEntry, ConflictSelection, ConflictState, conflict_count_text, conflict_path_label, has_deleted_side
|
|
14
|
+
from gitpane.ui.widget.dialog_layout import fit_form
|
|
15
|
+
from gitpane.ui.widget.long_text import WrappingLabel
|
|
16
|
+
from gitpane.ui.widget.result_label import ResultLabel
|
|
17
|
+
from gitpane.worktree.worktree_snapshot import CONFLICT_ROLE_BY_STAGE
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from PySide6.QtCore import QObject, QRect
|
|
21
|
+
|
|
22
|
+
from gitpane.preference.preference_store import PreferenceStore
|
|
23
|
+
from gitpane.reference.stash import StashObservation
|
|
24
|
+
from gitpane.sequencer.conflict import ConflictMarkerLine
|
|
25
|
+
from gitpane.sequencer.rebase_state import RebaseSnapshot
|
|
26
|
+
|
|
27
|
+
CONFLICT_PATH_COLUMN = 0
|
|
28
|
+
CONFLICT_KIND_COLUMN = 1
|
|
29
|
+
CONFLICT_STATE_COLUMN = 2
|
|
30
|
+
DETAIL_HEIGHT_DIVISOR = 3
|
|
31
|
+
MESSAGE_VISIBLE_LINES = 4
|
|
32
|
+
PRIMARY_ACTION_ROW, SECONDARY_ACTION_ROW = 0, 1
|
|
33
|
+
STOP_ACTION_ROW = 2
|
|
34
|
+
FIRST_ACTION_COLUMN, SECOND_ACTION_COLUMN, LAST_ACTION_COLUMN = 0, 1, 2
|
|
35
|
+
# A file with many conflicts would otherwise give a warning longer than its window; the first lines are enough to find them.
|
|
36
|
+
SHOWN_MARKER_LINE_LIMIT = 12
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def conflict_marker_warning(label: str, marker_lines: tuple[ConflictMarkerLine, ...]) -> str:
|
|
40
|
+
"""Tell which lines of a file still hold conflict markers and what staging it anyway would do."""
|
|
41
|
+
line_texts = []
|
|
42
|
+
for marker_line in marker_lines[:SHOWN_MARKER_LINE_LIMIT]:
|
|
43
|
+
line_texts.append(str(marker_line.line_number))
|
|
44
|
+
hidden_count = len(marker_lines) - len(line_texts)
|
|
45
|
+
if hidden_count > 0:
|
|
46
|
+
line_texts.append(f"and {hidden_count} more")
|
|
47
|
+
line_word = "line" if len(marker_lines) == 1 else "lines"
|
|
48
|
+
return (f"'{label}' still contains conflict markers ({line_word} {', '.join(line_texts)}). Mark it as solved anyway? "
|
|
49
|
+
"The markers will be committed as they are.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def conflict_rows_signature(state: ConflictState) -> tuple[object, ...]:
|
|
53
|
+
"""Tell what the rows of the conflict list show of a state; two states with the same signature give the same rows."""
|
|
54
|
+
return (state.entries, state.fresh)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rebase_role_signature(state: RebaseSnapshot | None) -> tuple[object, ...] | None:
|
|
58
|
+
"""Tell what the conflict rows and details show of a rebase state; two states with the same signature describe the conflicts the same way."""
|
|
59
|
+
if state is None:
|
|
60
|
+
return None
|
|
61
|
+
return (state.key, state.identity, state.current_step)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConflictDialog(QDialog):
|
|
65
|
+
"""Keep a selectable list open independently of repository browsing."""
|
|
66
|
+
|
|
67
|
+
refresh_requested = Signal()
|
|
68
|
+
mark_solved_requested = Signal(object)
|
|
69
|
+
delete_requested = Signal(object)
|
|
70
|
+
open_tool_requested = Signal(object)
|
|
71
|
+
settings_requested = Signal()
|
|
72
|
+
stop_series_requested = Signal()
|
|
73
|
+
|
|
74
|
+
def __init__(self, state: ConflictState, parent: QWidget | None = None) -> None:
|
|
75
|
+
"""Build a modeless view with exact path identities and permanent refresh access."""
|
|
76
|
+
super().__init__(parent)
|
|
77
|
+
self.state = state
|
|
78
|
+
self.rebase_state: RebaseSnapshot | None = None
|
|
79
|
+
self.shown_row_signature: tuple[object, ...] | None = None
|
|
80
|
+
self.shown_rebase_role_signature: tuple[object, ...] | None = None
|
|
81
|
+
self.busy = False
|
|
82
|
+
self.write_diagnostic = ""
|
|
83
|
+
self.scanning = False
|
|
84
|
+
self.scan_complete = False
|
|
85
|
+
self.merge_series_id = ""
|
|
86
|
+
# The whole list is selected once, when its first conflicts appear, so the series starts in one click; after that
|
|
87
|
+
# the selection is the user's and publications keep it.
|
|
88
|
+
self.initial_selection_done = False
|
|
89
|
+
repository_label = parent.windowTitle() if parent is not None else state.key.git_dir
|
|
90
|
+
self.setWindowTitle("Resolve merge conflicts — " + repository_label)
|
|
91
|
+
self.resize(OPERATION_WIDTH, OPERATION_HEIGHT)
|
|
92
|
+
layout = QVBoxLayout(self)
|
|
93
|
+
self.message_scroll = QScrollArea()
|
|
94
|
+
self.message_scroll.setWidgetResizable(True)
|
|
95
|
+
self.message_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
96
|
+
self.message_scroll.setAccessibleName("Conflict status and diagnostics")
|
|
97
|
+
message_container = QWidget()
|
|
98
|
+
message_layout = QVBoxLayout(message_container)
|
|
99
|
+
message_layout.setContentsMargins(0, 0, 0, 0)
|
|
100
|
+
self.status_label = WrappingLabel()
|
|
101
|
+
self.status_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
102
|
+
self.status_label.setWordWrap(True)
|
|
103
|
+
message_layout.addWidget(self.status_label)
|
|
104
|
+
self.retention_label = WrappingLabel()
|
|
105
|
+
self.retention_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
106
|
+
self.retention_label.setWordWrap(True)
|
|
107
|
+
message_layout.addWidget(self.retention_label)
|
|
108
|
+
# Plain, wrapped text selectable by mouse and keyboard, which can show a refusal in the warning tone.
|
|
109
|
+
self.error_label = ResultLabel()
|
|
110
|
+
self.error_label.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
111
|
+
self.error_label.setAccessibleName("Conflict diagnostic and recovery path")
|
|
112
|
+
self.error_label.installEventFilter(self)
|
|
113
|
+
message_layout.addWidget(self.error_label)
|
|
114
|
+
self.message_scroll.setWidget(message_container)
|
|
115
|
+
layout.addWidget(self.message_scroll)
|
|
116
|
+
self.conflict_list = QTreeWidget()
|
|
117
|
+
self.conflict_list.setHeaderLabels(["Filename", "Conflict type", "State"])
|
|
118
|
+
self.conflict_list.setRootIsDecorated(False)
|
|
119
|
+
self.conflict_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
120
|
+
self.conflict_list.setAccessibleName("Unresolved merge conflicts")
|
|
121
|
+
self.conflict_list.itemSelectionChanged.connect(self._show_selection)
|
|
122
|
+
layout.addWidget(self.conflict_list, 1)
|
|
123
|
+
self.explanation_label = QPlainTextEdit()
|
|
124
|
+
self.explanation_label.setReadOnly(True)
|
|
125
|
+
self.explanation_label.setTabChangesFocus(True)
|
|
126
|
+
self.explanation_label.setAccessibleName("Selected conflict details")
|
|
127
|
+
self.explanation_label.setMaximumHeight(OPERATION_HEIGHT // DETAIL_HEIGHT_DIVISOR)
|
|
128
|
+
layout.addWidget(self.explanation_label)
|
|
129
|
+
button_layout = QGridLayout()
|
|
130
|
+
self.refresh_button = QPushButton("Rescan merge conflicts")
|
|
131
|
+
self.refresh_button.clicked.connect(self.refresh_requested.emit)
|
|
132
|
+
self.mark_solved_button = QPushButton("Mark conflict as solved")
|
|
133
|
+
self.mark_solved_button.clicked.connect(lambda: self.mark_solved_requested.emit(self.selection()))
|
|
134
|
+
self.delete_button = QPushButton("Delete file")
|
|
135
|
+
self.delete_button.clicked.connect(lambda: self.delete_requested.emit(self.selection()))
|
|
136
|
+
self.open_tool_button = QPushButton("Solve conflicts in KDiff3")
|
|
137
|
+
self.open_tool_button.setAccessibleName(self.open_tool_button.text())
|
|
138
|
+
self.open_tool_button.setToolTip("Process selected files in order: open KDiff3 for content conflicts, or ask to keep/delete a file when one side is deleted")
|
|
139
|
+
self.open_tool_button.clicked.connect(self.open_selected_tool)
|
|
140
|
+
self.conflict_list.itemDoubleClicked.connect(lambda _item, _column: self.open_selected_tool())
|
|
141
|
+
self.settings_button = QPushButton("Open Settings")
|
|
142
|
+
self.settings_button.clicked.connect(self.settings_requested.emit)
|
|
143
|
+
self.settings_button.hide()
|
|
144
|
+
self.stop_series_button = QPushButton("Stop series")
|
|
145
|
+
self.stop_series_button.clicked.connect(self.stop_series_requested.emit)
|
|
146
|
+
self.stop_series_button.hide()
|
|
147
|
+
self.close_button = QPushButton("Close")
|
|
148
|
+
self.close_button.clicked.connect(self.close)
|
|
149
|
+
button_layout.addWidget(self.refresh_button, PRIMARY_ACTION_ROW, FIRST_ACTION_COLUMN)
|
|
150
|
+
button_layout.addWidget(self.mark_solved_button, PRIMARY_ACTION_ROW, SECOND_ACTION_COLUMN)
|
|
151
|
+
button_layout.addWidget(self.open_tool_button, PRIMARY_ACTION_ROW, LAST_ACTION_COLUMN)
|
|
152
|
+
button_layout.addWidget(self.delete_button, SECONDARY_ACTION_ROW, SECOND_ACTION_COLUMN)
|
|
153
|
+
button_layout.addWidget(self.settings_button, SECONDARY_ACTION_ROW, FIRST_ACTION_COLUMN)
|
|
154
|
+
button_layout.addWidget(self.stop_series_button, STOP_ACTION_ROW, SECOND_ACTION_COLUMN)
|
|
155
|
+
button_layout.addWidget(self.close_button, SECONDARY_ACTION_ROW, LAST_ACTION_COLUMN)
|
|
156
|
+
layout.addLayout(button_layout)
|
|
157
|
+
self.show_state(state)
|
|
158
|
+
self.refresh_metrics()
|
|
159
|
+
|
|
160
|
+
def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
|
|
161
|
+
"""Keep native text and controls usable after screen or font changes."""
|
|
162
|
+
for column in (CONFLICT_PATH_COLUMN, CONFLICT_KIND_COLUMN, CONFLICT_STATE_COLUMN):
|
|
163
|
+
self.conflict_list.resizeColumnToContents(column)
|
|
164
|
+
self.message_scroll.setMaximumHeight(control_height(self.fontMetrics().height()) * MESSAGE_VISIBLE_LINES)
|
|
165
|
+
fit_form(self, available_geometry)
|
|
166
|
+
|
|
167
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
168
|
+
"""Reveal the selectable recovery diagnostic when keyboard navigation reaches it."""
|
|
169
|
+
if watched is self.error_label and event.type() == QEvent.Type.FocusIn:
|
|
170
|
+
self.message_scroll.ensureWidgetVisible(self.error_label)
|
|
171
|
+
return super().eventFilter(watched, event)
|
|
172
|
+
|
|
173
|
+
def selection(self) -> ConflictSelection:
|
|
174
|
+
"""Capture selected raw paths in explicit order with their observation identity."""
|
|
175
|
+
paths = []
|
|
176
|
+
for item in self.conflict_list.selectedItems():
|
|
177
|
+
paths.append(item.data(CONFLICT_PATH_COLUMN, Qt.ItemDataRole.UserRole))
|
|
178
|
+
return ConflictSelection(self.state.key, self.state.generation, tuple(sorted(paths)))
|
|
179
|
+
|
|
180
|
+
def show_state(self, state: ConflictState) -> None:
|
|
181
|
+
"""Retain the latest state, reconcile selection by raw path and preserve unknown rows after read failures.
|
|
182
|
+
|
|
183
|
+
The rows are rebuilt only when the conflicts or their freshness changed.
|
|
184
|
+
"""
|
|
185
|
+
selected_path_set = set(self.selection().paths)
|
|
186
|
+
self.state = state
|
|
187
|
+
# The retained read number decides whether a click is still current, so it follows every publication. The rows
|
|
188
|
+
# only change with the conflicts or their freshness; rebuilding them anyway would lose the scroll position and
|
|
189
|
+
# set the selection again for nothing.
|
|
190
|
+
signature = conflict_rows_signature(state)
|
|
191
|
+
if signature != self.shown_row_signature:
|
|
192
|
+
self.shown_row_signature = signature
|
|
193
|
+
self.conflict_list.clear()
|
|
194
|
+
for entry in state.entries:
|
|
195
|
+
kind, explanation = self._describe_entry(entry)
|
|
196
|
+
item = QTreeWidgetItem([conflict_path_label(entry.path), kind, "Unresolved" if state.fresh else "Unknown; last known conflict"])
|
|
197
|
+
item.setData(CONFLICT_PATH_COLUMN, Qt.ItemDataRole.UserRole, entry.path)
|
|
198
|
+
item.setToolTip(CONFLICT_KIND_COLUMN, explanation)
|
|
199
|
+
self.conflict_list.addTopLevelItem(item)
|
|
200
|
+
item.setSelected(entry.path in selected_path_set)
|
|
201
|
+
for column in (CONFLICT_PATH_COLUMN, CONFLICT_KIND_COLUMN, CONFLICT_STATE_COLUMN):
|
|
202
|
+
self.conflict_list.resizeColumnToContents(column)
|
|
203
|
+
self._select_first_conflicts()
|
|
204
|
+
count = len(state.entries)
|
|
205
|
+
message = "No unresolved merge conflicts. Return to the repository to review the staged changes."
|
|
206
|
+
if count:
|
|
207
|
+
message = conflict_count_text(count)
|
|
208
|
+
if self.scan_complete:
|
|
209
|
+
message = "Scan complete. " + message + ". Select a file and choose its resolution."
|
|
210
|
+
if self.scanning:
|
|
211
|
+
message = "Rescanning merge conflicts..."
|
|
212
|
+
self.status_label.setText(message if state.fresh or self.scanning else "Conflict state is unknown. " + state.diagnostic)
|
|
213
|
+
if not state.fresh:
|
|
214
|
+
self.show_retention(None)
|
|
215
|
+
self._show_selection()
|
|
216
|
+
|
|
217
|
+
def _select_first_conflicts(self) -> None:
|
|
218
|
+
"""Select every row the first time the list shows conflicts, and start over once it is emptied.
|
|
219
|
+
|
|
220
|
+
An emptied list holds no choice of the user's, and the conflicts that appear next, such as those of the next
|
|
221
|
+
replayed commit, are a new series.
|
|
222
|
+
"""
|
|
223
|
+
if not self.state.entries:
|
|
224
|
+
self.initial_selection_done = False
|
|
225
|
+
return
|
|
226
|
+
if self.initial_selection_done:
|
|
227
|
+
return
|
|
228
|
+
self.initial_selection_done = True
|
|
229
|
+
self.conflict_list.setCurrentItem(self.conflict_list.topLevelItem(FIRST_ROW), CONFLICT_PATH_COLUMN, QItemSelectionModel.SelectionFlag.NoUpdate)
|
|
230
|
+
self.conflict_list.selectAll()
|
|
231
|
+
self.conflict_list.setFocus()
|
|
232
|
+
|
|
233
|
+
def show_write_availability(self, diagnostic: str) -> None:
|
|
234
|
+
"""Apply the shared write refusal without replacing the current selection or scan; the same refusal again changes nothing."""
|
|
235
|
+
if diagnostic == self.write_diagnostic:
|
|
236
|
+
return
|
|
237
|
+
self.write_diagnostic = diagnostic
|
|
238
|
+
self._show_selection()
|
|
239
|
+
|
|
240
|
+
def _show_selection(self) -> None:
|
|
241
|
+
"""Explain selected special modes and show every available role without invented sides."""
|
|
242
|
+
selected_path_set = set(self.selection().paths)
|
|
243
|
+
selected_entries = []
|
|
244
|
+
for entry in self.state.entries:
|
|
245
|
+
if entry.path in selected_path_set:
|
|
246
|
+
selected_entries.append(entry)
|
|
247
|
+
all_deletions = bool(selected_entries) and all(has_deleted_side(entry.sides) for entry in selected_entries)
|
|
248
|
+
actionable = bool(selected_path_set) and self.state.fresh and not self.busy and not self.scanning and not self.write_diagnostic
|
|
249
|
+
self.mark_solved_button.setEnabled(actionable)
|
|
250
|
+
self.mark_solved_button.setText("Keep modified file" if all_deletions else "Mark conflict as solved")
|
|
251
|
+
self.open_tool_button.setEnabled(actionable)
|
|
252
|
+
self.mark_solved_button.setToolTip(self.write_diagnostic)
|
|
253
|
+
self.open_tool_button.setToolTip(self.write_diagnostic or "Open the selected conflict in the configured merge tool")
|
|
254
|
+
self.delete_button.setEnabled(actionable and all_deletions)
|
|
255
|
+
self.delete_button.setToolTip("Delete the selected file and stage its deletion" if all_deletions else "Select only conflicts with a deleted side")
|
|
256
|
+
descriptions = []
|
|
257
|
+
for entry in self.state.entries:
|
|
258
|
+
if entry.path not in selected_path_set:
|
|
259
|
+
continue
|
|
260
|
+
_kind, explanation = self._describe_entry(entry)
|
|
261
|
+
parts = [conflict_path_label(entry.path) + ": " + explanation]
|
|
262
|
+
if has_deleted_side(entry.sides):
|
|
263
|
+
parts.extend(("Keep modified file: stage the current working file.", "Delete file: remove the working file and stage its deletion.",
|
|
264
|
+
"Solve conflicts in KDiff3 asks whether to keep or delete this file, then proceeds to the next selected file. Rescan only updates this list."))
|
|
265
|
+
rebase = self.rebase_state
|
|
266
|
+
if rebase is not None and rebase.key == self.state.key and rebase.identity is not None:
|
|
267
|
+
parts.extend(("During rebase:", "BASE: base of the replayed change", "LOCAL: result already built on the target",
|
|
268
|
+
"REMOTE: commit being replayed", "MERGED: saved result to stage"))
|
|
269
|
+
if rebase.current_step is not None:
|
|
270
|
+
parts.append(rebase.current_step.oid + " " + rebase.current_step.message)
|
|
271
|
+
side_by_role = {}
|
|
272
|
+
for side in entry.sides:
|
|
273
|
+
side_by_role[CONFLICT_ROLE_BY_STAGE[side.stage]] = side
|
|
274
|
+
for role in CONFLICT_ROLE_BY_STAGE.values():
|
|
275
|
+
side = side_by_role.get(role)
|
|
276
|
+
parts.append(role + ": " + (f"mode {side.mode}, object {side.oid}" if side is not None else "absent"))
|
|
277
|
+
descriptions.append("\n".join(parts))
|
|
278
|
+
self.explanation_label.setPlainText("\n\n".join(descriptions) if descriptions else "Select a conflict to inspect its versions and choose a resolved result.")
|
|
279
|
+
|
|
280
|
+
def show_rebase_state(self, state: RebaseSnapshot | None) -> None:
|
|
281
|
+
"""Explain replay roles without changing the shared index-stage or tool argument order.
|
|
282
|
+
|
|
283
|
+
The latest state is always retained; the rows and details are written again only when what they show of it changed.
|
|
284
|
+
"""
|
|
285
|
+
self.rebase_state = state
|
|
286
|
+
signature = rebase_role_signature(state)
|
|
287
|
+
if signature == self.shown_rebase_role_signature:
|
|
288
|
+
return
|
|
289
|
+
self.shown_rebase_role_signature = signature
|
|
290
|
+
entry_by_path = {}
|
|
291
|
+
for entry in self.state.entries:
|
|
292
|
+
entry_by_path[entry.path] = entry
|
|
293
|
+
for row in range(self.conflict_list.topLevelItemCount()):
|
|
294
|
+
item = self.conflict_list.topLevelItem(row)
|
|
295
|
+
entry = entry_by_path.get(item.data(CONFLICT_PATH_COLUMN, Qt.ItemDataRole.UserRole))
|
|
296
|
+
if entry is not None:
|
|
297
|
+
kind, explanation = self._describe_entry(entry)
|
|
298
|
+
item.setText(CONFLICT_KIND_COLUMN, kind)
|
|
299
|
+
item.setToolTip(CONFLICT_KIND_COLUMN, explanation)
|
|
300
|
+
self.conflict_list.resizeColumnToContents(CONFLICT_KIND_COLUMN)
|
|
301
|
+
self._show_selection()
|
|
302
|
+
|
|
303
|
+
def _describe_entry(self, entry: ConflictEntry) -> tuple[str, str]:
|
|
304
|
+
"""Name the actual deletion during replay without implying a remote server or another person."""
|
|
305
|
+
rebase = self.rebase_state
|
|
306
|
+
if rebase is None or rebase.key != self.state.key or rebase.identity is None or not has_deleted_side(entry.sides):
|
|
307
|
+
return entry.kind, entry.explanation
|
|
308
|
+
role_set = set()
|
|
309
|
+
for side in entry.sides:
|
|
310
|
+
role_set.add(CONFLICT_ROLE_BY_STAGE[side.stage])
|
|
311
|
+
if "LOCAL" in role_set:
|
|
312
|
+
return "Deleted by replayed commit", "The rebuilt history contains the modified file; the commit being replayed deletes it. Choose whether to keep the file or apply the deletion."
|
|
313
|
+
if "REMOTE" in role_set:
|
|
314
|
+
return "Deleted in rebuilt history", "The file is already absent from the rebuilt history; the commit being replayed modifies it. Choose whether to keep the file or keep it deleted."
|
|
315
|
+
return entry.kind, entry.explanation
|
|
316
|
+
|
|
317
|
+
def begin_rescan(self) -> None:
|
|
318
|
+
"""Acknowledge an explicit refresh while its shared read is pending."""
|
|
319
|
+
self.scanning = True
|
|
320
|
+
self.scan_complete = False
|
|
321
|
+
self.refresh_button.setText("Rescanning...")
|
|
322
|
+
self.refresh_button.setEnabled(False)
|
|
323
|
+
self.status_label.setText("Rescanning merge conflicts...")
|
|
324
|
+
self._show_selection()
|
|
325
|
+
|
|
326
|
+
def finish_rescan(self) -> None:
|
|
327
|
+
"""Retain visible completion feedback after the shared read succeeds or fails."""
|
|
328
|
+
if not self.scanning:
|
|
329
|
+
return
|
|
330
|
+
self.scanning = False
|
|
331
|
+
self.scan_complete = self.state.fresh
|
|
332
|
+
self.refresh_button.setText("Rescan merge conflicts")
|
|
333
|
+
self.refresh_button.setEnabled(True)
|
|
334
|
+
self.show_state(self.state)
|
|
335
|
+
|
|
336
|
+
def set_busy(self, busy: bool) -> None:
|
|
337
|
+
"""Prevent a second mutation while keeping the list and refresh available."""
|
|
338
|
+
self.busy = busy
|
|
339
|
+
if busy:
|
|
340
|
+
self.scan_complete = False
|
|
341
|
+
self._show_selection()
|
|
342
|
+
|
|
343
|
+
def show_mergetool_result(self, message: str, *, settings_available: bool) -> None:
|
|
344
|
+
"""Show what the merge tool run ended with, and offer Open Settings when changing the tool there is the way out.
|
|
345
|
+
|
|
346
|
+
A result that offers Settings is a refusal of the tool itself (not configured, not found, not executable, or
|
|
347
|
+
not started): it reads bold orange so it is not missed, while any other result keeps the ordinary look.
|
|
348
|
+
"""
|
|
349
|
+
if settings_available:
|
|
350
|
+
self.error_label.show_warning(message)
|
|
351
|
+
else:
|
|
352
|
+
self.error_label.setText(message)
|
|
353
|
+
self.settings_button.setVisible(settings_available)
|
|
354
|
+
|
|
355
|
+
def open_selected_tool(self) -> None:
|
|
356
|
+
"""Route button and double-click through the same captured raw path intention."""
|
|
357
|
+
if self.open_tool_button.isEnabled():
|
|
358
|
+
self.open_tool_requested.emit(self.selection())
|
|
359
|
+
|
|
360
|
+
def show_retention(self, observation: StashObservation | None) -> None:
|
|
361
|
+
"""Name a retained stash only when a current read establishes its presence."""
|
|
362
|
+
if observation is None or observation.retained is None:
|
|
363
|
+
self.retention_label.setText("Stash retention has not been verified." if observation is not None else "")
|
|
364
|
+
return
|
|
365
|
+
text = ""
|
|
366
|
+
if observation.retained and observation.retained_oid is not None and observation.observed_at is not None:
|
|
367
|
+
text = f"Stash {observation.retained_oid} is retained (checked {observation.observed_at.isoformat(timespec='seconds')})."
|
|
368
|
+
self.retention_label.setText(text)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class ConflictConfirmation(QDialog):
|
|
372
|
+
"""Ask about one exact working result while preserving a clear non-writing exit.
|
|
373
|
+
|
|
374
|
+
Marking a conflict solved only opens it for a file that still holds conflict markers or for a deletion: the click on
|
|
375
|
+
Mark conflict as solved or Keep modified file already is the user's choice for a cleaned file, whereas a deletion
|
|
376
|
+
removes a working file or stages something other than what Keep modified file says.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(self, path: bytes, parent: QWidget | None = None, *, delete: bool = False, remove_existing: bool = False,
|
|
380
|
+
marker_lines: tuple[ConflictMarkerLine, ...] = ()) -> None:
|
|
381
|
+
"""Name the captured path and distinguish a deletion from an existing empty file.
|
|
382
|
+
|
|
383
|
+
When the accepted bytes still hold conflict markers, the question becomes a warning that names their lines, and
|
|
384
|
+
only an explicit click on the confirm button stages them: Enter and the focus stay on Keep unresolved.
|
|
385
|
+
"""
|
|
386
|
+
super().__init__(parent)
|
|
387
|
+
self.setWindowTitle("Stage resolved deletion" if delete else "Mark conflict as solved")
|
|
388
|
+
layout = QVBoxLayout(self)
|
|
389
|
+
label = conflict_path_label(path)
|
|
390
|
+
message = f"Keep '{label}' deleted and stage that deletion as the conflict resolution?" if delete else f"Use the current working file '{label}' as the resolved result and stage it?"
|
|
391
|
+
if remove_existing:
|
|
392
|
+
self.setWindowTitle("Delete conflicted file")
|
|
393
|
+
message = f"Delete the working file '{label}' and stage its deletion as the conflict resolution? Earlier commits keep their file content."
|
|
394
|
+
self.message_label = ResultLabel()
|
|
395
|
+
if marker_lines:
|
|
396
|
+
self.message_label.show_warning(conflict_marker_warning(label, marker_lines))
|
|
397
|
+
else:
|
|
398
|
+
self.message_label.setText(message)
|
|
399
|
+
layout.addWidget(self.message_label)
|
|
400
|
+
button_layout = QHBoxLayout()
|
|
401
|
+
self.confirm_button = QPushButton("Stage deletion" if delete else "Mark conflict as solved")
|
|
402
|
+
if remove_existing:
|
|
403
|
+
self.confirm_button.setText("Delete file")
|
|
404
|
+
# A risky answer is never the button Enter or the initial focus would press.
|
|
405
|
+
cautious = remove_existing or bool(marker_lines)
|
|
406
|
+
if cautious:
|
|
407
|
+
self.confirm_button.setAutoDefault(False)
|
|
408
|
+
self.decline_button = QPushButton("Keep unresolved")
|
|
409
|
+
self.confirm_button.clicked.connect(self.accept)
|
|
410
|
+
self.decline_button.clicked.connect(self.reject)
|
|
411
|
+
self.decline_button.setDefault(True)
|
|
412
|
+
if cautious:
|
|
413
|
+
self.decline_button.setFocus()
|
|
414
|
+
button_layout.addWidget(self.confirm_button)
|
|
415
|
+
button_layout.addWidget(self.decline_button)
|
|
416
|
+
layout.addLayout(button_layout)
|
|
417
|
+
self.resize(OPERATION_WIDTH, layout.totalHeightForWidth(OPERATION_WIDTH))
|
|
418
|
+
self.refresh_metrics()
|
|
419
|
+
|
|
420
|
+
def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
|
|
421
|
+
"""Keep the named result and both answers reachable with native font scaling."""
|
|
422
|
+
fit_form(self, available_geometry)
|
|
423
|
+
self.resize(self.width(), max(self.height(), self.layout().totalHeightForWidth(self.width())))
|
|
424
|
+
fit_form(self, available_geometry)
|
|
425
|
+
|
|
426
|
+
class MergeConfirmation(ConflictConfirmation):
|
|
427
|
+
"""Ask separately about missing historical input and an ambiguous saved output."""
|
|
428
|
+
|
|
429
|
+
def __init__(self, path: bytes, reason: str, parent: QWidget | None = None) -> None:
|
|
430
|
+
"""Keep both decisions explicit and provide the same non-writing decline action."""
|
|
431
|
+
super().__init__(path, parent)
|
|
432
|
+
label = conflict_path_label(path)
|
|
433
|
+
if reason == "missing-base":
|
|
434
|
+
self.setWindowTitle("Two-input merge")
|
|
435
|
+
self.message_label.setText(f"No base version exists for '{label}'. Open a two-input merge with LOCAL and REMOTE?")
|
|
436
|
+
self.confirm_button.setText("Open two-input merge")
|
|
437
|
+
else:
|
|
438
|
+
self.setWindowTitle("Use saved merge result")
|
|
439
|
+
self.message_label.setText(f"KDiff3 produced a result for '{label}', but success could not be confirmed. Use this result and stage it?")
|
|
440
|
+
self.confirm_button.setText("Use saved result")
|
|
441
|
+
self.refresh_metrics()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class DeletedFileDialog(QDialog):
|
|
445
|
+
"""Choose the resolved file during a mixed conflict series without opening a text merge tool."""
|
|
446
|
+
|
|
447
|
+
def __init__(self, path: bytes, parent: QWidget | None = None, *, keep_available: bool = True) -> None:
|
|
448
|
+
"""Name the current file and leave cancellation as the default keyboard choice."""
|
|
449
|
+
super().__init__(parent)
|
|
450
|
+
self.delete_result: bool | None = None
|
|
451
|
+
self.setWindowTitle("Resolve deleted file")
|
|
452
|
+
layout = QVBoxLayout(self)
|
|
453
|
+
self.message_label = WrappingLabel(f"'{conflict_path_label(path)}' has a deleted side. Keep the current working file or delete it? The chosen result will be staged, then the next selected file will be processed.")
|
|
454
|
+
self.message_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
455
|
+
self.message_label.setWordWrap(True)
|
|
456
|
+
layout.addWidget(self.message_label)
|
|
457
|
+
self.keep_button = QPushButton("Keep modified file")
|
|
458
|
+
self.keep_button.setEnabled(keep_available)
|
|
459
|
+
self.keep_button.setAutoDefault(False)
|
|
460
|
+
self.keep_button.setToolTip("Use the current working file" if keep_available else "The working file is already absent")
|
|
461
|
+
self.delete_button = QPushButton("Delete file")
|
|
462
|
+
self.delete_button.setAutoDefault(False)
|
|
463
|
+
self.cancel_button = QPushButton("Cancel")
|
|
464
|
+
self.cancel_button.setToolTip("Keep this conflict unresolved and stop processing the remaining selected files")
|
|
465
|
+
self.cancel_button.setDefault(True)
|
|
466
|
+
self.cancel_button.setFocus()
|
|
467
|
+
self.keep_button.clicked.connect(lambda: self.choose_result(False))
|
|
468
|
+
self.delete_button.clicked.connect(lambda: self.choose_result(True))
|
|
469
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
470
|
+
button_layout = QHBoxLayout()
|
|
471
|
+
for button in (self.keep_button, self.delete_button, self.cancel_button):
|
|
472
|
+
button_layout.addWidget(button)
|
|
473
|
+
layout.addLayout(button_layout)
|
|
474
|
+
self.resize(OPERATION_WIDTH, layout.totalHeightForWidth(OPERATION_WIDTH))
|
|
475
|
+
self.refresh_metrics()
|
|
476
|
+
|
|
477
|
+
def choose_result(self, delete: bool) -> None:
|
|
478
|
+
"""Remember only an explicit button choice for the still-captured file."""
|
|
479
|
+
self.delete_result = delete
|
|
480
|
+
self.accept()
|
|
481
|
+
|
|
482
|
+
def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
|
|
483
|
+
"""Keep the complete filename and every choice visible with native font scaling."""
|
|
484
|
+
fit_form(self, available_geometry)
|
|
485
|
+
self.resize(self.width(), max(self.height(), self.layout().totalHeightForWidth(self.width())))
|
|
486
|
+
fit_form(self, available_geometry)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class ConflictPrompt(QDialog):
|
|
490
|
+
"""Offer immediate resolution without removing access when the user declines."""
|
|
491
|
+
|
|
492
|
+
resolve_requested = Signal()
|
|
493
|
+
|
|
494
|
+
def __init__(self, preference: PreferenceStore, parent: QWidget | None = None) -> None:
|
|
495
|
+
"""Save suppression through the validated store before accepting either answer."""
|
|
496
|
+
super().__init__(parent)
|
|
497
|
+
self.preference = preference
|
|
498
|
+
self.setWindowTitle("Unresolved merge conflicts")
|
|
499
|
+
layout = QVBoxLayout(self)
|
|
500
|
+
label = QLabel("There are unresolved merge conflicts. Would you like to resolve them now?")
|
|
501
|
+
label.setWordWrap(True)
|
|
502
|
+
layout.addWidget(label)
|
|
503
|
+
self.dont_show_again = QCheckBox("Don't show again")
|
|
504
|
+
layout.addWidget(self.dont_show_again)
|
|
505
|
+
self.error_label = QLabel()
|
|
506
|
+
self.error_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
507
|
+
self.error_label.setWordWrap(True)
|
|
508
|
+
layout.addWidget(self.error_label)
|
|
509
|
+
button_layout = QHBoxLayout()
|
|
510
|
+
self.resolve_button = QPushButton("Resolve now")
|
|
511
|
+
self.decline_button = QPushButton("Not now")
|
|
512
|
+
self.resolve_button.clicked.connect(lambda: self._answer(True))
|
|
513
|
+
self.decline_button.clicked.connect(lambda: self._answer(False))
|
|
514
|
+
button_layout.addWidget(self.resolve_button)
|
|
515
|
+
button_layout.addWidget(self.decline_button)
|
|
516
|
+
layout.addLayout(button_layout)
|
|
517
|
+
self.refresh_metrics()
|
|
518
|
+
|
|
519
|
+
def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
|
|
520
|
+
"""Fit the proposal and its error text to the current screen."""
|
|
521
|
+
fit_form(self, available_geometry)
|
|
522
|
+
|
|
523
|
+
def _answer(self, resolve: bool) -> None:
|
|
524
|
+
"""Keep the proposal visible if its suppression choice cannot be saved."""
|
|
525
|
+
try:
|
|
526
|
+
self.preference.update({CONFLICT_PROMPT_PREFERENCE_KEY: self.dont_show_again.isChecked()})
|
|
527
|
+
except (OSError, ValueError, TypeError) as error:
|
|
528
|
+
self.error_label.setText("Could not save this choice. The prompt remains enabled; after fixing the settings file, choose an answer again. " + SensitiveTextFilter().redact(str(error)))
|
|
529
|
+
return
|
|
530
|
+
self.accept()
|
|
531
|
+
if resolve:
|
|
532
|
+
self.resolve_requested.emit()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Present retained merge files independently of the current conflict list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Signal
|
|
8
|
+
from PySide6.QtWidgets import QApplication, QDialog, QHBoxLayout, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget
|
|
9
|
+
|
|
10
|
+
from gitpane.config import OPERATION_HEIGHT, OPERATION_WIDTH, control_height
|
|
11
|
+
from gitpane.sequencer.conflict import conflict_path_label
|
|
12
|
+
from gitpane.ui.widget.dialog_layout import fit_form
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from PySide6.QtCore import QRect
|
|
16
|
+
|
|
17
|
+
from gitpane.sequencer.merge_recovery import MergeRecovery
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MergeRecoveryDialog(QDialog):
|
|
21
|
+
"""Offer copyable locations without any action that publishes retained bytes."""
|
|
22
|
+
|
|
23
|
+
refresh_requested = Signal()
|
|
24
|
+
|
|
25
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
26
|
+
"""Build a separate reader that remains usable with no current conflicts."""
|
|
27
|
+
super().__init__(parent)
|
|
28
|
+
self.read_generation = 0
|
|
29
|
+
self.setWindowTitle("Retained merge results" + (" — " + parent.windowTitle() if parent is not None else ""))
|
|
30
|
+
self.resize(OPERATION_WIDTH, OPERATION_HEIGHT)
|
|
31
|
+
layout = QVBoxLayout(self)
|
|
32
|
+
self.recovery_text = QPlainTextEdit()
|
|
33
|
+
self.recovery_text.setReadOnly(True)
|
|
34
|
+
self.recovery_text.setTabChangesFocus(True)
|
|
35
|
+
self.recovery_text.setAccessibleName("Retained merge results and recovery paths")
|
|
36
|
+
layout.addWidget(self.recovery_text, 1)
|
|
37
|
+
button_layout = QHBoxLayout()
|
|
38
|
+
self.refresh_button = QPushButton("Refresh")
|
|
39
|
+
self.refresh_button.clicked.connect(self.refresh_requested.emit)
|
|
40
|
+
self.copy_button = QPushButton("Copy details")
|
|
41
|
+
self.copy_button.clicked.connect(lambda: QApplication.clipboard().setText(self.recovery_text.toPlainText()))
|
|
42
|
+
self.close_button = QPushButton("Close")
|
|
43
|
+
self.close_button.clicked.connect(self.close)
|
|
44
|
+
for button in (self.refresh_button, self.copy_button, self.close_button):
|
|
45
|
+
button_layout.addWidget(button)
|
|
46
|
+
layout.addLayout(button_layout)
|
|
47
|
+
self.refresh_metrics()
|
|
48
|
+
|
|
49
|
+
def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
|
|
50
|
+
"""Keep the reader and its native actions reachable on the available screen."""
|
|
51
|
+
for button in (self.refresh_button, self.copy_button, self.close_button):
|
|
52
|
+
button.setMinimumHeight(control_height(button.fontMetrics().height()))
|
|
53
|
+
fit_form(self, available_geometry)
|
|
54
|
+
|
|
55
|
+
def show_results(self, results: tuple[MergeRecovery, ...], diagnostic: str = "", recovery_dir: str = "") -> None:
|
|
56
|
+
"""Show exact copyable locations while leaving any new resolution to the user."""
|
|
57
|
+
descriptions = ["Retained merge results. Copy a path to inspect the saved file; apply any resolution manually."]
|
|
58
|
+
for result in results:
|
|
59
|
+
descriptions.append(conflict_path_label(result.path) + " (" + result.saved_at + ")\n" + result.filename)
|
|
60
|
+
if not results:
|
|
61
|
+
descriptions.append("No recorded merge results were found.")
|
|
62
|
+
if diagnostic:
|
|
63
|
+
descriptions = [diagnostic]
|
|
64
|
+
if recovery_dir:
|
|
65
|
+
descriptions.append("Recovery directory:\n" + recovery_dir)
|
|
66
|
+
self.recovery_text.setPlainText("\n\n".join(descriptions))
|
|
67
|
+
self.refresh_button.setEnabled(True)
|