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