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,263 @@
1
+ """Present the reset choices of the current branch for one captured commit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QRect, Qt, Signal
9
+ from PySide6.QtWidgets import QButtonGroup, QDialog, QGroupBox, QHBoxLayout, QLabel, QLayout, QMessageBox, QPushButton, QRadioButton, QScrollArea, QVBoxLayout, QWidget
10
+ from shiboken6 import isValid
11
+
12
+ from gitpane.config import LOCAL_PREFIX, SPACING_SM
13
+ from gitpane.history.repository_value import ReferenceKind
14
+ from gitpane.operation.operation_status import OperationStatus
15
+ from gitpane.reference.reference_edit import ResetIntent, ResetMode
16
+ from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
17
+ from gitpane.ui.widget.content_measure import ScrolledContentMeasure
18
+ from gitpane.ui.widget.dialog_layout import fit_form
19
+ from gitpane.ui.widget.long_text import WrappingLabel, WrappingRadioButton
20
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
21
+
22
+ if TYPE_CHECKING:
23
+ from gitpane.operation.repository_operation import OperationOutcome
24
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
25
+ from gitpane.worktree.worktree_snapshot import WorktreeSnapshot
26
+
27
+ RESET_DIALOG_WIDTH = 640
28
+ RESET_DIALOG_HEIGHT = 640
29
+ RESET_TITLE = "Reset current branch"
30
+ # The band colour of each reset choice: green for the mode that touches no file, red for the one that discards local changes.
31
+ RESET_MODE_COLOR_BY_MODE: dict[ResetMode, str] = {
32
+ ResetMode.SOFT: "#80ff80",
33
+ ResetMode.MIXED: "#ffff80",
34
+ ResetMode.KEEP: "#ffff80",
35
+ ResetMode.MERGE: "#ffff80",
36
+ ResetMode.HARD: "#ff8080",
37
+ }
38
+
39
+
40
+ class ResetDialog(ContentGrowthMixin, QDialog):
41
+ """Keep reset choices bound to one branch, commit and local-state capture."""
42
+
43
+ reset_requested = Signal(object)
44
+ preparation_requested = Signal()
45
+
46
+ def __init__(self, session_id: str, target_oid: str, parent: QWidget | None = None) -> None:
47
+ """Build scrolling metadata and five fixed-color choices above permanent actions."""
48
+ super().__init__(parent)
49
+ self.session_id = session_id
50
+ self.target_oid = target_oid
51
+ self.snapshot: ReferenceSnapshot | None = None
52
+ self.worktree_snapshot: WorktreeSnapshot | None = None
53
+ self.working_signature: tuple = ()
54
+ self.working_paths: tuple[bytes, ...] | None = None
55
+ self.reset_target_paths: tuple[bytes, ...] = ()
56
+ self.intent: ResetIntent | None = None
57
+ self.mode = ResetMode.SOFT
58
+ self.closed = False
59
+ self.busy = False
60
+ self.completed = False
61
+ self.metadata_ready = False
62
+ self.confirmation: QMessageBox | None = None
63
+ self.confirmation_intent: ResetIntent | None = None
64
+ self.return_focus = parent.focusWidget() if parent is not None else None
65
+ self.setWindowTitle(RESET_TITLE)
66
+ self.resize(RESET_DIALOG_WIDTH, RESET_DIALOG_HEIGHT)
67
+ layout = QVBoxLayout(self)
68
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
69
+ self.scroll_area = QScrollArea()
70
+ self.scroll_area.setWidgetResizable(True)
71
+ content = QWidget()
72
+ form = QVBoxLayout(content)
73
+ form.setSpacing(SPACING_SM)
74
+ self.branch_label = self._label("Loading current branch…")
75
+ self.branch_label.setAccessibleName("Current branch")
76
+ form.addWidget(self.branch_label)
77
+ metadata_box = QGroupBox("Target revision")
78
+ metadata_layout = QVBoxLayout(metadata_box)
79
+ self.target_label = self._label(target_oid)
80
+ self.subject_label = self._label("Loading commit metadata…")
81
+ font = self.subject_label.font()
82
+ font.setBold(True)
83
+ self.subject_label.setFont(font)
84
+ self.metadata_label = self._label("Loading…")
85
+ for label in (self.target_label, self.subject_label, self.metadata_label):
86
+ metadata_layout.addWidget(label)
87
+ form.addWidget(metadata_box)
88
+ self.mode_box = QGroupBox("Reset type")
89
+ mode_layout = QVBoxLayout(self.mode_box)
90
+ self.mode_group = QButtonGroup(self)
91
+ self.button_by_mode: dict[ResetMode, QRadioButton] = {}
92
+ for mode in ResetMode:
93
+ button = WrappingRadioButton(mode.label)
94
+ button.setAccessibleName(mode.label)
95
+ button.setStyleSheet("QRadioButton { background-color: " + RESET_MODE_COLOR_BY_MODE[mode] + "; color: black; padding: " + str(SPACING_SM) + "px; }")
96
+ button.description_label.setStyleSheet("color: black; background: transparent;")
97
+ self.mode_group.addButton(button)
98
+ self.button_by_mode[mode] = button
99
+ button.setChecked(mode == ResetMode.SOFT)
100
+ button.toggled.connect(lambda checked, mode=mode: self._select_mode(mode) if checked else None)
101
+ mode_layout.addWidget(button)
102
+ form.addWidget(self.mode_box)
103
+ self.status_label = self._label("")
104
+ form.addWidget(self.status_label)
105
+ form.addStretch()
106
+ self.scroll_area.setWidget(content)
107
+ # The commit details read by Git, a refusal with its diagnostic and "Running Git…" arrive in the form after the
108
+ # window opened, and the window follows them both ways by the rule it shares with the other forms, never below
109
+ # the height it opened at or the one the user set. The starting height was chosen for Windows' smaller system
110
+ # text: a larger font grows the window once its first show is laid out, the scroll area staying the last resort
111
+ # for a screen too small for the form, with OK and Cancel always in view under it.
112
+ self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.GROW_ONLY, RESET_DIALOG_HEIGHT)
113
+ layout.addWidget(self.scroll_area, 1)
114
+ actions = QHBoxLayout()
115
+ actions.addStretch()
116
+ self.submit_button = QPushButton("OK")
117
+ self.submit_button.setAutoDefault(False)
118
+ self.submit_button.setEnabled(False)
119
+ self.cancel_button = QPushButton("Cancel")
120
+ actions.addWidget(self.submit_button)
121
+ actions.addWidget(self.cancel_button)
122
+ layout.addLayout(actions)
123
+ self.submit_button.clicked.connect(self._submit)
124
+ self.cancel_button.clicked.connect(self.reject)
125
+ self.finished.connect(self._closed)
126
+ self.refresh_metrics()
127
+
128
+ def _label(self, text: str) -> QLabel:
129
+ """Keep repository text literal and available in full at narrow widths."""
130
+ label = WrappingLabel(text)
131
+ label.setTextFormat(Qt.TextFormat.PlainText)
132
+ label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
133
+ label.setWordWrap(True)
134
+ return label
135
+
136
+ def refresh_metrics(self, available: QRect | None = None) -> None:
137
+ """Fit the form to the screen while keeping its actions reachable."""
138
+ fit_form(self, available)
139
+
140
+ def populate(self, snapshot: ReferenceSnapshot, local: WorktreeSnapshot | None = None, signature: tuple = (), paths: tuple[bytes, ...] = (), target_paths: tuple[bytes, ...] = ()) -> None:
141
+ """Publish the exact branch and local capture without changing the selected mode."""
142
+ self.snapshot = snapshot
143
+ self.worktree_snapshot = local
144
+ self.working_signature = signature
145
+ self.working_paths = paths
146
+ self.reset_target_paths = target_paths
147
+ branch_name = (snapshot.head_ref or "").removeprefix(LOCAL_PREFIX)
148
+ self.branch_label.setText("Reset branch '" + branch_name + "' to revision:")
149
+ self._update_intent()
150
+
151
+ def show_metadata(self, fields: tuple[str, ...] | None, cause: str = "") -> None:
152
+ """Distinguish missing metadata from a failed read of the captured commit."""
153
+ if fields is None:
154
+ self.metadata_ready = False
155
+ self.metadata_label.setText("Commit metadata unavailable")
156
+ self.subject_label.setText("Commit metadata unavailable")
157
+ self.show_error(cause)
158
+ return
159
+ oid, author, _author_date, _committer, commit_date, message = fields
160
+ if oid != self.target_oid or self.snapshot is None:
161
+ return
162
+ subject, _, _body = message.partition("\n")
163
+ self.subject_label.setText(subject or "n/a")
164
+ branches: list[str] = []
165
+ tags: list[str] = []
166
+ for reference in sorted(self.snapshot.references, key=lambda reference: reference.full_name):
167
+ if reference.target_oid != oid:
168
+ continue
169
+ if reference.kind == ReferenceKind.TAGS:
170
+ tags.append(reference.full_name.removeprefix(ReferenceKind.TAGS.prefix))
171
+ else:
172
+ branches.append(reference.full_name.removeprefix(LOCAL_PREFIX))
173
+ self.metadata_label.setText("Author: " + (author or "n/a") + "\nCommit date: " + (commit_date or "n/a")
174
+ + "\nBranch(es): " + (", ".join(branches) or "n/a") + "\nTag(s): " + (", ".join(tags) or "n/a"))
175
+ self.metadata_ready = True
176
+ self._update_intent()
177
+
178
+ def _select_mode(self, mode: ResetMode) -> None:
179
+ """Prepare file-changing modes on demand while Soft needs only reference authority."""
180
+ self.mode = mode
181
+ self._update_intent()
182
+ if mode != ResetMode.SOFT and self.worktree_snapshot is None:
183
+ self.preparation_requested.emit()
184
+
185
+ def _update_intent(self) -> None:
186
+ """Enable submission only after complete authority and metadata reads."""
187
+ self.intent = None
188
+ if self.snapshot is not None and (self.mode == ResetMode.SOFT or self.worktree_snapshot is not None) and not self.busy and not self.completed:
189
+ try:
190
+ local = None if self.mode == ResetMode.SOFT else self.worktree_snapshot
191
+ self.intent = ResetIntent.capture(self.snapshot, self.target_oid, local, self.working_signature, session_id=self.session_id, mode=self.mode)
192
+ self.intent = replace(self.intent, working_paths=self.working_paths, reset_target_paths=self.reset_target_paths)
193
+ except ValueError as error:
194
+ self.status_label.setText(str(error))
195
+ self.submit_button.setEnabled(self.intent is not None and self.metadata_ready)
196
+
197
+ def _submit(self) -> None:
198
+ """Emit the displayed reset once; the operation owner retains its native lifetime."""
199
+ if self.intent is None or self.busy or not self.metadata_ready or self.confirmation is not None:
200
+ return
201
+ if self.mode == ResetMode.HARD:
202
+ question = QMessageBox(QMessageBox.Icon.Warning, RESET_TITLE, "You are about to discard ALL local changes, are you sure?",
203
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self)
204
+ prepare_message_box(question, RESET_TITLE)
205
+ question.setTextFormat(Qt.TextFormat.PlainText)
206
+ question.setDefaultButton(QMessageBox.StandardButton.No)
207
+ question.setEscapeButton(QMessageBox.StandardButton.No)
208
+ self.confirmation = question
209
+ self.confirmation_intent = self.intent
210
+ question.finished.connect(self._answer_hard)
211
+ open_window_modal(question)
212
+ return
213
+ self._launch()
214
+
215
+ def _answer_hard(self, result: int) -> None:
216
+ """Only explicit Yes authorizes the captured Hard intent, without rereading over it."""
217
+ question = self.confirmation
218
+ captured = self.confirmation_intent
219
+ self.confirmation = None
220
+ self.confirmation_intent = None
221
+ if question is not None:
222
+ question.deleteLater()
223
+ if self.closed or result != QMessageBox.StandardButton.Yes or captured is None or self.intent != captured:
224
+ return
225
+ self.intent = replace(captured, hard_confirmed=True)
226
+ self._launch()
227
+
228
+ def _launch(self) -> None:
229
+ """Freeze controls after any required confirmation and submit to the common owner."""
230
+ self.busy = True
231
+ self.submit_button.setEnabled(False)
232
+ self.mode_box.setEnabled(False)
233
+ self.status_label.setText("Running Git…")
234
+ self.reset_requested.emit(self.intent)
235
+
236
+ def show_error(self, cause: str) -> None:
237
+ """Keep a read error visible without turning it into absent metadata."""
238
+ self.status_label.setText(cause)
239
+ self.submit_button.setEnabled(False)
240
+
241
+ def show_outcome(self, outcome: OperationOutcome) -> None:
242
+ """Retain native refusal details and the chosen mode without destructive fallback."""
243
+ self.busy = False
244
+ self.completed = outcome.status in (OperationStatus.SUCCESS, OperationStatus.PARTIAL, OperationStatus.UNKNOWN)
245
+ lines: list[str] = []
246
+ for step in outcome.steps:
247
+ lines.append(step.operation + ": " + step.status.value + " — " + step.effect)
248
+ if outcome.diagnostic is not None:
249
+ lines.append(outcome.diagnostic.detail)
250
+ self.status_label.setText("\n".join(lines))
251
+ self.mode_box.setEnabled(not self.completed)
252
+ self.cancel_button.setText("Close")
253
+ self._update_intent()
254
+ if outcome.status == OperationStatus.SUCCESS:
255
+ self.accept()
256
+
257
+ def _closed(self) -> None:
258
+ """Invalidate late reads and return keyboard focus to the launching window."""
259
+ self.closed = True
260
+ if self.confirmation is not None:
261
+ self.confirmation.reject()
262
+ if self.return_focus is not None and isValid(self.return_focus):
263
+ self.return_focus.setFocus()
@@ -0,0 +1,121 @@
1
+ """Reproduce the Git Extensions "Choose another revision" window, which Cherry-pick shares with the reference forms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QEvent, QObject, Qt, Signal
8
+ from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget
9
+
10
+ from gitpane.config import REPLAY_DIALOG_HEIGHT, REPLAY_DIALOG_WIDTH
11
+ from gitpane.text.typed_text import typed_value
12
+ from gitpane.ui.widget.dialog_layout import fit_form
13
+ from gitpane.ui.widget.long_text import WrappingLabel
14
+ from gitpane.ui.window_control.item_activation import activate_current_row
15
+
16
+ if TYPE_CHECKING:
17
+ from gitpane.history.repository_value import Commit
18
+
19
+
20
+ class RevisionChoiceDialog(QDialog):
21
+ """Search the commits already loaded, or take any typed revision, to choose another commit for a form.
22
+
23
+ The window never starts Git: a click in the list writes the commit's full ID, and Apply hands the text to its
24
+ owner, which resolves it and answers with ``accept`` or ``show_error``.
25
+ """
26
+
27
+ revision_requested = Signal(str)
28
+
29
+ def __init__(self, commits: list[Commit], parent: QWidget) -> None:
30
+ """Keep revision validation asynchronous and cancellation independent of its result."""
31
+ super().__init__(parent)
32
+ self.setWindowTitle("Choose another revision")
33
+ self.setModal(True)
34
+ self.resize(REPLAY_DIALOG_WIDTH, REPLAY_DIALOG_HEIGHT)
35
+ self.generation = 0
36
+ self.closed = False
37
+ self.commits = tuple(commits)
38
+ layout = QVBoxLayout(self)
39
+ label = QLabel("Revision or commit hash:")
40
+ self.revision_edit = QLineEdit()
41
+ label.setBuddy(self.revision_edit)
42
+ layout.addWidget(label)
43
+ layout.addWidget(self.revision_edit)
44
+ self.commit_list = QListWidget()
45
+ self.commit_list.setAccessibleName("Available commits")
46
+ layout.addWidget(self.commit_list)
47
+ self.status_label = WrappingLabel("")
48
+ layout.addWidget(self.status_label)
49
+ actions = QHBoxLayout()
50
+ actions.addStretch()
51
+ self.apply_button = QPushButton("Apply")
52
+ self.apply_button.setDefault(True)
53
+ self.apply_button.setEnabled(False)
54
+ self.cancel_button = QPushButton("Cancel")
55
+ actions.addWidget(self.apply_button)
56
+ actions.addWidget(self.cancel_button)
57
+ layout.addLayout(actions)
58
+ self.revision_edit.textChanged.connect(self._revision_changed)
59
+ self.commit_list.itemClicked.connect(self._choose_commit)
60
+ self.commit_list.itemActivated.connect(self._choose_commit)
61
+ self.commit_list.installEventFilter(self)
62
+ self.apply_button.clicked.connect(self._apply)
63
+ self.cancel_button.clicked.connect(self.reject)
64
+ self.finished.connect(self._closed)
65
+ self._revision_changed("")
66
+ fit_form(self)
67
+ self.revision_edit.setFocus()
68
+
69
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
70
+ """Choose the current commit on Enter in the list, then let the key reach the default Apply, on every system.
71
+
72
+ Qt for macOS does not activate a list row on Enter, so Apply would read the revision the field held before.
73
+ """
74
+ if watched is self.commit_list and activate_current_row(self.commit_list, event):
75
+ return True
76
+ return super().eventFilter(watched, event)
77
+
78
+ def _revision_changed(self, text: str) -> None:
79
+ """Invalidate older reads immediately and filter local captions without contacting Git.
80
+
81
+ The field holds a revision, not a free search, so the list is filtered on the revision without the blanks
82
+ around it, the same text Apply asks for.
83
+ """
84
+ self.generation += 1
85
+ self.status_label.setText("")
86
+ revision = typed_value(text)
87
+ self.apply_button.setEnabled(bool(revision))
88
+ self.commit_list.clear()
89
+ search = revision.casefold()
90
+ for commit in self.commits:
91
+ subject, _separator, _body = commit.message.partition("\n")
92
+ caption = commit.oid + " " + subject
93
+ if search in caption.casefold():
94
+ item = QListWidgetItem(caption)
95
+ item.setData(Qt.ItemDataRole.UserRole, commit.oid)
96
+ item.setToolTip(caption)
97
+ self.commit_list.addItem(item)
98
+
99
+ def _choose_commit(self, item: QListWidgetItem) -> None:
100
+ """Use the full identity of a selected search result, never its caption."""
101
+ self.revision_edit.setText(item.data(Qt.ItemDataRole.UserRole))
102
+
103
+ def _apply(self) -> None:
104
+ """Request one read while allowing edits or cancellation to invalidate it."""
105
+ revision = typed_value(self.revision_edit.text())
106
+ if self.closed or not revision or not self.apply_button.isEnabled():
107
+ return
108
+ self.generation += 1
109
+ self.apply_button.setEnabled(False)
110
+ self.status_label.setText("Reading revision…")
111
+ self.revision_requested.emit(revision)
112
+
113
+ def show_error(self, cause: str = "Cannot read this local commit. Check the revision or hash and try again.") -> None:
114
+ """Keep the previous source and allow correction without exposing raw Git output."""
115
+ self.status_label.setText(cause)
116
+ self.apply_button.setEnabled(bool(typed_value(self.revision_edit.text())))
117
+
118
+ def _closed(self) -> None:
119
+ """Invalidate outstanding validation when the selector finishes."""
120
+ self.closed = True
121
+ self.generation += 1
@@ -0,0 +1,227 @@
1
+ """Show a target commit the way the Git Extensions revision field does: [short hash][choose button] -N."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer, Signal
8
+ from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
9
+
10
+ from gitpane.config import ICON_SIDE, REVISION_INPUT_PAUSE_MS
11
+ from gitpane.text.typed_text import typed_value
12
+ from gitpane.ui.look.action_icon import action_icon
13
+ from gitpane.ui.look.windows_control_look import apply_secondary_text_color, apply_windows_button_look, apply_windows_line_edit_look
14
+ from gitpane.ui.reference.commit_summary import GIT_EXTENSIONS_REVISION_PREFIX_LENGTH
15
+
16
+ if TYPE_CHECKING:
17
+ from gitpane.ui.reference.revision_dialog import RevisionChoiceDialog
18
+
19
+ # The small "choose another revision" button of the Git Extensions forms (visible size, border included) and its name.
20
+ REVISION_BUTTON_SIZE = QSize(23, 22)
21
+ REVISION_CHOICE_NAME = "Choose another revision"
22
+ # Spacing measured on the Checkout revision capture: the button sits 4 px after the field and 1 px higher than it,
23
+ # and the commit counter starts 10 px after the button.
24
+ FIELD_TO_BUTTON_SPACE = 4
25
+ BUTTON_RAISE = 1
26
+ BUTTON_TO_COUNT_SPACE = 10
27
+
28
+
29
+ class RevisionPicker(QWidget):
30
+ """Row that shows one commit as its short hash, a button to choose another one, and its position from HEAD.
31
+
32
+ The widget only displays and reports; it never starts Git. The reference form controller resolves what the user types or
33
+ chooses and answers with ``show_revision`` and ``show_count``.
34
+
35
+ Typing does not resolve anything on each key: the text is reported once the user pauses, and again when the
36
+ field is left with a text that has no proven commit yet. ``revision_edited`` carries the text and whether the
37
+ field was left. While a typed text is unresolved the widget has no target, so the form cannot act on a commit
38
+ the field no longer shows. ``resolution_generation`` changes with each key and each shown target, so an answer
39
+ for an older text can be recognized and dropped. Leaving the field while the text reported at the pause is still
40
+ being resolved reports nothing more: ``settle_generation`` asks the coming answer to settle the field instead.
41
+ """
42
+
43
+ revision_edited = Signal(str, bool)
44
+ choose_requested = Signal()
45
+ target_changed = Signal(object)
46
+
47
+ def __init__(self, field_width: int, parent: QWidget | None = None) -> None:
48
+ """Build the field, the choose button and the counter at the sizes and colors of the reproduced window."""
49
+ super().__init__(parent)
50
+ self.target_oid: str | None = None
51
+ # The "Choose another revision" window the choose button opened, while it is open.
52
+ self.revision_dialog: RevisionChoiceDialog | None = None
53
+ # The last commit this field proved, shown again when the user leaves the field with a text that is none.
54
+ self.last_target_oid: str | None = None
55
+ self.resolution_generation = 0
56
+ # The last text the reference form controller answered and its commit, None when that text is not a revision.
57
+ self.answered_text: str | None = None
58
+ self.answered_oid: str | None = None
59
+ # The last text reported at a typing pause, and the resolution generation it was reported under.
60
+ self.reported_text: str | None = None
61
+ self.reported_generation: int | None = None
62
+ # The resolution generation whose answer, still awaited when the field was left, must settle the field.
63
+ self.settle_generation: int | None = None
64
+ self.pause_timer = QTimer(self)
65
+ self.pause_timer.setSingleShot(True)
66
+ self.pause_timer.setInterval(REVISION_INPUT_PAUSE_MS)
67
+ self.pause_timer.timeout.connect(self._typing_paused)
68
+ self.revision_edit = QLineEdit()
69
+ self.revision_edit.setFixedWidth(field_width)
70
+ self.revision_edit.setAccessibleName("Revision")
71
+ self.choose_button = QPushButton()
72
+ self.choose_button.setIcon(action_icon("choose_revision", self.devicePixelRatioF()))
73
+ self.choose_button.setIconSize(QSize(ICON_SIDE, ICON_SIDE))
74
+ self.choose_button.setFixedSize(REVISION_BUTTON_SIZE)
75
+ self.choose_button.setAccessibleName(REVISION_CHOICE_NAME)
76
+ self.choose_button.setToolTip(REVISION_CHOICE_NAME)
77
+ # Only the form's submit button may be the default one; see the Enter handling of the reference forms.
78
+ self.choose_button.setAutoDefault(False)
79
+ self.choose_button.clicked.connect(self.choose_requested)
80
+ self.count_label = QLabel()
81
+ self.count_label.setAccessibleName("Commits relative to HEAD")
82
+ self.apply_theme_look()
83
+ # The field and the counter start one pixel lower than the button, which is how the capture places them.
84
+ field_column = QVBoxLayout()
85
+ field_column.setContentsMargins(0, BUTTON_RAISE, 0, 0)
86
+ field_column.setSpacing(0)
87
+ field_column.addWidget(self.revision_edit)
88
+ count_column = QVBoxLayout()
89
+ count_column.setContentsMargins(0, BUTTON_RAISE, 0, 0)
90
+ count_column.setSpacing(0)
91
+ count_column.addWidget(self.count_label)
92
+ row = QHBoxLayout(self)
93
+ row.setContentsMargins(0, 0, 0, 0)
94
+ row.setSpacing(0)
95
+ row.addLayout(field_column)
96
+ row.addSpacing(FIELD_TO_BUTTON_SPACE)
97
+ row.addWidget(self.choose_button, 0, Qt.AlignmentFlag.AlignTop)
98
+ row.addSpacing(BUTTON_TO_COUNT_SPACE)
99
+ row.addLayout(count_column)
100
+ row.addStretch(1)
101
+ # textEdited only reports the user's own changes: showing a resolved commit never starts another resolution.
102
+ self.revision_edit.textEdited.connect(self._text_edited)
103
+ self.revision_edit.editingFinished.connect(self._editing_finished)
104
+ self.revision_edit.installEventFilter(self)
105
+
106
+ def apply_theme_look(self) -> None:
107
+ """Lay the light or dark look of the field, the choose button and the counter, after the application palette.
108
+
109
+ The picker does not follow the theme on its own: it is not in its window yet when it is built, so the form that
110
+ holds it calls this from its own theme look. Only style sheets and palettes change; the text and target stay.
111
+ """
112
+ apply_windows_line_edit_look(self.revision_edit)
113
+ apply_windows_button_look(self.choose_button)
114
+ apply_secondary_text_color(self.count_label)
115
+
116
+ def show_revision(self, oid: str, *, rewrite_text: bool = True) -> None:
117
+ """Target a full commit ID and remember it as the last proven one; its position is cleared until counted.
118
+
119
+ With ``rewrite_text`` the field shows the commit's first eight digits, all selected so typing replaces them;
120
+ without it the text the user is typing stays as it is, since the commit only answers a typing pause.
121
+ """
122
+ self.pause_timer.stop()
123
+ self.resolution_generation += 1
124
+ self.target_oid = oid
125
+ self.last_target_oid = oid
126
+ if rewrite_text:
127
+ self.revision_edit.setText(oid[:GIT_EXTENSIONS_REVISION_PREFIX_LENGTH])
128
+ self.revision_edit.selectAll()
129
+ self.answered_text = typed_value(self.revision_edit.text())
130
+ self.answered_oid = oid
131
+ self.show_count("")
132
+ self.target_changed.emit(oid)
133
+
134
+ def show_invalid(self) -> None:
135
+ """Show that the typed text names no commit: no target, no position, and the refusal kept for that text."""
136
+ self.target_oid = None
137
+ self.answered_text = typed_value(self.revision_edit.text())
138
+ self.answered_oid = None
139
+ self.show_count("")
140
+ self.target_changed.emit(None)
141
+
142
+ def restore_last_target(self) -> None:
143
+ """Show the last proven commit again, as its short hash, after the field was left with a text naming none."""
144
+ if self.last_target_oid is None:
145
+ return
146
+ self.show_revision(self.last_target_oid)
147
+
148
+ def show_count(self, text: str) -> None:
149
+ """Show the commit's position from HEAD, for example "-13", "+2", "=" or nothing when it is unknown."""
150
+ self.count_label.setText(text)
151
+
152
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
153
+ """Let Enter settle a text that has no commit yet without also running the form's action.
154
+
155
+ The field would pass Enter on to the form, whose default button would then act on whatever commit the field
156
+ had just been settled on, or restored to, while the user was still correcting it. Enter on a proven commit
157
+ still reaches the form.
158
+ """
159
+ if (watched is self.revision_edit and event.type() == QEvent.Type.KeyPress and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
160
+ and self.target_oid is None):
161
+ self._editing_finished()
162
+ return True
163
+ return super().eventFilter(watched, event)
164
+
165
+ def _text_edited(self, _text: str) -> None:
166
+ """Forget the target the field no longer shows and wait for a pause in typing before reporting the text."""
167
+ self.resolution_generation += 1
168
+ self.pause_timer.start()
169
+ if self.target_oid is None:
170
+ return
171
+ self.target_oid = None
172
+ self.show_count("")
173
+ self.target_changed.emit(None)
174
+
175
+ def _typing_paused(self) -> None:
176
+ """Report the typed text once the user has stopped typing, unless it is the text answered last.
177
+
178
+ Typing a character and erasing it gives back a text whose answer is already known: that answer is shown again
179
+ without asking the reference form controller, so no Git process is started for a text resolved a moment ago.
180
+ """
181
+ text = typed_value(self.revision_edit.text())
182
+ if text != self.answered_text:
183
+ self.reported_text = text
184
+ self.reported_generation = self.resolution_generation
185
+ self.revision_edited.emit(text, False)
186
+ return
187
+ if self.answered_oid is not None:
188
+ self.show_revision(self.answered_oid, rewrite_text=False)
189
+
190
+ def _editing_finished(self) -> None:
191
+ """Settle the field when the user leaves it or presses Enter.
192
+
193
+ A commit already proven for the typed text is shown as its short hash, like Git Extensions does. A text still
194
+ without a commit is reported as final, so the reference form controller either takes it or restores the last commit. That
195
+ report is remembered like a pause's report, so leaving the field right after Enter waits for its answer instead
196
+ of resolving the same text a second time.
197
+ """
198
+ self.pause_timer.stop()
199
+ text = typed_value(self.revision_edit.text())
200
+ if self.target_oid is None and text == self.answered_text and self.answered_oid is not None:
201
+ self.show_revision(self.answered_oid)
202
+ return
203
+ if self.target_oid is not None:
204
+ # Only the text changes: the target, and the position already shown or still being counted, stay.
205
+ short_text = self.target_oid[:GIT_EXTENSIONS_REVISION_PREFIX_LENGTH]
206
+ if text != short_text:
207
+ self.revision_edit.setText(short_text)
208
+ self.answered_text = short_text
209
+ return
210
+ if self.is_awaiting_answer(text):
211
+ # The text reported at the pause is still being resolved: its answer settles the field when it arrives, so
212
+ # the same text is never resolved a second time.
213
+ self.settle_generation = self.resolution_generation
214
+ return
215
+ self.resolution_generation += 1
216
+ self.reported_text = text
217
+ self.reported_generation = self.resolution_generation
218
+ self.settle_generation = self.resolution_generation
219
+ self.revision_edited.emit(text, True)
220
+
221
+ def is_awaiting_answer(self, text: str) -> bool:
222
+ """Tell whether this text was reported at the last typing pause and its answer has not arrived yet.
223
+
224
+ Any key typed since, and any target shown since, changes the resolution generation, so an older report never
225
+ counts as awaited.
226
+ """
227
+ return text == self.reported_text and self.reported_generation == self.resolution_generation and text != self.answered_text