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,565 @@
1
+ """Present the consequences of a prepared history removal proposal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QEvent, QMargins, QModelIndex, QSize, Qt, Signal
10
+ from PySide6.QtGui import QFont, QPalette
11
+ from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QPushButton, QScrollArea, QSplitter, QStyle, QStyleOptionViewItem, QTableView, QVBoxLayout, QWidget
12
+
13
+ from gitpane.config import ICON_SIDE, LOCAL_PREFIX, SHORT_OID_LENGTH, SPACING_MD, SPACING_SM, fit_window, history_row_height
14
+ from gitpane.history_rewrite.value import RewriteStatus, RewriteTargetKind
15
+ from gitpane.ui.history_rewrite.details import DetailsText, HistoryRewriteDetails, commit_details
16
+ from gitpane.ui.history_rewrite.preview_model import BranchColumn, BranchPreviewModel, CommitColumn, CommitPreviewModel, ModelText, StatusDelegate, author_date, safe_text, status_color
17
+ from gitpane.ui.look.theme_look import follow_theme
18
+ from gitpane.ui.look.theme_palette import desktop_prefers_high_contrast
19
+ from gitpane.ui.widget.dialog_layout import apply_form_metrics, install_banded_layout
20
+ from gitpane.ui.widget.long_text import WrappingLabel
21
+ from gitpane.ui.widget.result_label import ResultLabel
22
+ from gitpane.ui.widget.standard_icon_label import StandardIconLabel
23
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
24
+
25
+ if TYPE_CHECKING:
26
+ from PySide6.QtCore import QObject
27
+ from PySide6.QtGui import QCloseEvent, QKeyEvent
28
+
29
+ from gitpane.history_rewrite.value import RewritePreparation, RewriteRefusal, RewriteTarget
30
+
31
+ PREVIEW_WIDTH, PREVIEW_HEIGHT = 960, 720
32
+ PREVIEW_MINIMUM_WIDTH, PREVIEW_MINIMUM_HEIGHT = 640, 480
33
+ INITIAL_BRANCH_ROW_COUNT = 4
34
+ MINIMUM_WARNING_LINE_COUNT = 4
35
+ TARGET_LINE_COUNT = 3
36
+ MESSAGE_MINIMUM_CHARACTERS = 24
37
+ AUTHOR_MINIMUM_CHARACTERS = 16
38
+ PREVIEW_BRANCH_MINIMUM_CHARACTERS = 20
39
+ BRANCH_TABLE_STRETCH, COMMIT_TABLE_STRETCH = 1, 3
40
+
41
+
42
+ class PreviewState(Enum):
43
+ """Identify which actions are meaningful for the current proposal."""
44
+
45
+ PREPARING = "preparing"
46
+ READY = "ready"
47
+ EMPTY = "empty"
48
+ ERROR = "error"
49
+ STALE = "stale"
50
+ STOPPING = "stopping"
51
+ EXECUTING = "executing"
52
+ CLOSED = "closed"
53
+
54
+
55
+ class PreviewText(Enum):
56
+ """Name the preview regions and its explicit action states."""
57
+
58
+ TITLE = "Remove from history — Preview"
59
+ TARGET = "Selected path"
60
+ FILE = "File: {path}"
61
+ FOLDER = "Folder and all files below it: {path}"
62
+ REPOSITORY = "Repository: {repository}"
63
+ BRANCH = "Current branch: {branch}"
64
+ PREPARING = "Preparing preview… No changes have been made to this repository."
65
+ STOPPING = "Stopping preview…"
66
+ EMPTY = "No history changes found"
67
+ EMPTY_BODY = "{path} has no history changes to remove from the affected local branches. Choose Abort and select another path."
68
+ ERROR = "Could not build the preview: {cause}. No changes have been made to this repository. Resolve the problem, then choose Recompute preview."
69
+ INCOMPLETE = "The preview could not be completed: {cause}. Confirm is unavailable. Inspect the diagnostic and resolve the limit before recomputing; no history was changed."
70
+ MISSING_METADATA = "Required branch or commit metadata is incomplete or unreadable"
71
+ DIFFERENT_TARGET = "The proposal belongs to another selected path"
72
+ STALE = "Preview is out of date"
73
+ STALE_BODY = "The repository changed after this preview was built. Choose Recompute preview to review the current branches and commits before confirming."
74
+ BRANCHES = "Affected local branches"
75
+ ONE_BRANCH = "1 local branch will move."
76
+ MANY_BRANCHES = "{count} local branches will move."
77
+ COMMITS = "Affected commit changes"
78
+ ONE_MODIFIED = "1 modified commit"
79
+ MANY_MODIFIED = "{count} modified commits"
80
+ ONE_DELETED = "1 deleted commit"
81
+ MANY_DELETED = "{count} deleted commits"
82
+ MODIFIED_LEGEND = "— Modified commit, kept in history."
83
+ DELETED_LEGEND = "— Commit deleted because removing the path makes it empty."
84
+ DESCENDANTS = "Descendants may also receive new commit hashes even when their own changes do not involve this path. They are not listed here."
85
+ WARNINGS = "Before you confirm"
86
+ CONFIRM = "Confirm"
87
+ ABORT = "Abort"
88
+ RECOMPUTE = "Recompute preview"
89
+ EXECUTING = "Checking repository…"
90
+ PUBLICATION = "Publication information uses remote references from the last fetch. No fetch was performed; the server may have changed since then."
91
+ PUBLISHED = "Published history will differ from these local branches. Coordinate with other users before updating remote branches. GitPane will not push these changes."
92
+ ONE_SIGNATURE = "1 affected signed commit will lose its signature, including rewritten commits not listed above. Confirm accepts this loss."
93
+ MANY_SIGNATURES = "{count} affected signed commits will lose their signatures, including rewritten commits not listed above. Confirm accepts this loss."
94
+ NO_SIGNATURE = "No affected signed commits were found."
95
+ ABSENT = "This path is absent from the current branch version. Historical versions will be removed; no file will be recreated on disk."
96
+ DISK_INDEX = ("Current files on disk, including unstaged changes, will be kept exactly as they are. The target will be removed from the index. "
97
+ "Other staged changes will be kept. No ignore rule will be added.")
98
+ FINAL = ("Confirm removes {path} from the history of every local branch listed above. Other local branches, tags and remote references will not move. "
99
+ "Review the consequences below before confirming.")
100
+ RETAINED = ("This is not a global purge. Other references, reflogs, clones and servers may retain the old data. "
101
+ "Revoke or rotate compromised credentials; removing a path does not make them safe again.")
102
+ SUBMODULE = "This repository is a submodule"
103
+ SUBMODULE_PARENT = ("Parent repositories record exact submodule commit hashes. They will still point to the old commits, not their rewritten replacements. "
104
+ "If an old commit remains available, it can still expose the removed file. "
105
+ "If it is no longer available locally or retrievable, restoring that parent version may fail.")
106
+ SUBMODULE_CONSUMERS = ("Other repositories using these commits may also be affected. GitPane cannot identify every consumer. "
107
+ "Repair could require rewriting those repositories' histories, branches and tags, then their own parents. GitPane will not update any parent repository.")
108
+ SUBMODULE_ACCEPT = "Confirm accepts that some old parent versions may no longer be restorable."
109
+ KNOWN_PARENT = "Known parent repository: {repository}"
110
+ REFUSAL_PATH = "Path: {path}"
111
+ REFUSAL_COMMIT = "Commit: {commit}"
112
+ REFUSAL_BRANCH = "Branch: {branch}"
113
+
114
+
115
+ class HistoryRewritePreview(QDialog):
116
+ """Expose a proposal through typed signals without performing repository work."""
117
+
118
+ confirm_requested = Signal(object)
119
+ abort_requested = Signal()
120
+ recompute_requested = Signal()
121
+
122
+ def __init__(self, target: RewriteTarget, branch: str, repository_dir: str, parent: QWidget | None = None, *, active_submodule: bool = False, parent_repository_dir: str = "") -> None:
123
+ """Start visibly preparing with Abort as the only default action."""
124
+ super().__init__(parent)
125
+ self.target = target
126
+ self.active_branch = branch if branch.startswith(LOCAL_PREFIX) else LOCAL_PREFIX + branch
127
+ self.repository_dir = repository_dir
128
+ self.active_submodule = active_submodule
129
+ self.parent_repository_dir = parent_repository_dir
130
+ self.preparation: RewritePreparation | None = None
131
+ self.details_dialog: HistoryRewriteDetails | None = None
132
+ self.state = PreviewState.PREPARING
133
+ self.setWindowTitle(PreviewText.TITLE.value)
134
+ self.resize(PREVIEW_WIDTH, PREVIEW_HEIGHT)
135
+ self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
136
+ body = QVBoxLayout()
137
+ body.setSpacing(SPACING_SM)
138
+ self.target_text = QPlainTextEdit()
139
+ self.target_text.setReadOnly(True)
140
+ self.target_text.setAccessibleName(PreviewText.TARGET.value)
141
+ self._show_target()
142
+ body.addWidget(self.target_text)
143
+ self.status_label = ResultLabel(PreviewText.PREPARING.value)
144
+ self.status_scroll = QScrollArea()
145
+ self.status_scroll.setWidgetResizable(True)
146
+ self.status_scroll.setWidget(self.status_label)
147
+ self.status_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
148
+ body.addWidget(self.status_scroll)
149
+ self.branch_model = BranchPreviewModel()
150
+ self.commit_model = CommitPreviewModel()
151
+ self.branch_table = self._table(PreviewText.BRANCHES)
152
+ self.commit_table = self._table(PreviewText.COMMITS)
153
+ self.branch_table.setModel(self.branch_model)
154
+ self.commit_table.setModel(self.commit_model)
155
+ self.status_delegate = StatusDelegate(self.commit_table)
156
+ self.commit_table.setItemDelegateForColumn(CommitColumn.STATUS.index, self.status_delegate)
157
+ self.commit_table.viewport().installEventFilter(self)
158
+ self.branch_count_label = self._label("")
159
+ self.commit_count_label = self._label("")
160
+ self.table_splitter = QSplitter(Qt.Orientation.Vertical)
161
+ self.table_splitter.addWidget(self._table_section(PreviewText.BRANCHES, self.branch_count_label, self.branch_table))
162
+ self.table_splitter.addWidget(self._table_section(PreviewText.COMMITS, self.commit_count_label, self.commit_table))
163
+ branch_section_index, commit_section_index = 0, 1
164
+ self.table_splitter.setStretchFactor(branch_section_index, BRANCH_TABLE_STRETCH)
165
+ self.table_splitter.setStretchFactor(commit_section_index, COMMIT_TABLE_STRETCH)
166
+ self.table_splitter.setChildrenCollapsible(False)
167
+ self.table_splitter.setHandleWidth(SPACING_MD)
168
+ body.addWidget(self.table_splitter, 1)
169
+ legend = QHBoxLayout()
170
+ legend.setSpacing(SPACING_SM)
171
+ self.legend_marker_by_status: dict[RewriteStatus, QLabel] = {}
172
+ for status, caption in ((RewriteStatus.MODIFIED, PreviewText.MODIFIED_LEGEND), (RewriteStatus.DELETED, PreviewText.DELETED_LEGEND)):
173
+ marker = QLabel(status.value)
174
+ marker.setTextFormat(Qt.TextFormat.PlainText)
175
+ marker.setBackgroundRole(QPalette.ColorRole.Base)
176
+ marker.setForegroundRole(QPalette.ColorRole.Text)
177
+ marker.setAutoFillBackground(True)
178
+ marker_font = QFont()
179
+ marker_font.setBold(status == RewriteStatus.DELETED)
180
+ marker.setFont(marker_font)
181
+ self.legend_marker_by_status[status] = marker
182
+ legend.addWidget(marker, 0, Qt.AlignmentFlag.AlignTop)
183
+ legend.addWidget(self._label(caption.value), 1, Qt.AlignmentFlag.AlignTop)
184
+ body.addLayout(legend)
185
+ body.addWidget(self._label(PreviewText.DESCENDANTS.value))
186
+ body.addSpacing(SPACING_MD - body.spacing())
187
+ warning_heading = QHBoxLayout()
188
+ self.warning_icon = StandardIconLabel(QSize(ICON_SIDE, ICON_SIDE), QStyle.StandardPixmap.SP_MessageBoxWarning)
189
+ warning_heading.addWidget(self.warning_icon, 0, Qt.AlignmentFlag.AlignVCenter)
190
+ warning_heading.addWidget(self._heading(PreviewText.WARNINGS), 1)
191
+ body.addLayout(warning_heading)
192
+ self.warning_scroll = QScrollArea()
193
+ self.warning_scroll.setWidgetResizable(True)
194
+ self.warning_scroll.setAccessibleName(PreviewText.WARNINGS.value)
195
+ self.warning_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
196
+ self.warning_scroll.installEventFilter(self)
197
+ self.warning_label = self._label("")
198
+ self.warning_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
199
+ self.warning_scroll.setWidget(self.warning_label)
200
+ body.addWidget(self.warning_scroll, 1)
201
+ self.confirm_button = QPushButton(PreviewText.CONFIRM.value)
202
+ self.abort_button = QPushButton(PreviewText.ABORT.value)
203
+ self.recompute_button = QPushButton(PreviewText.RECOMPUTE.value)
204
+ for button in (self.confirm_button, self.abort_button, self.recompute_button):
205
+ button.setAutoDefault(False)
206
+ self.button_band = install_banded_layout(self, body, [self.confirm_button, self.abort_button],
207
+ body_margins=QMargins(SPACING_MD, SPACING_MD, SPACING_MD, SPACING_MD), leading_buttons=[self.recompute_button])
208
+ self.abort_button.setDefault(True)
209
+ self.confirm_button.clicked.connect(self._confirm)
210
+ self.abort_button.clicked.connect(self.reject)
211
+ self.recompute_button.clicked.connect(self._recompute)
212
+ self.branch_table.doubleClicked.connect(self._branch_details)
213
+ self.commit_table.doubleClicked.connect(self._commit_details)
214
+ self._set_state(PreviewState.PREPARING)
215
+ self.apply_metrics()
216
+ self.table_splitter.setSizes([INITIAL_BRANCH_ROW_COUNT * history_row_height(self.fontMetrics().height()),
217
+ INITIAL_BRANCH_ROW_COUNT * history_row_height(self.fontMetrics().height()) * COMMIT_TABLE_STRETCH])
218
+ follow_theme(self, self._refresh_theme)
219
+ self._refresh_theme()
220
+ previous = self.target_text
221
+ for widget in (self.branch_table, self.commit_table, self.warning_scroll, self.warning_label, self.recompute_button, self.confirm_button, self.abort_button):
222
+ QWidget.setTabOrder(previous, widget)
223
+ previous = widget
224
+ fit_window(self, PREVIEW_MINIMUM_WIDTH, PREVIEW_MINIMUM_HEIGHT)
225
+
226
+ def _label(self, text: str) -> WrappingLabel:
227
+ """Keep repository text selectable without Qt markup detection."""
228
+ label = WrappingLabel(text)
229
+ label.setTextFormat(Qt.TextFormat.PlainText)
230
+ label.setWordWrap(True)
231
+ label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
232
+ return label
233
+
234
+ def _table(self, caption: PreviewText) -> QTableView:
235
+ """Use native scrolling and selection without editors or per-cell widgets."""
236
+ table = QTableView()
237
+ table.setAccessibleName(caption.value)
238
+ table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
239
+ table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
240
+ table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
241
+ table.setSortingEnabled(False)
242
+ table.setWordWrap(False)
243
+ table.setTextElideMode(Qt.TextElideMode.ElideRight)
244
+ table.setShowGrid(False)
245
+ table.verticalHeader().hide()
246
+ table.horizontalHeader().setSectionsClickable(False)
247
+ table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
248
+ table.installEventFilter(self)
249
+ return table
250
+
251
+ def _table_section(self, title: PreviewText, count: WrappingLabel, table: QTableView) -> QWidget:
252
+ """Keep headings outside the table viewport as its rows scroll."""
253
+ section = QWidget()
254
+ layout = QVBoxLayout(section)
255
+ layout.setContentsMargins(0, 0, 0, 0)
256
+ layout.setSpacing(SPACING_SM)
257
+ layout.addWidget(self._heading(title))
258
+ layout.addWidget(count)
259
+ layout.addWidget(table, 1)
260
+ return section
261
+
262
+ def _heading(self, title: PreviewText) -> WrappingLabel:
263
+ """Emphasize section titles while inheriting the native family and size."""
264
+ label = self._label(title.value)
265
+ font = QFont()
266
+ font.setBold(True)
267
+ label.setFont(font)
268
+ return label
269
+
270
+ def _hash_column_width(self, table: QTableView, column_index: int) -> int:
271
+ """Reserve a full short hash plus native cell padding and header space."""
272
+ option = QStyleOptionViewItem()
273
+ table.initViewItemOption(option)
274
+ widest_digit = max("0123456789abcdef", key=lambda digit: option.fontMetrics.horizontalAdvance(digit * SHORT_OID_LENGTH))
275
+ option.text = widest_digit * SHORT_OID_LENGTH
276
+ if table == self.branch_table and column_index == BranchColumn.NEW_TIP.index:
277
+ option.text += f" — {ModelText.TIP_DELETED.value}"
278
+ option.features |= QStyleOptionViewItem.ViewItemFeature.HasDisplay
279
+ cell_width = table.style().sizeFromContents(QStyle.ContentsType.CT_ItemViewItem, option, QSize(), table).width()
280
+ return max(cell_width, table.horizontalHeader().sectionSizeHint(column_index))
281
+
282
+ def apply_metrics(self) -> None:
283
+ """Size columns from the current native font without scanning every row."""
284
+ metrics = self.fontMetrics()
285
+ row_height = history_row_height(metrics.height())
286
+ self.target_text.setMaximumHeight(row_height * TARGET_LINE_COUNT)
287
+ self.status_scroll.setMaximumHeight(row_height * TARGET_LINE_COUNT)
288
+ self.warning_scroll.setMinimumHeight(metrics.lineSpacing() * MINIMUM_WARNING_LINE_COUNT + SPACING_MD)
289
+ for table in (self.branch_table, self.commit_table):
290
+ table.verticalHeader().setDefaultSectionSize(row_height)
291
+ table.verticalHeader().setMinimumSectionSize(row_height)
292
+ table.horizontalHeader().setMinimumSectionSize(SPACING_MD)
293
+ for column in CommitColumn:
294
+ sample = column.value
295
+ if column == CommitColumn.COMMIT:
296
+ self.commit_table.setColumnWidth(column.index, self._hash_column_width(self.commit_table, column.index))
297
+ continue
298
+ elif column == CommitColumn.MESSAGE:
299
+ sample = "M" * MESSAGE_MINIMUM_CHARACTERS
300
+ elif column == CommitColumn.AUTHOR:
301
+ sample = "M" * AUTHOR_MINIMUM_CHARACTERS
302
+ elif column == CommitColumn.DATE:
303
+ sample = "2000-12-31 23:59 +1200"
304
+ self.commit_table.setColumnWidth(column.index, metrics.horizontalAdvance(sample) + SPACING_MD)
305
+ for column in BranchColumn:
306
+ if column in (BranchColumn.CURRENT_TIP, BranchColumn.NEW_TIP):
307
+ self.branch_table.setColumnWidth(column.index, self._hash_column_width(self.branch_table, column.index))
308
+ continue
309
+ sample = "M" * PREVIEW_BRANCH_MINIMUM_CHARACTERS
310
+ self.branch_table.setColumnWidth(column.index, metrics.horizontalAdvance(sample) + SPACING_MD)
311
+ apply_form_metrics(self)
312
+ self._fit_message_column()
313
+
314
+ def _fit_message_column(self) -> None:
315
+ """Give the subject remaining viewport space while preserving its readable minimum."""
316
+ minimum = self.fontMetrics().horizontalAdvance("M" * MESSAGE_MINIMUM_CHARACTERS) + SPACING_MD
317
+ other_width = 0
318
+ for column in CommitColumn:
319
+ if column != CommitColumn.MESSAGE:
320
+ other_width += self.commit_table.columnWidth(column.index)
321
+ width = max(minimum, self.commit_table.viewport().width() - other_width)
322
+ if self.commit_table.columnWidth(CommitColumn.MESSAGE.index) != width:
323
+ self.commit_table.setColumnWidth(CommitColumn.MESSAGE.index, width)
324
+
325
+ def _refresh_theme(self) -> None:
326
+ """Follow native contrast preference without replacing the prepared models."""
327
+ self.status_delegate.high_contrast = desktop_prefers_high_contrast(QApplication.instance())
328
+ self.status_delegate.refresh_theme()
329
+ for status, marker in self.legend_marker_by_status.items():
330
+ palette = self.commit_table.palette()
331
+ for group in (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive, QPalette.ColorGroup.Disabled):
332
+ palette.setCurrentColorGroup(group)
333
+ palette.setColor(group, QPalette.ColorRole.Text, status_color(status, palette, high_contrast=self.status_delegate.high_contrast))
334
+ marker.setPalette(palette)
335
+ self.commit_table.viewport().update()
336
+
337
+ def changeEvent(self, event: QEvent) -> None:
338
+ """Recalculate text metrics only when fonts or styles change."""
339
+ super().changeEvent(event)
340
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(self, "button_band"):
341
+ self.apply_metrics()
342
+ self._refresh_theme()
343
+
344
+ def _set_state(self, state: PreviewState) -> None:
345
+ """Derive available actions from one explicit lifecycle state."""
346
+ self.state = state
347
+ self.status_scroll.setVisible(state != PreviewState.READY)
348
+ self.confirm_button.setEnabled(state == PreviewState.READY)
349
+ self.abort_button.setEnabled(state not in (PreviewState.STOPPING, PreviewState.EXECUTING, PreviewState.CLOSED))
350
+ self.recompute_button.setVisible(state in (PreviewState.ERROR, PreviewState.STALE))
351
+
352
+ def set_preparation(self, preparation: RewritePreparation) -> None:
353
+ """Show the complete result, refusing partial or unreadable proposals."""
354
+ if self.state in (PreviewState.STOPPING, PreviewState.EXECUTING, PreviewState.CLOSED):
355
+ return
356
+ self.preparation = preparation
357
+ if preparation.capture.head_ref:
358
+ self.active_branch = preparation.capture.head_ref
359
+ self._show_target()
360
+ old_branch_model, old_commit_model = self.branch_model, self.commit_model
361
+ self.branch_model = BranchPreviewModel(preparation.branches, preparation.capture.head_ref)
362
+ self.commit_model = CommitPreviewModel(preparation.rows)
363
+ self.branch_table.setModel(self.branch_model)
364
+ self.commit_table.setModel(self.commit_model)
365
+ old_branch_model.deleteLater()
366
+ old_commit_model.deleteLater()
367
+ self._update_counts()
368
+ self._update_warning(preparation)
369
+ if preparation.target != self.target:
370
+ self.set_error(PreviewText.DIFFERENT_TARGET.value)
371
+ return
372
+ if not preparation.complete or not preparation.capture.complete or preparation.refusal:
373
+ self.set_error(preparation.refusal or PreviewText.MISSING_METADATA.value)
374
+ return
375
+ if not preparation.rows and not preparation.branches:
376
+ self._set_state(PreviewState.EMPTY)
377
+ self.status_label.setText(PreviewText.EMPTY.value + "\n" + PreviewText.EMPTY_BODY.value.format(path=safe_text(self.target.path)))
378
+ return
379
+ try:
380
+ self._validate_metadata(preparation)
381
+ except (ValueError, OSError, OverflowError) as error:
382
+ self.set_error(str(error), incomplete=True)
383
+ return
384
+ self._set_state(PreviewState.READY)
385
+ self.status_label.setText("")
386
+
387
+ def _show_target(self) -> None:
388
+ """Keep the branch heading consistent with the newly computed proposal."""
389
+ scope = PreviewText.FOLDER if self.target.kind == RewriteTargetKind.FOLDER else PreviewText.FILE
390
+ self.target_text.setPlainText("\n".join((scope.value.format(path=safe_text(self.target.path)),
391
+ PreviewText.REPOSITORY.value.format(repository=safe_text(self.repository_dir)),
392
+ PreviewText.BRANCH.value.format(branch=safe_text(self.active_branch.removeprefix(LOCAL_PREFIX))))))
393
+
394
+ def _validate_metadata(self, preparation: RewritePreparation) -> None:
395
+ """Reject missing required metadata while keeping an actually empty message valid."""
396
+ if not preparation.rows or not preparation.branches:
397
+ raise ValueError(PreviewText.MISSING_METADATA.value)
398
+ message_size = None
399
+ if preparation.message_filename:
400
+ with open(preparation.message_filename, "rb") as stream:
401
+ message_size = os.fstat(stream.fileno()).st_size
402
+ for row in preparation.rows:
403
+ if not row.author or not row.oid:
404
+ raise ValueError(PreviewText.MISSING_METADATA.value)
405
+ author_date(row.date)
406
+ if row.message_offset is not None and (message_size is None or row.message_offset < 0 or row.message_length < 0 or row.message_offset + row.message_length > message_size):
407
+ raise ValueError(DetailsText.INCOMPLETE.value)
408
+ for branch in preparation.branches:
409
+ if not branch.name or not branch.old_oid or not branch.new_oid:
410
+ raise ValueError(PreviewText.MISSING_METADATA.value)
411
+
412
+ def _update_counts(self) -> None:
413
+ """Count only displayed contributions while retaining every affected branch."""
414
+ branch_count = len(self.branch_model.branches)
415
+ self.branch_count_label.setText((PreviewText.ONE_BRANCH if branch_count == 1 else PreviewText.MANY_BRANCHES).value.format(count=branch_count))
416
+ modified_count = sum(row.status == RewriteStatus.MODIFIED for row in self.commit_model.rows)
417
+ deleted_count = len(self.commit_model.rows) - modified_count
418
+ modified = (PreviewText.ONE_MODIFIED if modified_count == 1 else PreviewText.MANY_MODIFIED).value.format(count=modified_count)
419
+ deleted = (PreviewText.ONE_DELETED if deleted_count == 1 else PreviewText.MANY_DELETED).value.format(count=deleted_count)
420
+ self.commit_count_label.setText(modified + "; " + deleted)
421
+
422
+ def _update_warning(self, preparation: RewritePreparation) -> None:
423
+ """Explain all consequences using complete domain counts and already known context."""
424
+ paragraphs = [PreviewText.FINAL.value.format(path=safe_text(self.target.path)), PreviewText.PUBLICATION.value, PreviewText.PUBLISHED.value]
425
+ if preparation.signed_count == 0:
426
+ paragraphs.append(PreviewText.NO_SIGNATURE.value)
427
+ else:
428
+ caption = PreviewText.ONE_SIGNATURE if preparation.signed_count == 1 else PreviewText.MANY_SIGNATURES
429
+ paragraphs.append(caption.value.format(count=preparation.signed_count))
430
+ if preparation.current_absent:
431
+ paragraphs.append(PreviewText.ABSENT.value)
432
+ paragraphs.append(PreviewText.DISK_INDEX.value)
433
+ if self.active_submodule:
434
+ paragraphs.extend((PreviewText.SUBMODULE.value, PreviewText.SUBMODULE_PARENT.value, PreviewText.SUBMODULE_CONSUMERS.value, PreviewText.SUBMODULE_ACCEPT.value))
435
+ if self.parent_repository_dir:
436
+ paragraphs.append(PreviewText.KNOWN_PARENT.value.format(repository=safe_text(self.parent_repository_dir)))
437
+ paragraphs.append(PreviewText.RETAINED.value)
438
+ self.warning_label.setText("\n\n".join(paragraphs))
439
+
440
+ def set_refusal(self, refusal: RewriteRefusal) -> None:
441
+ """Show every supplied refusal witness without guessing a new repository state."""
442
+ paragraphs = [refusal.detail]
443
+ if refusal.path is not None:
444
+ paragraphs.append(PreviewText.REFUSAL_PATH.value.format(path=refusal.path.decode("utf-8", "surrogateescape")))
445
+ if refusal.commit_oid:
446
+ paragraphs.append(PreviewText.REFUSAL_COMMIT.value.format(commit=refusal.commit_oid))
447
+ if refusal.branch:
448
+ paragraphs.append(PreviewText.REFUSAL_BRANCH.value.format(branch=refusal.branch))
449
+ self.set_error("\n".join(paragraphs))
450
+
451
+ def set_error(self, cause: str, *, incomplete: bool = False) -> None:
452
+ """Keep an actionable preparation failure visible with Confirm unavailable."""
453
+ if self.state in (PreviewState.STOPPING, PreviewState.CLOSED):
454
+ return
455
+ self._set_state(PreviewState.ERROR)
456
+ self.status_label.show_warning((PreviewText.INCOMPLETE if incomplete else PreviewText.ERROR).value.format(cause=safe_text(cause, multiline=True)))
457
+
458
+ def mark_stale(self, cause: str = "") -> None:
459
+ """Revoke consent as soon as the controller observes changed repository facts."""
460
+ if self.state in (PreviewState.STOPPING, PreviewState.CLOSED):
461
+ return
462
+ self._set_state(PreviewState.STALE)
463
+ self.status_label.setText(PreviewText.STALE.value + "\n" + PreviewText.STALE_BODY.value + ("\n" + safe_text(cause, multiline=True) if cause else ""))
464
+
465
+ def finish_publication(self) -> None:
466
+ """Close an executed proposal without reporting a harmless preview cancellation."""
467
+ self._set_state(PreviewState.CLOSED)
468
+ super().accept()
469
+
470
+ def _recompute(self) -> None:
471
+ """Request one fresh calculation without carrying forward prior approval."""
472
+ if self.state not in (PreviewState.ERROR, PreviewState.STALE):
473
+ return
474
+ self._set_state(PreviewState.PREPARING)
475
+ self.status_label.setText(PreviewText.PREPARING.value)
476
+ self.recompute_requested.emit()
477
+
478
+ def _confirm(self) -> None:
479
+ """Disable repeated submission before handing the typed proposal to the controller."""
480
+ if self.state != PreviewState.READY or self.preparation is None:
481
+ return
482
+ self._set_state(PreviewState.EXECUTING)
483
+ self.status_label.setText(PreviewText.EXECUTING.value)
484
+ self.confirm_requested.emit(self.preparation)
485
+
486
+ def reject(self) -> None:
487
+ """Ask the controller to stop private work before closing this preview."""
488
+ if self.state in (PreviewState.STOPPING, PreviewState.EXECUTING, PreviewState.CLOSED):
489
+ return
490
+ self._set_state(PreviewState.STOPPING)
491
+ self.status_label.setText(PreviewText.STOPPING.value)
492
+ self.abort_requested.emit()
493
+
494
+ def finish_abort(self) -> None:
495
+ """Close only after the controller confirms that private work has stopped."""
496
+ self._set_state(PreviewState.CLOSED)
497
+ super().reject()
498
+
499
+ def closeEvent(self, event: QCloseEvent) -> None:
500
+ """Make title-bar closure follow the same drain requirement as Abort."""
501
+ if self.state == PreviewState.CLOSED:
502
+ super().closeEvent(event)
503
+ return
504
+ event.ignore()
505
+ self.reject()
506
+
507
+ def keyPressEvent(self, event: QKeyEvent) -> None:
508
+ """Require explicit button focus for Enter instead of activating a hidden default."""
509
+ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
510
+ focused = self.focusWidget()
511
+ if focused in (self.confirm_button, self.abort_button, self.recompute_button):
512
+ focused.click()
513
+ event.accept()
514
+ return
515
+ super().keyPressEvent(event)
516
+
517
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
518
+ """Offer complete keyboard reading without treating navigation as consent."""
519
+ if event.type() == QEvent.Type.Resize and hasattr(self, "commit_table") and watched == self.commit_table.viewport():
520
+ self._fit_message_column()
521
+ # QScrollArea handles arrows and page keys but leaves Home/End unhandled.
522
+ if event.type() == QEvent.Type.KeyPress and watched == self.warning_scroll and event.key() in (Qt.Key.Key_Home, Qt.Key.Key_End):
523
+ scrollbar = self.warning_scroll.verticalScrollBar()
524
+ scrollbar.setValue(scrollbar.minimum() if event.key() == Qt.Key.Key_Home else scrollbar.maximum())
525
+ event.accept()
526
+ return True
527
+ if event.type() == QEvent.Type.KeyPress and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
528
+ if watched == self.branch_table:
529
+ self._branch_details(self.branch_table.currentIndex())
530
+ elif watched == self.commit_table:
531
+ self._commit_details(self.commit_table.currentIndex())
532
+ else:
533
+ return super().eventFilter(watched, event)
534
+ return True
535
+ return super().eventFilter(watched, event)
536
+
537
+ def _branch_details(self, index: QModelIndex) -> None:
538
+ """Open full branch identities without querying the repository."""
539
+ if index.isValid():
540
+ self._show_details(self.branch_model.details(index.row()))
541
+
542
+ def _commit_details(self, index: QModelIndex) -> None:
543
+ """Load only the chosen message and refuse confirmation if its spool was lost."""
544
+ if not index.isValid() or self.preparation is None:
545
+ return
546
+ try:
547
+ text = commit_details(self.commit_model.rows[index.row()], self.preparation.message_filename)
548
+ except (ValueError, OSError, OverflowError) as error:
549
+ self.set_error(str(error), incomplete=True)
550
+ text = safe_text(str(error))
551
+ self._show_details(text)
552
+
553
+ def _show_details(self, text: str) -> None:
554
+ """Retain one modal child until it closes."""
555
+ self.details_dialog = HistoryRewriteDetails(text, self)
556
+ self.details_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
557
+ self.details_dialog.finished.connect(self._detail_closed)
558
+ self.details_dialog.show_modal()
559
+
560
+ def _detail_closed(self, _result: int) -> None:
561
+ self.details_dialog = None
562
+
563
+ def show_modal(self) -> None:
564
+ """Show through the native-window policy without beginning any Git work."""
565
+ open_window_modal(self)