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.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. 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)