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,580 @@
1
+ """Collect explicit push destinations without performing repository operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, NamedTuple
6
+
7
+ from PySide6.QtCore import Qt, Signal
8
+ from PySide6.QtWidgets import QCheckBox, QDialog, QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, QLayout, QLineEdit, QPushButton, QRadioButton, QScrollArea, QTabWidget, QVBoxLayout, QWidget
9
+ from shiboken6 import isValid
10
+
11
+ from gitpane.config import LOCAL_PREFIX, REMOTE_PREFIX, TAG_PREFIX
12
+ from gitpane.operation.diagnostic import SensitiveTextFilter
13
+ from gitpane.operation.operation_status import OperationStatus
14
+ from gitpane.operation.repository_operation import OWNED_REPOSITORY_TEXT
15
+ from gitpane.text.typed_path import session_typed_path, typed_path_value, typed_start_dir
16
+ from gitpane.text.typed_text import typed_value
17
+ from gitpane.transport.refusal_notice import failure_headline, failure_summary, headline_first, is_no_account_refusal, server_refusal_headline
18
+ from gitpane.transport.remote_config import default_push_destination_name, fetch_remote_name
19
+ from gitpane.ui.look.action_icon import action_icon
20
+ from gitpane.ui.transport.push_rejection_prompt import FORCE_WITH_LEASE_LABEL
21
+ from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
22
+ from gitpane.ui.widget.content_measure import ScrolledContentMeasure
23
+ from gitpane.ui.widget.dialog_layout import fit_form
24
+ from gitpane.ui.widget.long_text import WrappingLabel
25
+ from gitpane.ui.widget.result_label import ResultLabel
26
+ from gitpane.ui.widget.wheel_safe_drop_down import UrlSelector
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable
30
+
31
+ from PySide6.QtCore import QRect
32
+ from PySide6.QtGui import QShowEvent
33
+
34
+ from gitpane.commit.commit_push import CommitPushIntent
35
+ from gitpane.transport.branch_exchange import PushTarget, TransportOutcome
36
+ from gitpane.transport.refusal_notice import TransferAccount
37
+ from gitpane.transport.remote_config import RemoteSnapshot
38
+
39
+ # The draft a Push window sends: its capture, remote name, endpoint, source ref, destination ref, force, push target
40
+ # and the tag refs with their objects. Only type checkers read it, so the names above need no import at run time.
41
+ PushWindowRequest = tuple[RemoteSnapshot, str, str, str, str, bool, PushTarget | None, tuple[tuple[str, str], ...]]
42
+
43
+ PUSH_DIALOG_WIDTH = 800
44
+ PUSH_DIALOG_HEIGHT = 480
45
+ BRANCH_TAB_INDEX = 0
46
+ TAG_TAB_INDEX = 1
47
+ TAG_SELECTION_ROLE = Qt.ItemDataRole.UserRole + 1
48
+ ALL_TAG_SELECTION = ""
49
+
50
+
51
+ class PinnedTag(NamedTuple):
52
+ """The one tag object a creation follow-up may push, and the remote the tag creation proposed for it.
53
+
54
+ An empty remote name leaves the remote choice to Push.
55
+ """
56
+
57
+ full_ref: str
58
+ object_oid: str
59
+ remote_name: str = ""
60
+
61
+ def selection(self) -> tuple[tuple[str, str], ...]:
62
+ """Answer the tag list entry that stands for exactly this tag object, in the form the tag selector stores."""
63
+ return ((self.full_ref, self.object_oid),)
64
+
65
+
66
+ class PushDialog(ContentGrowthMixin, QDialog):
67
+ """Retain one repository's source and destination draft until an explicit Push."""
68
+
69
+ push_requested = Signal(object)
70
+ remote_requested = Signal()
71
+ reread_requested = Signal()
72
+ destination_changed = Signal()
73
+
74
+ def __init__(self, repository_dir: str, parent: QWidget | None = None, *, is_available: Callable[[], bool] | None = None,
75
+ typed_path: Callable[[str], str] = session_typed_path) -> None:
76
+ """Place scrolling fields above permanent actions and preserve native keyboard behavior.
77
+
78
+ ``typed_path`` reads ``~`` in the Url field where that is the usage; the tests hand their own system and home
79
+ folder.
80
+ """
81
+ super().__init__(parent)
82
+ self.typed_path = typed_path
83
+ self.snapshot: RemoteSnapshot | None = None
84
+ self.commit_push_intent: CommitPushIntent | None = None
85
+ self.commit_push_message = ""
86
+ self.created_tag: PinnedTag | None = None
87
+ self.closed = False
88
+ self.busy = False
89
+ self.loading = True
90
+ self.request: PushWindowRequest | None = None
91
+ self.operation_id = ""
92
+ self.push_target: PushTarget | None = None
93
+ self.target_error = ""
94
+ self.needs_reread = False
95
+ self.is_available = is_available or (lambda: True)
96
+ self.available = self.is_available()
97
+ self.return_focus = parent.focusWidget() if parent else None
98
+ self.text_filter = SensitiveTextFilter()
99
+ self.setWindowTitle("Push (" + repository_dir + ")")
100
+ layout = QVBoxLayout(self)
101
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
102
+ self.scroll_area = QScrollArea()
103
+ self.scroll_area.setWidgetResizable(True)
104
+ self.content = QWidget()
105
+ body = QVBoxLayout(self.content)
106
+ self.commit_result_label = ResultLabel()
107
+ self.commit_result_label.hide()
108
+ body.addWidget(self.commit_result_label)
109
+ destination_group = QGroupBox("Push to")
110
+ destination = QGridLayout(destination_group)
111
+ self.remote_radio = QRadioButton("Remote")
112
+ self.remote_radio.setChecked(True)
113
+ self.url_radio = QRadioButton("Url")
114
+ self.remote_selector = UrlSelector()
115
+ self.remote_selector.setAccessibleName("Push remote")
116
+ self.manage_button = QPushButton("Manage remotes")
117
+ self.url_edit = QLineEdit()
118
+ self.url_edit.setAccessibleName("Push Url")
119
+ self.browse_button = QPushButton("Browse…")
120
+ self.browse_button.setAccessibleName("Browse Push repository")
121
+ destination.addWidget(self.remote_radio, 0, 0)
122
+ destination.addWidget(self.remote_selector, 0, 1)
123
+ destination.addWidget(self.manage_button, 0, 2)
124
+ destination.addWidget(self.url_radio, 1, 0)
125
+ destination.addWidget(self.url_edit, 1, 1)
126
+ destination.addWidget(self.browse_button, 1, 2)
127
+ self.endpoint_selector = UrlSelector()
128
+ self.endpoint_selector.setAccessibleName("Push endpoint")
129
+ endpoint_label = WrappingLabel("Push endpoint")
130
+ endpoint_label.setBuddy(self.endpoint_selector)
131
+ destination.addWidget(endpoint_label, 2, 0)
132
+ destination.addWidget(self.endpoint_selector, 2, 1, 1, 2)
133
+ body.addWidget(destination_group)
134
+ self.tabs = QTabWidget()
135
+ branch_page = QWidget()
136
+ branch = QGridLayout(branch_page)
137
+ branch.setColumnStretch(1, 1)
138
+ branch.setColumnStretch(3, 1)
139
+ self.local_label = WrappingLabel("Local branch")
140
+ self.destination_label = WrappingLabel("Remote branch")
141
+ self.source_selector = UrlSelector()
142
+ self.source_selector.setAccessibleName("Local branch to push")
143
+ self.destination_selector = UrlSelector()
144
+ self.destination_selector.setEditable(True)
145
+ self.destination_selector.setAccessibleName("Remote branch destination")
146
+ self.destination_selector.lineEdit().setPlaceholderText("Existing or new branch name")
147
+ self.local_label.setBuddy(self.source_selector)
148
+ self.destination_label.setBuddy(self.destination_selector)
149
+ branch.addWidget(self.local_label, 0, 1)
150
+ branch.addWidget(self.destination_label, 0, 3)
151
+ branch.addWidget(WrappingLabel("Branch to push"), 1, 0)
152
+ branch.addWidget(self.source_selector, 1, 1)
153
+ branch.addWidget(WrappingLabel("to"), 1, 2)
154
+ branch.addWidget(self.destination_selector, 1, 3)
155
+ self.options_check = QCheckBox("Show options")
156
+ self.force_check = QCheckBox(FORCE_WITH_LEASE_LABEL)
157
+ self.force_check.hide()
158
+ self.options_check.toggled.connect(lambda checked: self.force_check.setVisible(checked or self.force_check.isChecked()))
159
+ self.force_check.toggled.connect(lambda checked: self.options_check.setChecked(True) if checked else None)
160
+ branch.addWidget(self.options_check, 2, 1)
161
+ branch.addWidget(self.force_check, 3, 1)
162
+ branch.setRowStretch(4, 1)
163
+ self.tabs.addTab(branch_page, "Push branches")
164
+ tag_page = QWidget()
165
+ tag_layout = QGridLayout(tag_page)
166
+ tag_label = WrappingLabel("Tag to push")
167
+ self.tag_selector = UrlSelector()
168
+ self.tag_selector.setAccessibleName("Tag to push")
169
+ tag_label.setBuddy(self.tag_selector)
170
+ tag_label_row, tag_force_row, tag_spacer_row = 0, 1, 2
171
+ tag_label_column, tag_selector_column = 0, 1
172
+ tag_layout.addWidget(tag_label, tag_label_row, tag_label_column)
173
+ tag_layout.addWidget(self.tag_selector, tag_label_row, tag_selector_column)
174
+ self.tag_force_check = QCheckBox(FORCE_WITH_LEASE_LABEL)
175
+ tag_layout.addWidget(self.tag_force_check, tag_force_row, tag_selector_column)
176
+ tag_layout.setColumnStretch(tag_selector_column, 1)
177
+ tag_layout.setRowStretch(tag_spacer_row, 1)
178
+ self.tabs.addTab(tag_page, "Push tags")
179
+ body.addWidget(self.tabs, 1)
180
+ self.status_label = ResultLabel("Reading source and destination…")
181
+ body.addWidget(self.status_label)
182
+ self.scroll_area.setWidget(self.content)
183
+ # The note of a refused Commit & push, a refusal in the status line and the reread that replaces it arrive after
184
+ # the window opened, and the window follows them both ways by the rule it shares with the other forms, within
185
+ # its screen, never below the height it opened at or the one the user set.
186
+ self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.GROW_ONLY, PUSH_DIALOG_HEIGHT)
187
+ layout.addWidget(self.scroll_area, 1)
188
+ actions = QHBoxLayout()
189
+ self.reread_button = QPushButton("Reread destination")
190
+ self.close_button = QPushButton("Close")
191
+ self.push_button = QPushButton("Push")
192
+ self.push_button.setIcon(action_icon("push", self.devicePixelRatioF()))
193
+ actions.addWidget(self.reread_button)
194
+ actions.addStretch()
195
+ actions.addWidget(self.close_button)
196
+ actions.addWidget(self.push_button)
197
+ layout.addLayout(actions)
198
+ for button in (self.manage_button, self.browse_button, self.reread_button, self.close_button, self.push_button):
199
+ button.setAutoDefault(False)
200
+ self.push_button.clicked.connect(self.submit)
201
+ self.close_button.clicked.connect(self.reject)
202
+ self.manage_button.clicked.connect(self.remote_requested.emit)
203
+ self.reread_button.clicked.connect(self.reread_requested.emit)
204
+ self.browse_button.clicked.connect(self.browse)
205
+ self.remote_selector.currentIndexChanged.connect(self._remote_changed)
206
+ self.endpoint_selector.currentIndexChanged.connect(self._endpoint_changed)
207
+ self.source_selector.currentIndexChanged.connect(self._source_changed)
208
+ self.tag_selector.currentIndexChanged.connect(self.update_action)
209
+ self.tag_selector.currentIndexChanged.connect(self.reset_force)
210
+ self.source_selector.currentIndexChanged.connect(self.reset_force)
211
+ self.destination_selector.editTextChanged.connect(self.update_action)
212
+ self.destination_selector.editTextChanged.connect(self.reset_force)
213
+ self.destination_changed.connect(self.reset_force)
214
+ self.remote_radio.toggled.connect(self._mode_changed)
215
+ self.url_edit.textChanged.connect(lambda _text: self.destination_changed.emit() if not self.remote_radio.isChecked() else None)
216
+ self.url_edit.textChanged.connect(self.update_action)
217
+ self.tabs.currentChanged.connect(self.update_action)
218
+ self.tabs.currentChanged.connect(self.reset_force)
219
+ self.finished.connect(self._closed)
220
+ self.resize(PUSH_DIALOG_WIDTH, PUSH_DIALOG_HEIGHT)
221
+ self.refresh_metrics()
222
+ self.update_action()
223
+
224
+ def populate(self, snapshot: RemoteSnapshot, head_ref: str | None, tag_oid_by_ref: dict[str, str]) -> None:
225
+ """Display captured refs and retain a prior draft without selecting a vanished remote."""
226
+ self.reset_force()
227
+ previous = self.snapshot
228
+ previous_endpoint = getattr(self, "endpoint", "")
229
+ remote_name = self.remote_selector.currentData() if previous else fetch_remote_name(snapshot, head_ref)
230
+ # A remote proposed by a tag creation that pinned this window before its first read is chosen once the remote
231
+ # list arrives; a later read keeps whatever remote the user chose since.
232
+ if not previous and self.created_tag is not None and self.created_tag.remote_name in snapshot.entry_by_name:
233
+ remote_name = self.created_tag.remote_name
234
+ source_ref = self.source_selector.currentData() if previous else head_ref
235
+ destination = self.destination_selector.currentText() if previous else ""
236
+ tag_selection = self.tag_selector.currentData(TAG_SELECTION_ROLE) if previous else ALL_TAG_SELECTION
237
+ if self.created_tag is not None:
238
+ tag_selection = self.created_tag.full_ref
239
+ self.loading = True
240
+ self.snapshot = snapshot
241
+ self.needs_reread = False
242
+ self.remote_selector.clear()
243
+ for name in sorted(snapshot.entry_by_name):
244
+ entry = snapshot.entry_by_name[name]
245
+ self.remote_selector.addItem(name + (" (inactive)" if not entry.active else ""), name)
246
+ self.remote_selector.setCurrentIndex(self.remote_selector.findData(remote_name))
247
+ self.source_selector.clear()
248
+ for full_ref in sorted(snapshot.tracking_by_ref):
249
+ self.source_selector.addItem(full_ref.removeprefix(LOCAL_PREFIX), full_ref)
250
+ self.source_selector.setCurrentIndex(self.source_selector.findData(source_ref))
251
+ if self.commit_push_intent is not None:
252
+ self.pin_commit(self.commit_push_intent, self.commit_push_message)
253
+ self.tag_selector.clear()
254
+ if tag_oid_by_ref:
255
+ self.tag_selector.addItem("[ All ]", tuple(sorted(tag_oid_by_ref.items())))
256
+ self.tag_selector.setItemData(self.tag_selector.count() - 1, ALL_TAG_SELECTION, TAG_SELECTION_ROLE)
257
+ for full_ref in sorted(tag_oid_by_ref):
258
+ self.tag_selector.addItem(full_ref.removeprefix(TAG_PREFIX), ((full_ref, tag_oid_by_ref[full_ref]),))
259
+ self.tag_selector.setItemData(self.tag_selector.count() - 1, full_ref, TAG_SELECTION_ROLE)
260
+ self.tag_selector.setCurrentIndex(self.tag_selector.findData(tag_selection, TAG_SELECTION_ROLE))
261
+ if self.created_tag is not None:
262
+ self._lock_pinned_tag()
263
+ self.loading = False
264
+ self._remote_changed()
265
+ self._source_changed()
266
+ if previous:
267
+ self.destination_selector.setEditText(destination)
268
+ self.endpoint_selector.setCurrentIndex(self.endpoint_selector.findData(previous_endpoint))
269
+ self._endpoint_changed()
270
+ self.status_label.setText("Choose the source and destination, then Push.")
271
+ self.update_action()
272
+
273
+ def pin_tag(self, full_ref: str, object_oid: str, remote_name: str = "") -> bool:
274
+ """Restrict a creation follow-up to that exact tag object, never all tags, and choose the remote it proposed.
275
+
276
+ The remote is chosen only when the remote list already holds it; a list still being read gets it on arrival,
277
+ and a remote Push does not know is never added, so the choice stays Push's own.
278
+ """
279
+ if self.busy or self.commit_push_intent is not None:
280
+ return False
281
+ self.created_tag = PinnedTag(full_ref, object_oid, remote_name)
282
+ remote_index = self.remote_selector.findData(remote_name) if remote_name else -1
283
+ if remote_index >= 0:
284
+ self.remote_selector.setCurrentIndex(remote_index)
285
+ self._lock_pinned_tag()
286
+ self.reset_force()
287
+ self.update_action()
288
+ return True
289
+
290
+ def _lock_pinned_tag(self) -> None:
291
+ """Select the pinned tag object on the tag page and lock both the list and the branch page."""
292
+ index = self.tag_selector.findData(self.created_tag.full_ref, TAG_SELECTION_ROLE)
293
+ matches = index >= 0 and self.tag_selector.itemData(index) == self.created_tag.selection()
294
+ self.tag_selector.setCurrentIndex(index if matches else -1)
295
+ self.tag_selector.setEnabled(False)
296
+ self.tabs.setCurrentIndex(TAG_TAB_INDEX)
297
+ self.tabs.setTabEnabled(BRANCH_TAB_INDEX, False)
298
+
299
+ def pin_commit(self, intent: CommitPushIntent, message: str) -> None:
300
+ """Make a created commit the sole source while leaving its destination editable.
301
+
302
+ The note arrives once the window is already open, above the form; the window follows it by the shared content
303
+ rule, so the note and the whole form fit without scrolling, within its screen.
304
+ """
305
+ self.commit_push_intent = intent
306
+ self.commit_push_message = message
307
+ commit_text = self.text_filter.redact(message + "\nCommit: " + intent.created_oid)
308
+ reason_text = headline_first(intent.reason_headline, self.text_filter.redact(intent.reason) if intent.reason else "")
309
+ if reason_text:
310
+ commit_text += "\n" + reason_text
311
+ if intent.push_failed:
312
+ self.commit_result_label.show_failure(commit_text)
313
+ else:
314
+ self.commit_result_label.setText(commit_text)
315
+ self.commit_result_label.show()
316
+ self.source_selector.clear()
317
+ self.source_selector.addItem(intent.source_ref.removeprefix(LOCAL_PREFIX) + " — " + intent.created_oid, intent.source_ref or intent.created_oid)
318
+ self.source_selector.setEnabled(False)
319
+ self.tabs.setCurrentIndex(BRANCH_TAB_INDEX)
320
+ for index in range(self.tabs.count()):
321
+ self.tabs.setTabEnabled(index, index == BRANCH_TAB_INDEX)
322
+ self.reset_force()
323
+
324
+ def _remote_changed(self) -> None:
325
+ """Show the exact effective Push URL and require correction for an absent destination."""
326
+ if self.loading or self.snapshot is None:
327
+ return
328
+ entry = self.snapshot.entry_by_name.get(self.remote_selector.currentData())
329
+ self.endpoint_selector.clear()
330
+ for endpoint in entry.push_urls if entry is not None else ():
331
+ self.endpoint_selector.addItem(self.text_filter.redact(endpoint), endpoint)
332
+ self.endpoint_selector.setCurrentIndex(0 if entry is not None and len(entry.push_urls) == 1 else -1)
333
+ self._endpoint_changed()
334
+ destination = self.destination_selector.currentText()
335
+ self.destination_selector.clear()
336
+ remote_prefix = REMOTE_PREFIX + (self.remote_selector.currentData() or "") + "/"
337
+ for record in self.snapshot.references.splitlines():
338
+ full_ref, _separator, _rest = record.partition(b"\0")
339
+ name = full_ref.decode("utf-8", "surrogateescape")
340
+ if name.startswith(remote_prefix):
341
+ self.destination_selector.addItem(name.removeprefix(remote_prefix))
342
+ self.destination_selector.setEditText(destination)
343
+
344
+ def _endpoint_changed(self) -> None:
345
+ """Keep one full endpoint identity separate from its filtered display text."""
346
+ self.endpoint = self.endpoint_selector.currentData() or ""
347
+ if self.remote_radio.isChecked():
348
+ self.url_edit.setText(self.text_filter.redact(self.endpoint))
349
+ if not self.loading:
350
+ self.destination_changed.emit()
351
+ self.update_action()
352
+
353
+ def _source_changed(self) -> None:
354
+ """Suggest the local name for a branch without tracking, otherwise use its matching upstream."""
355
+ if self.loading or self.snapshot is None:
356
+ return
357
+ self.destination_selector.setEditText(default_push_destination_name(self.snapshot, self.source_selector.currentData(), self.remote_selector.currentData()))
358
+ self.update_action()
359
+
360
+ def _mode_changed(self) -> None:
361
+ """Switch the destination input without interpreting a displayed redacted URL as authority."""
362
+ remote_mode = self.remote_radio.isChecked()
363
+ self.remote_selector.setEnabled(remote_mode)
364
+ self.endpoint_selector.setEnabled(remote_mode)
365
+ self.url_edit.setEnabled(not remote_mode)
366
+ self.browse_button.setEnabled(not remote_mode)
367
+ if remote_mode:
368
+ self._remote_changed()
369
+ else:
370
+ self.url_edit.clear()
371
+ self.destination_changed.emit()
372
+ self.update_action()
373
+
374
+ def update_action(self, *_arguments: object) -> None:
375
+ """Disable incomplete, loading and unsupported submissions with a visible reason.
376
+
377
+ The reason is ordinary text, except GitPane's refusal to send without an account, shown in bold orange: nothing
378
+ is wrong with the draft, the user only has to choose an account in Settings.
379
+ """
380
+ if not hasattr(self, "push_button"):
381
+ return
382
+ reason = ""
383
+ if self.loading:
384
+ reason = "Reading source and destination…"
385
+ elif self.busy:
386
+ reason = "Push in progress…"
387
+ elif self.needs_reread:
388
+ reason = "Reread destination before another Push."
389
+ elif not self.available:
390
+ reason = OWNED_REPOSITORY_TEXT
391
+ elif self.created_tag is not None and self.tag_selector.currentData() != self.created_tag.selection():
392
+ reason = "The created tag changed or disappeared. Close Push and review the tag before sending."
393
+ elif self.tabs.currentIndex() != BRANCH_TAB_INDEX and not self.tag_selector.currentData():
394
+ reason = "Select a tag or explicitly choose [ All ] before pushing tags." if self.tag_selector.count() else "Create a tag from a commit before pushing tags."
395
+ elif self.tabs.currentIndex() == BRANCH_TAB_INDEX and not self.source_selector.currentData():
396
+ reason = "No local branch selected."
397
+ elif self.tabs.currentIndex() == BRANCH_TAB_INDEX and not self.typed_destination_branch():
398
+ reason = "Choose a remote branch destination."
399
+ elif not self.chosen_endpoint():
400
+ reason = "No remote configured. Use Manage remotes or Url to choose a destination."
401
+ elif self.target_error or self.push_target is None:
402
+ reason = self.target_error or "Choose a Push endpoint."
403
+ self.push_button.setEnabled(not reason and not self.closed)
404
+ if reason and is_no_account_refusal(reason):
405
+ self.status_label.show_warning(reason)
406
+ elif reason:
407
+ self.status_label.setText(reason)
408
+ else:
409
+ self.status_label.setText("Ready to push the selected branch." if self.tabs.currentIndex() == BRANCH_TAB_INDEX else "Ready to push the selected tags.")
410
+ self.url_edit.setEnabled(not self.remote_radio.isChecked())
411
+ self.browse_button.setEnabled(not self.remote_radio.isChecked())
412
+
413
+ def chosen_endpoint(self) -> str:
414
+ """Return where Push sends: the chosen remote's endpoint, or the typed Url without the blanks around it.
415
+
416
+ In the typed Url, ``~`` alone or ``~/…`` at the start stands for the home folder where that is the usage; only a
417
+ local folder address can change, since no HTTPS, SSH or ``file://`` address starts with ``~/``. The button
418
+ state, the account bound to the destination, the captured target and the request all read the address here, so
419
+ they always agree on the same address; the Url field keeps the text as typed.
420
+ """
421
+ if self.remote_radio.isChecked():
422
+ return getattr(self, "endpoint", "")
423
+ return typed_path_value(self.url_edit.text(), self.typed_path)
424
+
425
+ def typed_destination_branch(self) -> str:
426
+ """Return the remote branch typed or chosen, without the blanks around it; the field keeps the text as typed."""
427
+ return typed_value(self.destination_selector.currentText())
428
+
429
+ def submit(self) -> None:
430
+ """Emit the exact displayed draft once without executing Git from the widget."""
431
+ self.check_availability()
432
+ if not self.push_button.isEnabled() or self.snapshot is None:
433
+ return
434
+ endpoint = self.chosen_endpoint()
435
+ remote_name = self.remote_selector.currentData() if self.remote_radio.isChecked() else ""
436
+ branch_mode = self.tabs.currentIndex() == BRANCH_TAB_INDEX
437
+ tag_refs = () if branch_mode else tuple(self.tag_selector.currentData())
438
+ self.request = (self.snapshot, remote_name, endpoint, self.source_selector.currentData() if branch_mode else "",
439
+ LOCAL_PREFIX + self.typed_destination_branch() if branch_mode else "",
440
+ self.force_check.isChecked() if branch_mode else self.tag_force_check.isChecked(), self.push_target, tag_refs)
441
+ self.reset_force()
442
+ self.busy = True
443
+ self.content.setEnabled(False)
444
+ self.reread_button.setEnabled(False)
445
+ self.update_action()
446
+ self.push_requested.emit(self.request)
447
+
448
+ def cancel_send(self, force: bool) -> None:
449
+ """Give the draft back as it was before Push was clicked, when the user answers Cancel to a question asked before sending.
450
+
451
+ Nothing was sent and nothing went wrong, so no error is shown; Force comes back checked when it was, since
452
+ submit cleared it for the send that did not happen.
453
+ """
454
+ self.busy = False
455
+ self.request = None
456
+ self.content.setEnabled(True)
457
+ self.reread_button.setEnabled(True)
458
+ if self.tabs.currentIndex() == BRANCH_TAB_INDEX:
459
+ self.force_check.setChecked(force)
460
+ else:
461
+ self.tag_force_check.setChecked(force)
462
+ # check_availability ignores a busy window, so a repository taken by another owner meanwhile is only seen here.
463
+ self.available = self.is_available()
464
+ self.update_action()
465
+
466
+ def follow_operation(self, operation_id: str) -> None:
467
+ """Treat a push started for this draft from elsewhere (Force push with lease chosen after a refusal) as this window's own send."""
468
+ self.operation_id = operation_id
469
+ self.busy = True
470
+ self.content.setEnabled(False)
471
+ self.reread_button.setEnabled(False)
472
+ self.update_action()
473
+
474
+ def check_availability(self) -> None:
475
+ """Reflect another repository owner without replacing this operation's terminal details.
476
+
477
+ The transport controller connects it to each change of the repository operation register, so the window never polls.
478
+ """
479
+ if self.busy or self.closed:
480
+ return
481
+ available = self.is_available()
482
+ if available != self.available:
483
+ self.available = available
484
+ if not available:
485
+ self.reset_force()
486
+ self.update_action()
487
+
488
+ def reset_force(self, *_arguments: object) -> None:
489
+ """Require a fresh explicit choice for each target and each independent submission."""
490
+ self.force_check.setChecked(False)
491
+ self.tag_force_check.setChecked(False)
492
+
493
+ def show_error(self, message: str, headline: str = "") -> None:
494
+ """Keep failed drafts available and show only filtered plain-text diagnostics, in bold red.
495
+
496
+ GitPane's refusal to send without an account is shown in bold orange instead, as when Push opens: nothing was
497
+ sent and the user only has to choose an account in Settings.
498
+
499
+ A ``headline`` built by GitPane from an address and an account label comes first and is not filtered, so the
500
+ account it names stays readable. The window follows its status line both ways by the shared content rule: it
501
+ grows so the whole message and the form fit without scrolling, within its screen, and takes back its height
502
+ when the reread replaces the message.
503
+ """
504
+ self.reset_force()
505
+ self.busy = False
506
+ self.loading = False
507
+ self.content.setEnabled(True)
508
+ self.reread_button.setEnabled(True)
509
+ # check_availability ignores a busy window, so a repository released while this send was running is only seen here.
510
+ self.available = self.is_available()
511
+ self.update_action()
512
+ shown_text = headline_first(headline, self.text_filter.redact(message))
513
+ if is_no_account_refusal(shown_text):
514
+ self.status_label.show_warning(shown_text)
515
+ else:
516
+ self.status_label.show_failure(shown_text)
517
+
518
+ def show_outcome(self, outcome: TransportOutcome, account: TransferAccount | None = None) -> None:
519
+ """Close after a verified success, like Git Extensions; otherwise keep the draft and its details without any implicit second push.
520
+
521
+ A failure starts with the same sentence as the Push console: the branches the server itself refused (a hook,
522
+ for instance) with its message and reason, or else the address and the ``account`` used when the server refused
523
+ it. It ends with Git's own lines without debug traces or stack lines. The console keeps the full result and
524
+ follows its own Keep dialog open preference.
525
+ """
526
+ if outcome.status == OperationStatus.SUCCESS:
527
+ self.accept()
528
+ return
529
+ self.needs_reread = True
530
+ details = ["Push: " + outcome.status.value]
531
+ for result in outcome.remote_results:
532
+ details.append((result.remote_name or "Url") + " — " + result.endpoint + ": " + result.status.value + " (" + result.effect + ")")
533
+ for result in outcome.ref_results:
534
+ details.append(result.full_ref + ": " + result.status.value + " (" + result.effect + ")")
535
+ for step in outcome.steps:
536
+ details.append(step.operation + ": " + step.status.value)
537
+ if outcome.status == OperationStatus.UNKNOWN:
538
+ details.append("Outcome unknown. Review the same remote before another Push.")
539
+ elif outcome.status != OperationStatus.SUCCESS:
540
+ details.append("Push failed. Review the details and choose how to proceed.")
541
+ headline = ""
542
+ if outcome.diagnostic is not None:
543
+ headline = server_refusal_headline(outcome.server_refusals, outcome.diagnostic.stderr) or failure_headline(outcome.diagnostic, account)
544
+ details.append(failure_summary(outcome.diagnostic))
545
+ self.show_error("\n".join(details), headline)
546
+ self.push_button.setEnabled(False)
547
+
548
+ def browse(self) -> None:
549
+ """Open a nonblocking directory selector from the folder the typed Url stands for, ``~`` read where that is the usage.
550
+
551
+ A Url that is not an absolute folder leaves the selector at its own start folder; cancelling changes nothing.
552
+ """
553
+ self._open_folder_chooser(typed_start_dir(self.url_edit.text(), self.typed_path))
554
+
555
+ def _open_folder_chooser(self, start_dir: str) -> None:
556
+ """Open the directory selector at ``start_dir``, tied to the current draft lifetime; cancelling changes nothing."""
557
+ dialog = QFileDialog(self, "Choose Push repository", start_dir)
558
+ dialog.setFileMode(QFileDialog.FileMode.Directory)
559
+ def selected(filename: str) -> None:
560
+ """Keep the chosen directory in the draft without starting network work."""
561
+ if not self.closed:
562
+ self.url_edit.setText(filename)
563
+ self.destination_changed.emit()
564
+ dialog.fileSelected.connect(selected)
565
+ dialog.open()
566
+
567
+ def refresh_metrics(self, available_geometry: QRect | None = None) -> None:
568
+ """Fit the fields and permanent Push action within the available screen."""
569
+ fit_form(self, available_geometry)
570
+
571
+ def showEvent(self, event: QShowEvent) -> None:
572
+ """Apply current screen bounds without scaling logical coordinates twice."""
573
+ super().showEvent(event)
574
+ self.refresh_metrics()
575
+
576
+ def _closed(self) -> None:
577
+ """Invalidate pending reads and return keyboard focus to the live opener."""
578
+ self.closed = True
579
+ if self.return_focus is not None and isValid(self.return_focus):
580
+ self.return_focus.setFocus()