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,419 @@
1
+ """Present captured branches without writing until the explicit Checkout action."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QRect, Qt, Signal
8
+ from PySide6.QtWidgets import QButtonGroup, QDialog, QFrame, QHBoxLayout, QLayout, QLineEdit, QPushButton, QRadioButton, QScrollArea, QVBoxLayout, QWidget
9
+ from shiboken6 import isValid
10
+
11
+ from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX, SPACING_SM
12
+ from gitpane.operation.operation_status import OperationStatus
13
+ from gitpane.reference.reference_edit import CheckoutIntent, CheckoutMode, remote_branch_name
14
+ from gitpane.text.typed_text import typed_value
15
+ from gitpane.ui.reference.branch_selector import BranchSelector
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 SelectionSummaryRow, WrappingLabel
20
+ from gitpane.ui.widget.refresh_advice import refresh_advice_text
21
+ from gitpane.ui.widget.result_label import ResultLabel
22
+
23
+ if TYPE_CHECKING:
24
+ from PySide6.QtGui import QCloseEvent
25
+
26
+ from gitpane.operation.repository_operation import OperationOutcome
27
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
28
+
29
+ CHECKOUT_WIDTH = 800
30
+ # The height asked before the first show; once shown, the window takes the exact height of its content instead.
31
+ CHECKOUT_HEIGHT = 280
32
+ # The first remote choice: create the local branch, or, when a local branch of that name already exists, move it onto
33
+ # the remote branch the way Git Extensions words it.
34
+ CREATE_SAME_NAME_TEXT = "Create local branch with same name:"
35
+ RESET_LOCAL_BRANCH_TEXT = "Reset local branch with the name:"
36
+ # The orange text under the choices telling how many local commits a reset would lose; the click on Checkout confirms.
37
+ LOST_COMMITS_WARNING_TEMPLATE = "{branch} has {count} commits that are not in {remote}. They will be lost."
38
+ LOST_COMMIT_WARNING_TEMPLATE = "{branch} has 1 commit that is not in {remote}. It will be lost."
39
+ LOST_COMMIT_FAILURE_TEMPLATE = "Commits a reset would lose: unavailable ({cause})"
40
+
41
+
42
+ class CheckoutDialog(ContentGrowthMixin, QDialog):
43
+ """Collect an explicit branch checkout while the reference form controller owns asynchronous Git work.
44
+
45
+ Checking out a single commit has its own Checkout revision window; this one only lists branches.
46
+ """
47
+
48
+ selection_changed = Signal(str)
49
+ # Local commit ID, remote commit ID: asks how many commits the local branch has that the remote one lacks.
50
+ lost_commit_count_requested = Signal(str, str)
51
+ checkout_requested = Signal(object)
52
+
53
+ def __init__(self, session_id: str, parent: QWidget | None = None) -> None:
54
+ """Keep actions outside the scrolling content and preserve focus on closure."""
55
+ super().__init__(parent)
56
+ self.session_id = session_id
57
+ self.snapshot: ReferenceSnapshot | None = None
58
+ self.intent: CheckoutIntent | None = None
59
+ self.closed = False
60
+ self.busy = False
61
+ self.read_generation = 0
62
+ self.comparison_ready = False
63
+ # True while the selected remote branch has a local branch of the same name, which the first choice resets.
64
+ self.resets_local_branch = False
65
+ # How many commits the reset would lose, None while unknown for the current selection.
66
+ self.lost_commit_count: int | None = None
67
+ # Why that count could not be read, shown in orange under the choices and in the comparison tooltip; empty when nothing failed.
68
+ self.lost_commit_cause = ""
69
+ self.difference_tooltip = ""
70
+ self.return_focus = parent.focusWidget() if parent is not None else None
71
+ self.setWindowTitle("Checkout branch")
72
+ self.resize(CHECKOUT_WIDTH, CHECKOUT_HEIGHT)
73
+ layout = QVBoxLayout(self)
74
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
75
+ self.scroll_area = QScrollArea()
76
+ self.scroll_area.setWidgetResizable(True)
77
+ content = QWidget()
78
+ form = QVBoxLayout(content)
79
+ form.setSpacing(SPACING_SM)
80
+ kinds = QHBoxLayout()
81
+ self.local_radio = QRadioButton("Local branch")
82
+ self.remote_radio = QRadioButton("Remote branch")
83
+ self.kind_group = QButtonGroup(self)
84
+ self.kind_group.addButton(self.local_radio)
85
+ self.kind_group.addButton(self.remote_radio)
86
+ self.local_radio.setChecked(True)
87
+ kinds.addWidget(self.local_radio)
88
+ kinds.addWidget(self.remote_radio)
89
+ kinds.addStretch()
90
+ form.addLayout(kinds)
91
+ self.branch_selector = BranchSelector()
92
+ label = WrappingLabel("Select branch")
93
+ label.setBuddy(self.branch_selector)
94
+ self.difference_label = WrappingLabel("Commit difference unavailable")
95
+ self.selection_row = SelectionSummaryRow(label, self.branch_selector, self.difference_label)
96
+ form.addWidget(self.selection_row)
97
+ self.target_label = WrappingLabel("Loading references…")
98
+ self.target_label.setTextFormat(Qt.TextFormat.PlainText)
99
+ self.target_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
100
+ self.target_label.setWordWrap(True)
101
+ form.addWidget(self.target_label)
102
+ self.separator = QFrame()
103
+ self.separator.setFrameShape(QFrame.Shape.HLine)
104
+ self.separator.setFrameShadow(QFrame.Shadow.Sunken)
105
+ form.addWidget(self.separator)
106
+ self.remote_options = QWidget()
107
+ options = QVBoxLayout(self.remote_options)
108
+ options.setContentsMargins(0, 0, 0, 0)
109
+ self.same_radio = QRadioButton(CREATE_SAME_NAME_TEXT)
110
+ self.same_name_label = WrappingLabel()
111
+ self.same_name_label.setTextFormat(Qt.TextFormat.PlainText)
112
+ self.same_name_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
113
+ self.same_name_label.setWordWrap(True)
114
+ same_row = QHBoxLayout()
115
+ same_row.addWidget(self.same_radio)
116
+ same_row.addWidget(self.same_name_label, 1)
117
+ options.addLayout(same_row)
118
+ self.custom_radio = QRadioButton("Create local branch with custom name:")
119
+ self.custom_edit = QLineEdit()
120
+ self.custom_edit.setAccessibleName("Custom local branch name")
121
+ self.custom_edit.setEnabled(False)
122
+ custom_row = QHBoxLayout()
123
+ custom_row.addWidget(self.custom_radio)
124
+ custom_row.addWidget(self.custom_edit, 1)
125
+ options.addLayout(custom_row)
126
+ self.detached_radio = QRadioButton("Checkout the commit (in detached head)")
127
+ options.addWidget(self.detached_radio)
128
+ self.lost_commit_label = ResultLabel()
129
+ self.lost_commit_label.hide()
130
+ options.addWidget(self.lost_commit_label)
131
+ self.mode_group = QButtonGroup(self)
132
+ for radio in (self.same_radio, self.custom_radio, self.detached_radio):
133
+ self.mode_group.addButton(radio)
134
+ self.same_radio.setChecked(True)
135
+ self.remote_options.hide()
136
+ form.addWidget(self.remote_options)
137
+ self.status_label = WrappingLabel()
138
+ self.status_label.setTextFormat(Qt.TextFormat.PlainText)
139
+ self.status_label.setWordWrap(True)
140
+ self.status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
141
+ form.addWidget(self.status_label)
142
+ form.addStretch()
143
+ self.scroll_area.setWidget(content)
144
+ # The content changes after the window opened (references read, commit difference, remote choices, orange lost
145
+ # commit text, error) and the window follows it both ways by the rule it shares with Clone, never below the height
146
+ # it opened at or the one the user set; once shown, it takes the exact height of the content it opens with,
147
+ # without empty room under the choices.
148
+ self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.EXACT, CHECKOUT_HEIGHT)
149
+ layout.addWidget(self.scroll_area, 1)
150
+ actions = QHBoxLayout()
151
+ actions.addStretch()
152
+ self.checkout_button = QPushButton("Checkout")
153
+ self.checkout_button.setEnabled(False)
154
+ self.checkout_button.setAutoDefault(False)
155
+ self.cancel_button = QPushButton("Cancel")
156
+ actions.addWidget(self.checkout_button)
157
+ actions.addWidget(self.cancel_button)
158
+ layout.addLayout(actions)
159
+ self.cancel_button.clicked.connect(self.reject)
160
+ self.checkout_button.clicked.connect(self._submit)
161
+ self.branch_selector.currentIndexChanged.connect(self._selection_changed)
162
+ self.local_radio.clicked.connect(lambda: self._select_kind(LOCAL_PREFIX))
163
+ self.remote_radio.clicked.connect(lambda: self._select_kind(REMOTE_PREFIX))
164
+ self.mode_group.buttonClicked.connect(self._mode_changed)
165
+ self.custom_edit.textChanged.connect(self._mode_changed)
166
+ self.finished.connect(self._closed)
167
+ self.refresh_metrics()
168
+
169
+ def refresh_metrics(self, available: QRect | None = None) -> None:
170
+ """Apply the shared form metrics and keep the window inside the usable screen area.
171
+
172
+ Called when the window is built, shown, and moved to another screen or given another font. The height follows
173
+ the content through the growth rule shared with Clone, not here.
174
+ """
175
+ fit_form(self, available)
176
+
177
+ def populate(self, snapshot: ReferenceSnapshot, full_ref: str) -> None:
178
+ """Publish only the captured selection and begin its independent comparison."""
179
+ self.snapshot = snapshot
180
+ self.branch_selector.populate(snapshot, full_ref)
181
+ self._selection_changed()
182
+
183
+ def _select_kind(self, prefix: str) -> None:
184
+ """Choose an actual reference of the requested kind, or an explicit empty selection."""
185
+ for index in range(self.branch_selector.count()):
186
+ full_ref = self.branch_selector.itemData(index)
187
+ if full_ref and full_ref.startswith(prefix):
188
+ self.branch_selector.setCurrentIndex(index)
189
+ return
190
+ self.branch_selector.setCurrentIndex(-1)
191
+
192
+ def _mode_changed(self) -> None:
193
+ """Update the draft mode without replacing the selected reference or OID comparison."""
194
+ self.custom_edit.setEnabled(self.custom_radio.isChecked() and not self.busy)
195
+ self._show_lost_commit_warning()
196
+ self._make_intent()
197
+ self._update_checkout_button()
198
+
199
+ def _reset_count_pending(self) -> bool:
200
+ """Tell that the reset of the local branch is chosen while neither the number of commits it loses nor why that number failed has arrived."""
201
+ return self.resets_local_branch and self.same_radio.isChecked() and self.lost_commit_count is None and not self.lost_commit_cause
202
+
203
+ def _update_checkout_button(self) -> None:
204
+ """Allow Checkout only for a complete choice whose commits were compared, and for a reset only once its lost commits are shown.
205
+
206
+ A reset moves the local branch and drops its own commits, so the click must come after the user could read
207
+ how many there are, or why that number is unknown.
208
+ """
209
+ self.checkout_button.setEnabled(self.intent is not None and self.comparison_ready and not self.busy and not self._reset_count_pending())
210
+
211
+ def _make_intent(self) -> None:
212
+ """Build an immutable intention from exact item data and the explicit remote mode.
213
+
214
+ The local name is taken without the blanks around it; the field keeps what was typed.
215
+ """
216
+ self.intent = None
217
+ full_ref = self.branch_selector.currentData()
218
+ if self.snapshot is None or not full_ref:
219
+ return
220
+ mode = CheckoutMode.LOCAL
221
+ if full_ref.startswith(REMOTE_PREFIX):
222
+ mode = CheckoutMode.CUSTOM if self.custom_radio.isChecked() else CheckoutMode.DETACHED if self.detached_radio.isChecked() else CheckoutMode.RESET_LOCAL if self.resets_local_branch else CheckoutMode.SAME
223
+ local_name = typed_value(self.custom_edit.text())
224
+ try:
225
+ self.intent = CheckoutIntent.capture(self.snapshot, full_ref, refresh_advice=refresh_advice_text, session_id=self.session_id, mode=mode, local_name=local_name)
226
+ if mode == CheckoutMode.CUSTOM and not local_name:
227
+ self.intent = None
228
+ except ValueError as error:
229
+ self.show_error(str(error))
230
+
231
+ def _selection_changed(self) -> None:
232
+ """Invalidate old comparison output whenever a different reference is selected."""
233
+ self.intent = None
234
+ self.comparison_ready = False
235
+ self.read_generation += 1
236
+ self.checkout_button.setEnabled(False)
237
+ self.difference_label.setText("Commit difference unavailable")
238
+ self._set_difference_tooltip("")
239
+ self.lost_commit_count = None
240
+ self.lost_commit_cause = ""
241
+ self._show_lost_commit_warning()
242
+ full_ref = self.branch_selector.currentData()
243
+ if self.snapshot is None or not full_ref:
244
+ self.remote_options.hide()
245
+ self.target_label.setText("Select a branch to check out")
246
+ return
247
+ remote = full_ref.startswith(REMOTE_PREFIX)
248
+ self.remote_options.setVisible(remote)
249
+ self.remote_radio.setChecked(remote)
250
+ self.local_radio.setChecked(not remote)
251
+ self.target_label.setText(full_ref)
252
+ self.resets_local_branch = False
253
+ if remote:
254
+ self._show_local_branch_choice(full_ref)
255
+ self._make_intent()
256
+ self.difference_label.setText("Comparing commits…")
257
+ if self.intent is not None:
258
+ self.selection_changed.emit(self.intent.target_oid)
259
+ if self.resets_local_branch and self.intent is not None:
260
+ local_oid = self.snapshot.object_oid_by_name[LOCAL_PREFIX + remote_branch_name(self.snapshot, full_ref)]
261
+ self.lost_commit_count_requested.emit(local_oid, self.intent.target_oid)
262
+
263
+ def show_lost_commits(self, count: int) -> None:
264
+ """Keep how many commits the reset would lose, show the warning when there are some, and allow Checkout once known."""
265
+ self.lost_commit_count = count
266
+ self._show_lost_commit_warning()
267
+ self._update_checkout_button()
268
+
269
+ def show_lost_commit_failure(self, cause: str) -> None:
270
+ """Show under the choices, in orange, why Git could not count the commits the reset would lose, then allow Checkout.
271
+
272
+ The cause also stays in the comparison tooltip. The user has read that the number is unknown, so the click on
273
+ Checkout is an informed one.
274
+ """
275
+ self.lost_commit_cause = cause
276
+ self._set_difference_tooltip(self.difference_tooltip)
277
+ self._show_lost_commit_warning()
278
+ self._update_checkout_button()
279
+
280
+ def _show_lost_commit_warning(self) -> None:
281
+ """Show or hide the orange lost commit text; the window grows to show it whole, never needing to scroll for it."""
282
+ text = self._lost_commit_warning_text()
283
+ if text:
284
+ self.lost_commit_label.show_warning(text)
285
+ self.lost_commit_label.show()
286
+ else:
287
+ self.lost_commit_label.clear()
288
+ self.lost_commit_label.hide()
289
+
290
+ def _lost_commit_warning_text(self) -> str:
291
+ """Return the orange text while the reset choice is checked: the cause of a failed count, or a known non-zero count; empty otherwise."""
292
+ if not self.resets_local_branch or not self.same_radio.isChecked():
293
+ return ""
294
+ if self.lost_commit_cause:
295
+ return LOST_COMMIT_FAILURE_TEMPLATE.format(cause=self.lost_commit_cause)
296
+ count = self.lost_commit_count
297
+ if not count:
298
+ return ""
299
+ full_ref = self.branch_selector.currentData() or ""
300
+ remote = full_ref.removeprefix(REMOTE_PREFIX)
301
+ template = LOST_COMMIT_WARNING_TEMPLATE if count == 1 else LOST_COMMITS_WARNING_TEMPLATE
302
+ return template.format(branch=self.same_name_label.text(), count=count, remote=remote)
303
+
304
+ def _set_difference_tooltip(self, text: str) -> None:
305
+ """Set the comparison tooltip, followed by why the lost commit count is unknown when it failed."""
306
+ self.difference_tooltip = text
307
+ lines = [text] if text else []
308
+ if self.lost_commit_cause:
309
+ lines.append(LOST_COMMIT_FAILURE_TEMPLATE.format(cause=self.lost_commit_cause))
310
+ self.difference_label.setToolTip("\n".join(lines))
311
+
312
+ def _show_local_branch_choice(self, full_ref: str) -> None:
313
+ """Word the first remote choice after the local branch of the same name, and choose the reset when that branch exists.
314
+
315
+ Git Extensions does the same: checking out ``origin/X`` while ``X`` exists moves ``X`` onto it rather than
316
+ failing on a name already taken. A branch without a local one keeps whatever choice the user made.
317
+ """
318
+ self.same_radio.setText(CREATE_SAME_NAME_TEXT)
319
+ try:
320
+ name = remote_branch_name(self.snapshot, full_ref)
321
+ except ValueError as error:
322
+ self.same_name_label.setText(str(error))
323
+ return
324
+ self.same_name_label.setText(name)
325
+ if LOCAL_PREFIX + name not in self.snapshot.object_oid_by_name:
326
+ return
327
+ self.resets_local_branch = True
328
+ self.same_radio.setText(RESET_LOCAL_BRANCH_TEXT)
329
+ self.same_radio.setChecked(True)
330
+ self.custom_edit.setEnabled(False)
331
+
332
+ def show_difference(self, difference: tuple[int, int] | None, cause: str = "") -> None:
333
+ """Distinguish an unknown comparison from two real zero counts.
334
+
335
+ The explanation of a first checkout arrives once the window is open and wraps over several lines at a large text
336
+ size; the window grows to show it, like it does for the other content arriving late.
337
+ """
338
+ if difference is None:
339
+ self.comparison_ready = False
340
+ self.difference_label.setText("Commit difference unavailable")
341
+ self._set_difference_tooltip(cause)
342
+ self.checkout_button.setEnabled(False)
343
+ return
344
+ left, right = difference
345
+ self.comparison_ready = True
346
+ self.difference_label.setText(f"−{left} / +{right}")
347
+ direction = "Commits only in current HEAD / commits only in selected target"
348
+ if self.snapshot is not None and self.snapshot.head_oid is None:
349
+ self.difference_label.setText(f"Initial checkout: +{right} commits (HEAD has no commit)")
350
+ direction = "Commits in the selected target, starting from empty history"
351
+ self._set_difference_tooltip(direction)
352
+ self.difference_label.setAccessibleName(direction)
353
+ self._update_checkout_button()
354
+
355
+ def show_error(self, cause: str) -> None:
356
+ """Expose the real error; the window grows to show it, up to its share of the screen, so ordinary refusals need no scrolling."""
357
+ self.status_label.setText(cause)
358
+ self.checkout_button.setEnabled(False)
359
+
360
+ def show_outcome(self, outcome: OperationOutcome) -> None:
361
+ """Close after a successful checkout, give the form back after a refusal it can retry, freeze it after any other result.
362
+
363
+ A refused checkout stopped before Git ran, so nothing was written and the form becomes usable again, like
364
+ Checkout revision, unless its capture no longer matches the repository: a second click would only be refused
365
+ again, and the console says to reopen the form or refresh. A failed, partial or unknown checkout may have
366
+ changed files: the request stays visible, greyed, while the console explains what happened.
367
+ """
368
+ self.busy = False
369
+ self.cancel_button.setEnabled(True)
370
+ self.status_label.clear()
371
+ if outcome.status == OperationStatus.SUCCESS:
372
+ self.accept()
373
+ return
374
+ if outcome.status == OperationStatus.REFUSED and not outcome.stale_capture:
375
+ self._set_choices_enabled(True)
376
+ self._mode_changed()
377
+ else:
378
+ self._set_choices_enabled(False)
379
+ self.checkout_button.setEnabled(False)
380
+
381
+ def _set_choices_enabled(self, enabled: bool) -> None:
382
+ """Enable or disable every choice of the form together; Checkout and Cancel are handled apart."""
383
+ for widget in (self.branch_selector, self.remote_options, self.local_radio, self.remote_radio):
384
+ widget.setEnabled(enabled)
385
+
386
+ def _submit(self) -> None:
387
+ """Emit a valid explicit request once without performing Git work in the widget."""
388
+ if self.closed or self.busy or self.intent is None or not self.checkout_button.isEnabled():
389
+ return
390
+ self.busy = True
391
+ self._set_choices_enabled(False)
392
+ self.checkout_button.setEnabled(False)
393
+ self.cancel_button.setEnabled(False)
394
+ self.status_label.clear()
395
+ self.checkout_requested.emit(self.intent)
396
+
397
+ def reject(self) -> None:
398
+ """Keep the captured form visible until its native operation finishes."""
399
+ if not self.busy:
400
+ super().reject()
401
+
402
+ def accept(self) -> None:
403
+ """Prevent direct acceptance while the native operation still owns the form."""
404
+ if not self.busy:
405
+ super().accept()
406
+
407
+ def closeEvent(self, event: QCloseEvent) -> None:
408
+ """Ignore window closure while Git is still using the captured choices."""
409
+ if self.busy:
410
+ event.ignore()
411
+ return
412
+ super().closeEvent(event)
413
+
414
+ def _closed(self) -> None:
415
+ """Invalidate pending callbacks and return focus to the still-live launching control."""
416
+ self.closed = True
417
+ self.read_generation += 1
418
+ if self.return_focus is not None and isValid(self.return_focus):
419
+ self.return_focus.setFocus()
@@ -0,0 +1,182 @@
1
+ """Reproduce the Git Extensions "Checkout revision" window: one commit, an optional force, one Checkout button."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import QMargins, QSize, Qt, Signal
8
+ from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
9
+
10
+ from gitpane.operation.operation_status import OperationStatus
11
+ from gitpane.reference.reference_edit import CheckoutIntent, CheckoutMode
12
+ from gitpane.ui.look.action_icon import action_icon
13
+ from gitpane.ui.look.theme_look import follow_theme
14
+ from gitpane.ui.reference.reference_form import ReferenceFormDialog
15
+ from gitpane.ui.reference.revision_picker import RevisionPicker
16
+ from gitpane.ui.widget.dialog_layout import BandButtonSizing, fit_caption_column, fit_capture_control, install_banded_layout
17
+ from gitpane.ui.widget.refresh_advice import refresh_advice_text
18
+
19
+ if TYPE_CHECKING:
20
+ from gitpane.operation.repository_operation import OperationOutcome
21
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
22
+
23
+ CHECKOUT_REVISION_TITLE = "Checkout revision"
24
+ CHECKOUT_REVISION_CAPTION = "Checkout this revision"
25
+ FORCE_CHECKOUT_LABEL = "Force (reset local changes)"
26
+ CHECKOUT_BUTTON_TEXT = "Checkout"
27
+ # Client area of the Git Extensions capture (481 x 131 inside the window borders and title bar).
28
+ CHECKOUT_REVISION_CLIENT_SIZE = QSize(481, 131)
29
+ # Measured on the capture in client pixels at 100 %: white body y 0-89, band line y 90, band y 91-130; caption ink
30
+ # x 18-134 and y 26-34; revision field (145, 17, 260, 23) with its choose button (409, 16, 23, 22) and the "-13" ink
31
+ # from x 442; Force square (145, 49, 13, 13); Checkout button (385, 99, 82, 23) in the band.
32
+ # The body margins put the caption label at x 17 (a label writes its text about 1 px after its edge) and the top of
33
+ # the revision row, whose choose button is its highest control, at y 16. The band sits at the bottom through a final
34
+ # stretch, so enlarging the resizable window only grows the empty body.
35
+ CHECKOUT_REVISION_BODY_MARGINS = QMargins(17, 16, 14, 0)
36
+ # Caption width from its left edge to the revision field (145 - 17); a wider caption text widens it.
37
+ REVISION_CAPTION_WIDTH = 128
38
+ REVISION_FIELD_WIDTH = 260
39
+ # The caption's text sits 2 px below the middle of the revision row in the capture; a top margin twice as high
40
+ # lowers the vertically centered label by that much.
41
+ CAPTION_TEXT_DROP = 2
42
+ # Space between the revision row (y 16-39) and the Force check box, whose square then lands on y 49.
43
+ REVISION_TO_FORCE_SPACE = 6
44
+ CHECKOUT_BUTTON_SIZE = QSize(82, 23)
45
+
46
+
47
+ class CheckoutRevisionDialog(ReferenceFormDialog):
48
+ """Check out one commit in detached HEAD, optionally discarding local changes, like Git Extensions.
49
+
50
+ The window only freezes a checkout request; the reference form controller reads the references, counts the commit's position
51
+ from HEAD and runs Git.
52
+ """
53
+
54
+ checkout_requested = Signal(object)
55
+
56
+ def __init__(self, session_id: str, parent: QWidget | None = None) -> None:
57
+ """Build the caption, the revision field, the Force check box and the button band of the capture."""
58
+ super().__init__(session_id, parent)
59
+ self.intent: CheckoutIntent | None = None
60
+ self.setWindowTitle(CHECKOUT_REVISION_TITLE)
61
+ layout = QVBoxLayout()
62
+ layout.setSpacing(0)
63
+ revision_row = QHBoxLayout()
64
+ revision_row.setContentsMargins(0, 0, 0, 0)
65
+ revision_row.setSpacing(0)
66
+ self.caption_label = QLabel(CHECKOUT_REVISION_CAPTION)
67
+ self.caption_label.setContentsMargins(0, CAPTION_TEXT_DROP * 2, 0, 0)
68
+ self.picker = RevisionPicker(REVISION_FIELD_WIDTH)
69
+ self.caption_label.setBuddy(self.picker.revision_edit)
70
+ revision_row.addWidget(self.caption_label, 0, Qt.AlignmentFlag.AlignVCenter)
71
+ revision_row.addWidget(self.picker, 1)
72
+ layout.addLayout(revision_row)
73
+ layout.addSpacing(REVISION_TO_FORCE_SPACE)
74
+ self.force_checkbox = QCheckBox(FORCE_CHECKOUT_LABEL)
75
+ # Indented by the caption's width, which follows the caption's text.
76
+ self.force_row = QHBoxLayout()
77
+ self.force_row.setSpacing(0)
78
+ self.force_row.addWidget(self.force_checkbox)
79
+ self.force_row.addStretch(1)
80
+ layout.addLayout(self.force_row)
81
+ layout.addStretch(1)
82
+ self.submit_button.setText(CHECKOUT_BUTTON_TEXT)
83
+ self.submit_button.setIcon(action_icon("checkout_commit", self.devicePixelRatioF()))
84
+ self.submit_button.setEnabled(False)
85
+ # Git Extensions shows this form as a white body over a grey band holding its single button; there is no
86
+ # Cancel button, Escape and the title bar close it.
87
+ self.button_band = install_banded_layout(self, layout, [self.submit_button], body_margins=CHECKOUT_REVISION_BODY_MARGINS, button_sizing=BandButtonSizing.WINDOW)
88
+ # The revision picker laid its own look when it was built; the form only has it laid again at each switch.
89
+ follow_theme(self, self.apply_theme_look)
90
+ # Enter runs Checkout from anywhere in the form. The role is set only now, once the button belongs to the
91
+ # dialog: Qt then records it as the dialog's main default and gives it back whenever the focus moves, instead
92
+ # of dropping it the first time the button loses the focus.
93
+ self.submit_button.setDefault(True)
94
+ self.submit_button.clicked.connect(self._submit)
95
+ self.force_checkbox.toggled.connect(self._update_intent)
96
+ # Connected before the reference form controller binds the field, so a new target's position is invalidated before it is
97
+ # counted again.
98
+ self.picker.target_changed.connect(self._target_changed)
99
+ self.resize(CHECKOUT_REVISION_CLIENT_SIZE)
100
+ self.refresh_metrics()
101
+ self.picker.revision_edit.setFocus()
102
+
103
+ def apply_theme_look(self) -> None:
104
+ """Lay the light or dark look of the form's own controls, decided on the application palette; called again at each switch.
105
+
106
+ The body and the band follow the switch on their own; the revision field, its choose button and its counter are
107
+ the revision picker's. Only style sheets and palettes change: the revision shown and Force stay as they are.
108
+ """
109
+ self.picker.apply_theme_look()
110
+
111
+ def fit_capture_controls(self) -> None:
112
+ """Give the caption and the Checkout button the capture's sizes, or more when their text needs it.
113
+
114
+ A wider caption moves the revision field and the Force check box right by the same amount, so they stay lined up.
115
+ """
116
+ caption_width = fit_caption_column((self.caption_label,), REVISION_CAPTION_WIDTH)
117
+ self.force_row.setContentsMargins(caption_width, 0, 0, 0)
118
+ fit_capture_control(self.submit_button, CHECKOUT_BUTTON_SIZE)
119
+
120
+ def populate(self, snapshot: ReferenceSnapshot, target_oid: str) -> None:
121
+ """Show the commit to check out against the references the window was opened on."""
122
+ if self.closed or self.busy or self.completed:
123
+ return
124
+ self.snapshot = snapshot
125
+ self.set_target(target_oid)
126
+ self.picker.revision_edit.setFocus()
127
+
128
+ def set_target(self, oid: str, *, rewrite_text: bool = True) -> None:
129
+ """Target another commit; its short hash replaces the field unless the user's typed text is kept."""
130
+ if self.closed or self.busy or self.completed:
131
+ return
132
+ self.picker.show_revision(oid, rewrite_text=rewrite_text)
133
+
134
+ def _target_changed(self, _oid: str | None) -> None:
135
+ """Drop any position still being counted for the previous target and freeze the new request, if any."""
136
+ self.position_generation += 1
137
+ self._update_intent()
138
+
139
+ def _update_intent(self) -> None:
140
+ """Freeze the current commit and Force choice into the request Checkout would send."""
141
+ self.intent = None
142
+ target_oid = self.picker.target_oid
143
+ if self.snapshot is not None and target_oid is not None:
144
+ try:
145
+ self.intent = CheckoutIntent.capture(self.snapshot, target_oid, refresh_advice=refresh_advice_text, session_id=self.session_id, mode=CheckoutMode.DETACHED,
146
+ force_checkout=self.force_checkbox.isChecked())
147
+ except ValueError as error:
148
+ self.show_error(str(error))
149
+ return
150
+ self.submit_button.setEnabled(self.intent is not None and not self.busy and not self.completed and not self.closed)
151
+
152
+ def _submit(self) -> None:
153
+ """Freeze the form and hand its request to the reference form controller once."""
154
+ if self.closed or self.busy or self.completed or self.intent is None or not self.submit_button.isEnabled():
155
+ return
156
+ self.busy = True
157
+ self._set_controls_enabled(False)
158
+ self.checkout_requested.emit(self.intent)
159
+
160
+ def show_outcome(self, outcome: OperationOutcome) -> None:
161
+ """Close after a successful checkout, give the form back after a refusal it can retry, freeze it after any other result.
162
+
163
+ A refused checkout stopped before Git ran, so nothing was written and the form becomes usable again, like the
164
+ other reference forms, unless its capture no longer matches the repository: a second click would only be
165
+ refused again, and the console says to reopen the form or refresh. A failed, partial or unknown checkout may
166
+ have changed files: the request stays visible, greyed, while the console explains what happened.
167
+ """
168
+ self.busy = False
169
+ if outcome.status == OperationStatus.SUCCESS:
170
+ self.accept()
171
+ return
172
+ if outcome.status == OperationStatus.REFUSED and not outcome.stale_capture:
173
+ self._set_controls_enabled(True)
174
+ self._update_intent()
175
+ return
176
+ self.completed = True
177
+ self._set_controls_enabled(False)
178
+
179
+ def _set_controls_enabled(self, enabled: bool) -> None:
180
+ """Enable or disable every choice of the form together."""
181
+ for widget in (self.picker, self.force_checkbox, self.submit_button):
182
+ widget.setEnabled(enabled)