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,417 @@
1
+ """Delete one captured remote branch on its server, independently of graph navigation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QRect, QSignalBlocker, Qt, Signal
10
+ from PySide6.QtWidgets import QCheckBox, QDialog, QHBoxLayout, QLayout, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QVBoxLayout, QWidget
11
+ from shiboken6 import isValid
12
+
13
+ from gitpane.config import DELETE_BRANCH_TITLE, LOCAL_PREFIX, SharedText
14
+ from gitpane.history.repository_value import ReferenceKind
15
+ from gitpane.operation.diagnostic import SensitiveTextFilter
16
+ from gitpane.operation.operation_status import OperationStatus
17
+ from gitpane.reference.reference_edit import ReferenceDeletionIntent, occupied_branch_set, remote_branch_destination, remote_delete_candidates
18
+ from gitpane.ui.reference.branch_selector import BranchSelector
19
+ from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
20
+ from gitpane.ui.widget.content_measure import ScrolledContentMeasure
21
+ from gitpane.ui.widget.dialog_layout import fit_form
22
+ from gitpane.ui.widget.long_text import WrappingLabel
23
+ from gitpane.ui.widget.wheel_safe_drop_down import UrlSelector
24
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
25
+
26
+ if TYPE_CHECKING:
27
+ from gitpane.operation.repository_operation import OperationOutcome
28
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
29
+ from gitpane.transport.branch_exchange import PushTarget
30
+ from gitpane.transport.remote_config import RemoteSnapshot
31
+
32
+ DELETE_DIALOG_WIDTH = 640
33
+ DELETE_DIALOG_HEIGHT = 360
34
+ # Button that confirms the deletion of a branch that is not fully merged, kept apart from the window title so that each can change alone.
35
+ CONFIRM_BRANCH_DELETION_BUTTON_TEXT = "Delete branch"
36
+
37
+
38
+ class DeleteBranchDialogText(Enum):
39
+ """Texts only the Delete branch window shows, one member per text so a misspelt name is an error."""
40
+
41
+ SUCCEEDED = "Succeeded"
42
+ DELETE_LOCAL_BRANCH = "Delete local branch (if available)"
43
+ """The check box that also deletes the local branch of the same name.
44
+
45
+ Not "local tracking branch": that is Git's word for a local branch that follows a remote one, but it reads like
46
+ "remote-tracking branch", the remote branch's local copy, which is not what this box deletes.
47
+ """
48
+
49
+
50
+ # Wording of the step statuses that have one in the result list. Any other status is written as its own word, the
51
+ # way Git and the transport name it: that choice is explicit, and a test lists the statuses it covers.
52
+ STEP_WORD_BY_STATUS: dict[OperationStatus, DeleteBranchDialogText | SharedText] = {
53
+ OperationStatus.SUCCESS: DeleteBranchDialogText.SUCCEEDED,
54
+ OperationStatus.FAILED: SharedText.FAILED,
55
+ OperationStatus.REFUSED: SharedText.FAILED,
56
+ OperationStatus.NOT_STARTED: SharedText.NOT_STARTED,
57
+ OperationStatus.UNKNOWN: SharedText.OUTCOME_UNKNOWN,
58
+ }
59
+
60
+
61
+ def step_word(status: OperationStatus) -> str:
62
+ """Give the word of a step status in the result list: its wording in the table, or else the status's own word."""
63
+ text = STEP_WORD_BY_STATUS.get(status)
64
+ return status.value if text is None else text.value
65
+
66
+
67
+ class DeleteBranchDialog(ContentGrowthMixin, QDialog):
68
+ """Delete one captured remote branch on its server, with an explicit remote choice and optional local tracking branches.
69
+
70
+ Local branches and tags have their own Git Extensions windows; this form only deletes a branch of a remote. The
71
+ window grows when its remote fields or its messages appear, so the list of local branches is never pushed out of
72
+ sight, and takes back its height when they go, never below the height it opened at or the one the user set.
73
+ """
74
+
75
+ prepare_requested = Signal(object)
76
+ delete_requested = Signal(object)
77
+ destination_changed = Signal()
78
+ retry_requested = Signal()
79
+
80
+ def __init__(self, session_id: str, full_ref: str, parent: QWidget | None = None) -> None:
81
+ """Keep the branch choice visible above permanent deletion and cancel actions."""
82
+ super().__init__(parent)
83
+ self.session_id = session_id
84
+ self.full_ref = full_ref
85
+ # Read by the reference form controller, which also runs the Delete tag window: this form deletes a remote branch.
86
+ self.remote_branch = True
87
+ self.snapshot: ReferenceSnapshot | None = None
88
+ self.intent: ReferenceDeletionIntent | None = None
89
+ self.closed = False
90
+ self.busy = False
91
+ self.confirmation: QMessageBox | None = None
92
+ self.remote_snapshot: RemoteSnapshot | None = None
93
+ self.push_target: PushTarget | None = None
94
+ self.remote_request: tuple | None = None
95
+ self.outcome: OperationOutcome | None = None
96
+ self.retry_operation_id = ""
97
+ self.retry_remote_success = False
98
+ self.candidates: tuple[ReferenceDeletionIntent, ...] = ()
99
+ self.local_intents: tuple[ReferenceDeletionIntent, ...] = ()
100
+ self.warning_refs: list[str] = []
101
+ self.text_filter = SensitiveTextFilter()
102
+ self.return_focus = parent.focusWidget() if parent is not None else None
103
+ self.setWindowTitle(DELETE_BRANCH_TITLE)
104
+ self.resize(DELETE_DIALOG_WIDTH, DELETE_DIALOG_HEIGHT)
105
+ layout = QVBoxLayout(self)
106
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
107
+ self.scroll_area = QScrollArea()
108
+ self.scroll_area.setWidgetResizable(True)
109
+ content = QWidget()
110
+ self.form = QVBoxLayout(content)
111
+ self.heading_label = WrappingLabel("Select branches")
112
+ self.branch_selector = BranchSelector()
113
+ self.branch_selector.setAccessibleName("Select branches")
114
+ self.heading_label.setBuddy(self.branch_selector)
115
+ self.form.addWidget(self.heading_label)
116
+ self.form.addWidget(self.branch_selector)
117
+ self.remote_checkbox = QCheckBox("Delete branch(es) from remote repository")
118
+ self.form.addWidget(self.remote_checkbox)
119
+ self.local_checkbox = QCheckBox(DeleteBranchDialogText.DELETE_LOCAL_BRANCH.value)
120
+ self.local_checkbox.setEnabled(False)
121
+ self.form.addWidget(self.local_checkbox)
122
+ self.remote_box = QWidget()
123
+ remote_form = QVBoxLayout(self.remote_box)
124
+ self.remote_selector = UrlSelector()
125
+ self.endpoint_selector = UrlSelector()
126
+ self.destination_edit = QLineEdit()
127
+ # The remote and the branch on it follow from the remote branch's Fetch mapping; only the endpoint is chosen.
128
+ self.destination_edit.setReadOnly(True)
129
+ self.remote_selector.setEnabled(False)
130
+ for label_text, widget in (("Remote repository", self.remote_selector), ("Remote branch", self.destination_edit), ("Push endpoint", self.endpoint_selector)):
131
+ label = WrappingLabel(label_text)
132
+ label.setBuddy(widget)
133
+ widget.setAccessibleName(label_text)
134
+ remote_form.addWidget(label)
135
+ remote_form.addWidget(widget)
136
+ self.remote_box.hide()
137
+ self.form.addWidget(self.remote_box)
138
+ self.candidate_label = WrappingLabel("Local tracking branch(es) candidate to deletion:")
139
+ self.candidate_list = QListWidget()
140
+ self.candidate_list.setAccessibleName(self.candidate_label.text())
141
+ self.candidate_label.setBuddy(self.candidate_list)
142
+ self.form.addWidget(self.candidate_label)
143
+ self.form.addWidget(self.candidate_list)
144
+ self.status_label = WrappingLabel()
145
+ self.status_label.setTextFormat(Qt.TextFormat.PlainText)
146
+ self.status_label.setWordWrap(True)
147
+ self.status_label.setText("Reading references and remote tracking…")
148
+ self.form.addWidget(self.status_label)
149
+ self.form.addStretch()
150
+ self.scroll_area.setWidget(content)
151
+ # The remote fields shown by the remote deletion box, a failure or the result of each step arrive after the
152
+ # window opened, and the window follows them both ways by the rule it shares with Clone and Checkout branch, never
153
+ # below the height it opened at or the one the user set. It opens at its usual height, taller only when its rows
154
+ # need more, which keeps room for the result.
155
+ self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.GROW_ONLY, DELETE_DIALOG_HEIGHT)
156
+ layout.addWidget(self.scroll_area, 1)
157
+ actions = QHBoxLayout()
158
+ self.retry_button = QPushButton("Retry failed step")
159
+ self.retry_button.setAutoDefault(False)
160
+ self.retry_button.hide()
161
+ actions.addWidget(self.retry_button)
162
+ actions.addStretch()
163
+ self.submit_button = QPushButton("Delete")
164
+ actions.addWidget(self.submit_button)
165
+ self.submit_button.setAutoDefault(False)
166
+ self.submit_button.setEnabled(False)
167
+ self.cancel_button = QPushButton("Cancel")
168
+ actions.addWidget(self.cancel_button)
169
+ layout.addLayout(actions)
170
+ self.branch_selector.currentIndexChanged.connect(self._branch_changed)
171
+ self.remote_checkbox.toggled.connect(self._remote_toggled)
172
+ self.remote_selector.currentIndexChanged.connect(self._remote_changed)
173
+ self.endpoint_selector.currentIndexChanged.connect(self._destination_changed)
174
+ self.destination_edit.textChanged.connect(self._destination_changed)
175
+ self.retry_button.clicked.connect(self.retry_requested.emit)
176
+ self.submit_button.clicked.connect(self._submit)
177
+ self.cancel_button.clicked.connect(self.reject)
178
+ self.finished.connect(self._closed)
179
+ self.refresh_metrics()
180
+
181
+ def refresh_metrics(self, available: QRect | None = None) -> None:
182
+ """Fit deletion choices while keeping action buttons outside scrolling content."""
183
+ fit_form(self, available)
184
+
185
+ def populate(self, snapshot: ReferenceSnapshot) -> None:
186
+ """Select the exact clicked ref without choosing a replacement on disappearance.
187
+
188
+ A capture arriving after the click or after the result changes nothing: it would build a new intention and
189
+ offer a deletion that was already asked or done.
190
+ """
191
+ if self.closed or self.busy or self.outcome is not None:
192
+ return
193
+ self.snapshot = snapshot
194
+ self.status_label.setText("Reading remote tracking…")
195
+ with QSignalBlocker(self.branch_selector):
196
+ self.branch_selector.clear()
197
+ for reference in sorted(snapshot.references, key=lambda reference: reference.full_name):
198
+ if reference.kind == ReferenceKind.REMOTES and not reference.symbolic_target and reference.target_oid:
199
+ self.branch_selector.addItem(reference.full_name.removeprefix(LOCAL_PREFIX), reference.full_name)
200
+ self.branch_selector.setCurrentIndex(self.branch_selector.findData(self.full_ref))
201
+ self.branch_selector.setEnabled(self.branch_selector.count() > 0 and not self.busy and not self.closed)
202
+ self._update_intent()
203
+
204
+ def _update_intent(self) -> None:
205
+ """Invalidate prior answers whenever the selected branch changes, and offer Delete only with a complete remote destination."""
206
+ if self.busy or self.closed:
207
+ return
208
+ self.intent = None
209
+ self.full_ref = self.branch_selector.currentData() or ""
210
+ if self.snapshot is not None and self.full_ref:
211
+ try:
212
+ self.intent = ReferenceDeletionIntent.capture(self.snapshot, self.full_ref, session_id=self.session_id)
213
+ except ValueError as error:
214
+ self.status_label.setText(str(error))
215
+ self.remote_request = None
216
+ if (self.remote_checkbox.isChecked() and self.remote_snapshot is not None and self.remote_selector.currentData()
217
+ and self.endpoint_selector.currentData() and self.destination_edit.text().startswith(LOCAL_PREFIX) and self.push_target is not None):
218
+ self.remote_request = (self.remote_snapshot, self.remote_selector.currentData(), self.destination_edit.text(), self.endpoint_selector.currentData(), self.push_target)
219
+ remote_ready = self.remote_checkbox.isChecked() and self.remote_request is not None
220
+ self.submit_button.setEnabled(self.intent is not None and remote_ready)
221
+
222
+ def populate_remote(self, snapshot: RemoteSnapshot) -> None:
223
+ """Show real tracking only when it names a unique remote, branch and effective URL, and only before the click."""
224
+ if self.busy or self.closed or self.outcome is not None:
225
+ return
226
+ self.remote_snapshot = snapshot
227
+ self.status_label.clear()
228
+ with QSignalBlocker(self.remote_selector):
229
+ self.remote_selector.clear()
230
+ for name, entry in sorted(snapshot.entry_by_name.items()):
231
+ if entry.active and not entry.collision:
232
+ self.remote_selector.addItem(self.text_filter.redact(name), name)
233
+ self.remote_selector.setCurrentIndex(-1)
234
+ self._branch_changed()
235
+
236
+ def _branch_changed(self) -> None:
237
+ """Derive the remote and its branch from the selected remote branch's Fetch mapping, and list its local candidates."""
238
+ if self.busy:
239
+ return
240
+ self.full_ref = self.branch_selector.currentData() or ""
241
+ remote_name, destination = "", ""
242
+ if self.remote_snapshot is not None:
243
+ try:
244
+ remote_name, destination = remote_branch_destination(self.remote_snapshot, self.full_ref)
245
+ except ValueError as error:
246
+ self.status_label.setText(str(error))
247
+ with QSignalBlocker(self.remote_selector), QSignalBlocker(self.destination_edit):
248
+ self.remote_selector.setCurrentIndex(self.remote_selector.findData(remote_name))
249
+ self.destination_edit.setText(destination if remote_name and destination.startswith(LOCAL_PREFIX) else "")
250
+ self._remote_changed()
251
+ self._populate_candidates()
252
+
253
+ def _populate_candidates(self) -> None:
254
+ """Show exact upstream candidates and disable branches occupied by worktrees."""
255
+ self.candidate_list.clear()
256
+ self.candidates = ()
257
+ self.local_checkbox.setChecked(False)
258
+ if self.snapshot is not None and self.remote_snapshot is not None:
259
+ try:
260
+ self.candidates = remote_delete_candidates(self.snapshot, self.remote_snapshot, self.full_ref, self.session_id)
261
+ except ValueError as error:
262
+ self.status_label.setText(str(error))
263
+ protected_ref_set = occupied_branch_set(self.snapshot) if self.snapshot is not None else set()
264
+ selectable = False
265
+ for candidate in self.candidates:
266
+ protected = candidate.full_ref in protected_ref_set
267
+ item = QListWidgetItem(candidate.full_ref + (" — checked out in a worktree" if protected else ""))
268
+ item.setData(Qt.ItemDataRole.UserRole, candidate)
269
+ item.setToolTip(item.text())
270
+ if protected:
271
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
272
+ item.setCheckState(Qt.CheckState.Unchecked)
273
+ else:
274
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
275
+ item.setCheckState(Qt.CheckState.Checked)
276
+ selectable = True
277
+ self.candidate_list.addItem(item)
278
+ self.local_checkbox.setEnabled(selectable)
279
+
280
+ def selected_candidates(self) -> tuple[ReferenceDeletionIntent, ...]:
281
+ """Return only explicitly enabled local candidates from the captured form."""
282
+ candidates: list[ReferenceDeletionIntent] = []
283
+ if not self.local_checkbox.isChecked():
284
+ return ()
285
+ for index in range(self.candidate_list.count()):
286
+ item = self.candidate_list.item(index)
287
+ if item.flags() & Qt.ItemFlag.ItemIsEnabled and item.checkState() == Qt.CheckState.Checked:
288
+ candidates.append(item.data(Qt.ItemDataRole.UserRole))
289
+ return tuple(candidates)
290
+
291
+ def _remote_changed(self) -> None:
292
+ """Require explicit selection for multiple endpoints instead of invoking native fan-out."""
293
+ if self.busy:
294
+ return
295
+ name = self.remote_selector.currentData()
296
+ entry = self.remote_snapshot.entry_by_name.get(name) if self.remote_snapshot is not None else None
297
+ with QSignalBlocker(self.endpoint_selector):
298
+ self.endpoint_selector.clear()
299
+ if entry is not None:
300
+ for endpoint in sorted(set(entry.push_urls)):
301
+ self.endpoint_selector.addItem(self.text_filter.redact(endpoint), endpoint)
302
+ initial_index = 0 if self.endpoint_selector.count() == 1 else -1
303
+ self.endpoint_selector.setCurrentIndex(initial_index)
304
+ self._destination_changed()
305
+
306
+ def _remote_toggled(self, checked: bool) -> None:
307
+ """Expose the exact remote destination only after the explicit opt-in."""
308
+ self.remote_box.setVisible(checked)
309
+ self._destination_changed()
310
+
311
+ def _destination_changed(self) -> None:
312
+ """Discard captured authentication whenever a destination field changes."""
313
+ if self.busy:
314
+ return
315
+ self.push_target = None
316
+ self.destination_changed.emit()
317
+ self._update_intent()
318
+
319
+ def set_push_target(self, target: PushTarget | None, cause: str = "") -> None:
320
+ """Bind the selected endpoint to its current non-secret account configuration."""
321
+ self.push_target = target
322
+ if cause:
323
+ self.status_label.setText(self.text_filter.redact(cause))
324
+ self._update_intent()
325
+
326
+ def _submit(self) -> None:
327
+ """Check the captured branch before asking only the necessary unmerged question."""
328
+ if self.intent is None or self.busy or self.closed or not self.submit_button.isEnabled():
329
+ return
330
+ self.busy = True
331
+ self.local_intents = self.selected_candidates()
332
+ self.warning_refs.clear()
333
+ self.submit_button.setEnabled(False)
334
+ self.branch_selector.setEnabled(False)
335
+ self.remote_checkbox.setEnabled(False)
336
+ self.remote_box.setEnabled(False)
337
+ self.local_checkbox.setEnabled(False)
338
+ self.candidate_list.setEnabled(False)
339
+ self.retry_button.setEnabled(False)
340
+ self.prepare_requested.emit(self.intent)
341
+
342
+ def prepared(self, merged: bool) -> None:
343
+ """Use Delete as authorization for merged branches and Cancel as the warning default."""
344
+ if self.closed or self.intent is None:
345
+ return
346
+ if merged:
347
+ self.delete_requested.emit(self.intent)
348
+ return
349
+ branch = (self.warning_refs.pop(0) if self.warning_refs else self.full_ref).removeprefix(LOCAL_PREFIX)
350
+ current_branch = (self.snapshot.head_ref or "HEAD").removeprefix(LOCAL_PREFIX)
351
+ question = QMessageBox(QMessageBox.Icon.Warning, DELETE_BRANCH_TITLE, f"Branch '{branch}' is not fully merged into '{current_branch}'. Delete it anyway?",
352
+ QMessageBox.StandardButton.NoButton, self)
353
+ prepare_message_box(question, DELETE_BRANCH_TITLE)
354
+ question.setTextFormat(Qt.TextFormat.PlainText)
355
+ delete_button = question.addButton(CONFIRM_BRANCH_DELETION_BUTTON_TEXT, QMessageBox.ButtonRole.AcceptRole)
356
+ cancel_button = question.addButton("Cancel deletion", QMessageBox.ButtonRole.RejectRole)
357
+ question.setDefaultButton(cancel_button)
358
+ question.setEscapeButton(cancel_button)
359
+ self.confirmation = question
360
+ captured = self.intent
361
+
362
+ def answer() -> None:
363
+ """Bind the affirmative button to the original intent and invalidate late answers."""
364
+ self.confirmation = None
365
+ accepted = question.clickedButton() is delete_button
366
+ question.deleteLater()
367
+ if self.closed or self.intent != captured:
368
+ return
369
+ if accepted:
370
+ if self.warning_refs:
371
+ self.prepared(False)
372
+ return
373
+ self.intent = replace(captured, unmerged_confirmed=True)
374
+ self.delete_requested.emit(self.intent)
375
+ else:
376
+ self.busy = False
377
+ self.branch_selector.setEnabled(True)
378
+ self.remote_checkbox.setEnabled(True)
379
+ self.remote_box.setEnabled(True)
380
+ self.local_checkbox.setEnabled(bool(self.candidates))
381
+ self.candidate_list.setEnabled(True)
382
+ self._update_intent()
383
+ question.finished.connect(answer)
384
+ open_window_modal(question)
385
+
386
+ def show_error(self, cause: str) -> None:
387
+ """Retain the exact failure without permitting stale capture reuse."""
388
+ self.busy = False
389
+ self.status_label.setText(self.text_filter.redact(cause))
390
+ self.branch_selector.setEnabled(False)
391
+ self.submit_button.setEnabled(False)
392
+ if self.outcome is not None:
393
+ self.retry_button.setEnabled(True)
394
+
395
+ def show_outcome(self, outcome: OperationOutcome) -> None:
396
+ """Keep each deletion result visible until the user closes the form."""
397
+ self.busy = False
398
+ self.outcome = outcome
399
+ lines: list[str] = []
400
+ for step in outcome.steps:
401
+ lines.append(step.operation + (" " + step.full_ref if step.full_ref else "") + ": " + step_word(step.status) + " — " + step.effect)
402
+ if outcome.diagnostic is not None:
403
+ lines.append(outcome.diagnostic.detail)
404
+ self.status_label.setText("\n".join(lines))
405
+ self.cancel_button.setText("Close")
406
+ self.submit_button.setEnabled(False)
407
+ retryable = outcome.status in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.PARTIAL) and any(step.status in (OperationStatus.FAILED, OperationStatus.REFUSED, OperationStatus.NOT_STARTED) for step in outcome.steps)
408
+ self.retry_button.setVisible(retryable)
409
+ self.retry_button.setEnabled(retryable)
410
+
411
+ def _closed(self) -> None:
412
+ """Invalidate pending reads and questions and restore the launching widget focus."""
413
+ self.closed = True
414
+ if self.confirmation is not None:
415
+ self.confirmation.reject()
416
+ if self.return_focus is not None and isValid(self.return_focus):
417
+ self.return_focus.setFocus()
@@ -0,0 +1,62 @@
1
+ """Render bounded operation feedback without owning Git work or interpreting its output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+
7
+ from PySide6.QtCore import QEvent, Qt
8
+ from PySide6.QtGui import QPalette
9
+ from PySide6.QtWidgets import QWidget
10
+
11
+ from gitpane.config import DARK_BACKGROUND_LIGHTNESS_LIMIT, ERROR_DARK_COLOR, ERROR_LIGHT_COLOR
12
+ from gitpane.operation.diagnostic import SensitiveTextFilter
13
+ from gitpane.ui.widget.long_text import WrappingLabel
14
+
15
+
16
+ def format_reference_summary(lines: list[tuple[str, bool]], palette: QPalette) -> str:
17
+ """Escape filtered observation lines and emphasize only errors against the current theme."""
18
+ dark = palette.color(QPalette.ColorRole.Window).lightnessF() < DARK_BACKGROUND_LIGHTNESS_LIMIT
19
+ color = ERROR_DARK_COLOR if dark else ERROR_LIGHT_COLOR
20
+ text_filter = SensitiveTextFilter()
21
+ rendered_lines: list[str] = []
22
+ for text, error in lines:
23
+ literal = escape(text_filter.redact(text)).replace("\n", "<br>")
24
+ rendered_lines.append(f'<span style="color: {color}"><b>{literal}</b></span>' if error else literal)
25
+ return "<br>".join(rendered_lines)
26
+
27
+
28
+ class ReferenceSummaryLabel(WrappingLabel):
29
+ """Keep ordinary messages literal and redraw rich results when the palette changes."""
30
+
31
+ def __init__(self, parent: QWidget | None = None) -> None:
32
+ """Retain filtered lines only while an operation summary is visible."""
33
+ super().__init__(parent=parent)
34
+ self.summary_lines: list[tuple[str, bool]] = []
35
+ self.setTextFormat(Qt.TextFormat.PlainText)
36
+ self.setWordWrap(True)
37
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
38
+
39
+ def setText(self, text: str) -> None:
40
+ """Display non-summary messages as literal filtered text."""
41
+ self.summary_lines = []
42
+ self.setTextFormat(Qt.TextFormat.PlainText)
43
+ super().setText(SensitiveTextFilter().redact(text))
44
+
45
+ def clear(self) -> None:
46
+ """Forget a previous summary when a new operation begins."""
47
+ self.setText("")
48
+
49
+ def show_summary(self, lines: list[tuple[str, bool]]) -> None:
50
+ """Retain safe observations for palette updates and text selection."""
51
+ text_filter = SensitiveTextFilter()
52
+ self.summary_lines = []
53
+ for text, error in lines:
54
+ self.summary_lines.append((text_filter.redact(text), error))
55
+ self.setTextFormat(Qt.TextFormat.RichText)
56
+ super().setText(format_reference_summary(self.summary_lines, self.palette()))
57
+
58
+ def changeEvent(self, event: QEvent) -> None:
59
+ """Refresh error contrast after a live theme change."""
60
+ super().changeEvent(event)
61
+ if event.type() == QEvent.Type.PaletteChange and self.summary_lines:
62
+ super().setText(format_reference_summary(self.summary_lines, self.palette()))
@@ -0,0 +1,139 @@
1
+ """Share the life cycle of the reference windows that reproduce Git Extensions forms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import Qt
8
+ from PySide6.QtWidgets import QDialog, QMessageBox, QPushButton, QWidget
9
+ from shiboken6 import isValid
10
+
11
+ from gitpane.config import fit_window
12
+ from gitpane.operation.diagnostic import SensitiveTextFilter
13
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
14
+
15
+ if TYPE_CHECKING:
16
+ from PySide6.QtCore import QRect
17
+ from PySide6.QtGui import QCloseEvent, QKeyEvent
18
+
19
+ from gitpane.reference.reference_edit import BranchDeletionIntent, BranchIntent, CheckoutIntent, ReferenceDeletionIntent, ResetIntent, TagIntent
20
+ from gitpane.reference.reference_snapshot import ReferenceSnapshot
21
+ from gitpane.ui.reference.commit_summary import CommitSummary
22
+
23
+
24
+ def reference_warning_box(title: str, cause: str, parent: QWidget | None, text_filter: SensitiveTextFilter | None = None) -> QMessageBox:
25
+ """Build the warning box of a reference form: the form's title, a warning icon, OK, and the cause as plain text with anything sensitive removed.
26
+
27
+ Both the error of a form in use and the refusal of a form at its opening are built here, so neither can drop the
28
+ removal of secrets nor differ in look from the other. A form in use passes its own filter, which keeps the secrets
29
+ it already met.
30
+ """
31
+ redacted = (text_filter or SensitiveTextFilter()).redact(cause)
32
+ box = QMessageBox(QMessageBox.Icon.Warning, title, redacted, QMessageBox.StandardButton.Ok, parent)
33
+ prepare_message_box(box, title)
34
+ box.setTextFormat(Qt.TextFormat.PlainText)
35
+ return box
36
+
37
+
38
+ class ReferenceFormDialog(QDialog):
39
+ """Base of the reference forms: frozen while Git runs, errors in a message box, Enter on the focused button.
40
+
41
+ A subclass builds its body and sets ``submit_button``; the reference form controller owns every Git read and write. While
42
+ ``busy`` the form cannot be closed, so the captured request stays visible until its operation ends.
43
+ """
44
+
45
+ def __init__(self, session_id: str, parent: QWidget | None = None) -> None:
46
+ """Start idle, without a capture, and remember which control to give the focus back to on closing."""
47
+ super().__init__(parent)
48
+ self.session_id = session_id
49
+ self.snapshot: ReferenceSnapshot | None = None
50
+ # Each form freezes its own kind of request; the Delete branch window of local branches freezes a group deletion.
51
+ self.intent: TagIntent | BranchIntent | CheckoutIntent | ResetIntent | ReferenceDeletionIntent | BranchDeletionIntent | None = None
52
+ self.busy = False
53
+ self.closed = False
54
+ self.completed = False
55
+ self.return_focus = parent.focusWidget() if parent is not None else None
56
+ self.error_box: QMessageBox | None = None
57
+ self.text_filter = SensitiveTextFilter()
58
+ # Raised each time the form targets another commit, so a late position answer for an older target is ignored.
59
+ self.position_generation = 0
60
+ # The Git Extensions commit box of the forms that show one; the reference form controller fills it for each new target.
61
+ self.commit_summary: CommitSummary | None = None
62
+ # Height of the forms made of a single row, which Git Extensions lets grow in width only; None lets the height change.
63
+ self.single_row_height: int | None = None
64
+ self.submit_button = QPushButton()
65
+ # The Git Extensions forms have no help button in their title bar, and there is no help to open.
66
+ self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False)
67
+ self.finished.connect(self._closed)
68
+
69
+ def show_error(self, cause: str) -> None:
70
+ """Refuse submission and explain why in a Windows-style warning box, as Git Extensions does.
71
+
72
+ The forms have no status area, so the cause goes to a non-blocking message box, and to the submit button's
73
+ tooltip so it can still be read after the box is closed. A second error updates the box already open instead
74
+ of stacking another one.
75
+ """
76
+ text = self.text_filter.redact(cause)
77
+ self.submit_button.setEnabled(False)
78
+ self.submit_button.setToolTip(text)
79
+ if self.error_box is not None and isValid(self.error_box):
80
+ self.error_box.setText(text)
81
+ open_window_modal(self.error_box)
82
+ return
83
+ self.error_box = reference_warning_box(self.windowTitle(), cause, self, self.text_filter)
84
+ open_window_modal(self.error_box)
85
+
86
+ def fit_capture_controls(self) -> None:
87
+ """Size the captions and buttons copied from the form's capture for the current font; forms that have some override it.
88
+
89
+ The captures were taken with Segoe UI 9 pt: each control keeps its capture's size where its text fits, and takes
90
+ the size its text needs with a wider font, so no caption or button text is cut.
91
+ """
92
+
93
+ def refresh_metrics(self, available: QRect | None = None) -> None:
94
+ """Size the controls copied from the capture, then keep the form on the usable screen area without turning its starting size into a minimum.
95
+
96
+ A single-row form gets its fixed height back afterwards: fitting the window rewrites its minimum size, which
97
+ would otherwise let the height change again. That height is the capture's, or the height its row needs when a
98
+ larger text size makes the caption, the field or the button taller, so no text is cut.
99
+ """
100
+ self.fit_capture_controls()
101
+ fit_window(self, 0, 0, available)
102
+ if self.single_row_height is not None:
103
+ self.setFixedHeight(max(self.single_row_height, self.layout().totalMinimumSize().height()))
104
+
105
+ def reject(self) -> None:
106
+ """Close an idle form; a form whose request is running stays open."""
107
+ if not self.busy:
108
+ super().reject()
109
+
110
+ def accept(self) -> None:
111
+ """Ignore acceptance while the native operation still owns the captured request."""
112
+ if not self.busy:
113
+ super().accept()
114
+
115
+ def closeEvent(self, event: QCloseEvent) -> None:
116
+ """Ignore the title bar close button while Git is still using the captured choices."""
117
+ if self.busy:
118
+ event.ignore()
119
+ return
120
+ super().closeEvent(event)
121
+
122
+ def keyPressEvent(self, event: QKeyEvent) -> None:
123
+ """Press the focused button on Enter, as Windows does; anywhere else Enter runs the default submit button."""
124
+ focused = self.focusWidget()
125
+ if (event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and isinstance(focused, QPushButton) and focused is not self.submit_button
126
+ and self.isAncestorOf(focused)):
127
+ if focused.isEnabled():
128
+ focused.click()
129
+ return
130
+ super().keyPressEvent(event)
131
+
132
+ def _closed(self) -> None:
133
+ """Invalidate late answers, close a pending error box and give the focus back to the launching control."""
134
+ self.closed = True
135
+ self.position_generation += 1
136
+ if self.error_box is not None and isValid(self.error_box):
137
+ self.error_box.close()
138
+ if self.return_focus is not None and isValid(self.return_focus):
139
+ self.return_focus.setFocus()