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,776 @@
1
+ """Clone a repository from the welcome window, submodules included."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QObject, Signal
9
+ from shiboken6 import isValid
10
+
11
+ from gitpane.account.account_choice import clone_account_row
12
+ from gitpane.account.address_owner import address_owner, owner_account_namespace
13
+ from gitpane.account.authentication import check_repository_url, endpoint_origin, transfer_account
14
+ from gitpane.config import AUTHENTICATION_PREFERENCE_KEY, ConsoleOperation
15
+ from gitpane.operation.console_tone import ConsoleTone
16
+ from gitpane.operation.diagnostic import Diagnostic
17
+ from gitpane.operation.operation_status import OperationStatus
18
+ from gitpane.transport.clone import BARE_CLONE_DONE_TEXT, CloneIntent, clipboard_local_repository_candidate, clipboard_repository_url
19
+ from gitpane.transport.git_refusal import authentication_refused
20
+ from gitpane.transport.refusal_notice import CLONE_NOTICE_SUBJECT_TEXT, SUBMODULE_NOTICE_SUBJECT_TEXT, account_notice, failure_headline, failure_summary
21
+ from gitpane.ui.console.operation_dialog import OperationDialog
22
+ from gitpane.ui.setting.account_page import fill_repository_combo
23
+ from gitpane.ui.setting.add_account_dialog import AddAccountDialog, sign_in_result_text
24
+ from gitpane.ui.transport.clone_dialog import CLONE_CANCELLED_PRESERVED_TEXT, CLONE_CANCELLED_TEXT, OPENING_CANCELLED_TEXT, CloneDialog, CloneDialogText
25
+ from gitpane.ui.widget.result_label import ResultTone
26
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable
30
+
31
+ from PySide6.QtWidgets import QApplication, QWidget
32
+
33
+ from gitpane.account.account_choice import CloneAccountRow
34
+ from gitpane.account.authentication import AccountSelection, AuthenticationService, AuthOutcome, SecretLookup
35
+ from gitpane.controller.account_controller import AccountController
36
+ from gitpane.controller.operation_policy import OperationPolicy
37
+ from gitpane.controller.session_registry import SessionRegistry
38
+ from gitpane.history.repository import GitRepository
39
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
40
+ from gitpane.preference.preference_store import PreferenceStore
41
+ from gitpane.transport.clone import BranchList, CloneRun, CloneService
42
+ from gitpane.transport.refusal_notice import TransferAccount
43
+
44
+ # Title of the operation console a clone opens as soon as it starts, the same console Fetch and Push use.
45
+ CLONE_CONSOLE_TITLE = "Clone repository output"
46
+
47
+
48
+ class CloneController(QObject):
49
+ """The clone form of each welcome window with its console, its sign-in window and its retries, up to the repository it hands to the opening."""
50
+
51
+ # Session of the welcome window, folder of the cloned repository: the opening reads it as if the user had chosen it.
52
+ open_requested = Signal(str, str)
53
+ # Session of the welcome window whose opening of the cloned repository is cancelled with the clone.
54
+ open_cancel_requested = Signal(str)
55
+
56
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, account_controller: AccountController, application: QApplication, preference: PreferenceStore, repository: GitRepository,
57
+ authentication: AuthenticationService, clone: CloneService, diagnostic_log: DiagnosticLog | None, parent: QObject | None = None) -> None:
58
+ """Receive the services and the lower controllers a clone works with; the wiring builds it after the accounts controller, and the opening learns from its signals which cloned repository to open or which opening to cancel."""
59
+ super().__init__(parent)
60
+ self.session_registry_controller = session_registry_controller
61
+ self.operation_policy_controller = operation_policy_controller
62
+ self.account_controller = account_controller
63
+ self.application = application
64
+ self.preference = preference
65
+ self.repository = repository
66
+ self.authentication = authentication
67
+ self.clone = clone
68
+ self.diagnostic_log = diagnostic_log
69
+ self.clone_output_by_id: dict[str, OperationDialog] = {}
70
+
71
+ def show_clone(self, session_id: str) -> CloneDialog | None:
72
+ """Open a clone form only for a current welcome window."""
73
+ welcome = self.session_registry_controller.welcome_by_session.get(session_id)
74
+ if welcome is None or session_id in self.session_registry_controller.identity_by_session:
75
+ return None
76
+ previous = self.session_registry_controller.clone_dialog_by_session.get(session_id)
77
+ if previous is not None:
78
+ previous.show()
79
+ previous.raise_()
80
+ return previous
81
+ dialog = CloneDialog(welcome)
82
+ self.session_registry_controller.clone_dialog_by_session[session_id] = dialog
83
+ dialog.clone_requested.connect(lambda: self._submit_clone(session_id))
84
+ dialog.cancel_requested.connect(lambda: self.cancel_clone(session_id))
85
+ dialog.open_requested.connect(lambda: self._retry_clone_open(session_id))
86
+ dialog.retry_requested.connect(lambda: self._retry_clone_submodules(session_id))
87
+ dialog.add_account_button.clicked.connect(lambda: self._show_clone_add_account(session_id))
88
+ dialog.url_edit.textChanged.connect(lambda: self._refresh_clone_endpoint(session_id))
89
+ dialog.branch_list_requested.connect(lambda: self._list_clone_branches(session_id))
90
+ dialog.branch_list_outdated.connect(lambda: self._cancel_clone_branch_list(dialog))
91
+ dialog.finished.connect(lambda: self._cancel_clone_branch_list(dialog))
92
+ dialog.finished.connect(lambda: self.session_registry_controller.clone_dialog_by_session.pop(session_id, None))
93
+ self._prefill_clone_form(dialog)
94
+ open_window_modal(dialog)
95
+ return dialog
96
+
97
+ def _prefill_clone_form(self, dialog: CloneDialog) -> None:
98
+ """Fill a new clone form with the destinations of the last started clones and with a repository found on the clipboard.
99
+
100
+ The remembered folders are offered as saved, without looking at the disk: a disconnected network drive must not
101
+ freeze the form, and the clone itself refuses a folder that no longer exists. A repository URL on the clipboard
102
+ is filled in at once, after the signals are connected, so the repository name and the account row follow it like
103
+ a typed URL. An absolute folder path on one line is handed to the form, which checks on a background thread
104
+ whether a repository is there and only then writes it into the field, if the field is still empty. Nothing is
105
+ started from the clipboard text.
106
+ """
107
+ dialog.set_destination_dirs(self.preference.read_clone_destinations())
108
+ clipboard_text = self.application.clipboard().text()
109
+ clipboard_url = clipboard_repository_url(clipboard_text)
110
+ if clipboard_url:
111
+ dialog.url_edit.setText(clipboard_url)
112
+ return
113
+ local_candidate = clipboard_local_repository_candidate(clipboard_text)
114
+ if local_candidate:
115
+ dialog.check_clipboard_repository(local_candidate)
116
+
117
+ def _list_clone_branches(self, session_id: str) -> None:
118
+ """Read the remote branches of the clone form's URL in the background, with the account the clone would use.
119
+
120
+ An empty or unfinished URL only keeps the remote HEAD entry. A failure only shows a warning icon beside the list,
121
+ saying that the branches could not be read: it never opens Settings and never writes to a clone's console, since the user may
122
+ still be typing. Nothing is read once the form shows a finished clone or the address of a submodule that asked
123
+ for an account: its URL is then fixed and its account menu describes the submodule's address, not the URL's. After
124
+ a failure the form may switch itself to the account named like the address's owner, which reads the list again.
125
+ """
126
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
127
+ if dialog is None or session_id not in self.session_registry_controller.welcome_by_session or self.session_registry_controller.shutdown_requested:
128
+ return
129
+ if dialog.partial or dialog.authentication_endpoint:
130
+ return
131
+ self._cancel_clone_branch_list(dialog)
132
+ url = dialog.repository_url()
133
+ if not self.clone.accepts_endpoint(url):
134
+ dialog.show_branches(())
135
+ return
136
+ selection = None
137
+ namespace = dialog.account_choice()
138
+ if namespace is not None:
139
+ selection = self._clone_listed_account(dialog, namespace)
140
+ if selection is None:
141
+ dialog.show_branch_failure()
142
+ return
143
+ dialog.set_branch_loading()
144
+
145
+ def complete(branch_list: BranchList) -> None:
146
+ """Show the answer only in the same form, and only for the last branch list it asked for."""
147
+ if self.session_registry_controller.clone_dialog_by_session.get(session_id) is not dialog or not isValid(dialog) or dialog.branch_request_id != branch_list.request_id:
148
+ return
149
+ dialog.branch_request_id = ""
150
+ if branch_list.cause:
151
+ dialog.show_branch_failure()
152
+ self._choose_owner_account(dialog)
153
+ else:
154
+ dialog.show_branches(branch_list.branches)
155
+
156
+ dialog.branch_request_id = self.clone.list_remote_branches(url, self.repository.git_filename, selection, complete)
157
+
158
+ def _choose_owner_account(self, dialog: CloneDialog) -> None:
159
+ """Switch the clone form to the account named like the owner written in its address, after the branches could not be read.
160
+
161
+ The user often has one account per owner and keeps another as the default, which cannot read a private
162
+ repository of the first. Only a safe case switches: the user did not pick an account in this form, no clone runs,
163
+ the form is about the typed URL (not a submodule's address), exactly one listed account of that service address
164
+ is named like the owner, and it is not already the account in use, the chosen one or the default behind 'Use
165
+ global default'. The switch reads the branches again once; if they still cannot be read, the owner's account is
166
+ then the one in use and nothing switches again. The form keeps the owner and the choice made before, so that an
167
+ address naming another owner puts that choice back and removes the line.
168
+ """
169
+ if dialog.account_chosen_by_user or dialog.running or dialog.partial or dialog.authentication_endpoint or not dialog.account_origin:
170
+ return
171
+ try:
172
+ catalogue = self.authentication.catalogue()
173
+ except (OSError, ValueError, TypeError):
174
+ return
175
+ namespace = owner_account_namespace(catalogue.accounts, dialog.repository_url())
176
+ if namespace is None:
177
+ return
178
+ account_in_use = dialog.account_choice() or catalogue.default_namespace_by_origin.get(dialog.account_origin)
179
+ if account_in_use == namespace:
180
+ return
181
+ for selection in catalogue.accounts:
182
+ if selection.namespace == namespace and selection.origin == dialog.account_origin:
183
+ dialog.choose_owner_account(namespace, address_owner(dialog.repository_url()), CloneDialogText.OWNER_ACCOUNT_CHOSEN.value.format(label=selection.label))
184
+ return
185
+
186
+ def _cancel_clone_branch_list(self, dialog: CloneDialog) -> None:
187
+ """Stop the branch list a clone form waits for, when its URL or account changed or when it closed; a late answer is then ignored."""
188
+ request_id = dialog.branch_request_id
189
+ if not request_id:
190
+ return
191
+ dialog.branch_request_id = ""
192
+ self.clone.cancel_branch_list(request_id)
193
+
194
+ def _submit_clone(self, session_id: str) -> None:
195
+ """Freeze exact form data and retain the process owner outside the dialog.
196
+
197
+ 'Use global default' gives no selection: the clone then resolves the address's default and nothing is saved for
198
+ the new repository. An explicit account is used by the clone and saved for the new repository once it opens.
199
+ """
200
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
201
+ if dialog is None or dialog.running or dialog.partial or session_id not in self.session_registry_controller.welcome_by_session:
202
+ return
203
+ if self._clone_form_signing_in(dialog):
204
+ dialog.status_label.show_failure("Finish signing in successfully before cloning.")
205
+ return
206
+ selection = None
207
+ namespace = dialog.account_choice()
208
+ if namespace is not None:
209
+ selection = self._clone_listed_account(dialog, namespace)
210
+ if selection is None:
211
+ dialog.status_label.show_failure("Choose a confirmed account for this repository before cloning.")
212
+ return
213
+ generation = self.session_registry_controller.clone_generation_by_session.get(session_id, 0) + 1
214
+ self.session_registry_controller.clone_generation_by_session[session_id] = generation
215
+ intent = CloneIntent(dialog.repository_url(), dialog.destination_dir(), dialog.subdirectory_name(), session_id, generation, selection, options=dialog.clone_options())
216
+ dialog.set_running(True)
217
+ run = self.clone.submit(intent, self.repository.git_filename)
218
+ dialog.operation_id = run.operation_id
219
+ if run.clone_status != "running":
220
+ # Refused before Git ran (invalid URL, existing destination): there is no output to show, and the form
221
+ # shows the cause once the refusal is published.
222
+ return
223
+ self._open_clone_console(session_id, run, dialog)
224
+ self._remember_clone_destination(intent.parent_dir)
225
+
226
+ def _remember_clone_destination(self, parent_dir: str) -> None:
227
+ """Put the parent folder of a clone that really started at the front of the next clone forms' Destination list.
228
+
229
+ A refused write only costs the suggestion, so it goes to the --log file and never interrupts the clone.
230
+ """
231
+ try:
232
+ self.preference.remember_clone_destination(parent_dir)
233
+ except (OSError, ValueError, TypeError) as error:
234
+ diagnostic = Diagnostic("Save clone destination", "", "", [], str(error)).prepare()
235
+ if self.diagnostic_log is not None:
236
+ self.diagnostic_log.write(diagnostic)
237
+
238
+ def _clone_form_signing_in(self, dialog: CloneDialog) -> bool:
239
+ """Tell whether the clone form's sign-in window is still waiting for a sign-in result."""
240
+ add_dialog = dialog.add_account_dialog
241
+ return add_dialog is not None and isValid(add_dialog) and bool(add_dialog.operation_id)
242
+
243
+ def _clone_listed_account(self, dialog: CloneDialog, namespace: str) -> AccountSelection | None:
244
+ """Find the connected account chosen in the clone form, for the form's address only.
245
+
246
+ None means the account is no longer listed, or the list could not be read (the cause is then shown under the menu).
247
+ """
248
+ try:
249
+ accounts = self.authentication.catalogue().accounts
250
+ except (OSError, ValueError, TypeError) as error:
251
+ dialog.show_account_status(Diagnostic("Clone authentication", "", "", [], str(error)).prepare().cause)
252
+ return None
253
+ for selection in accounts:
254
+ if selection.namespace == namespace and selection.origin == dialog.account_origin:
255
+ return selection
256
+ return None
257
+
258
+ def _refresh_clone_endpoint(self, session_id: str) -> None:
259
+ """Show the account row of the address the clone will contact, never the last opened repository's.
260
+
261
+ The address comes from a failed submodule when one asked for authentication, otherwise from the URL. Without an
262
+ HTTPS address (nothing typed yet, SSH, local folder) the row stays in place, greyed with the reason, and a
263
+ running sign-in is cancelled and its window closed. An address on another service, or the first HTTPS address
264
+ after none, cancels a running sign-in, closes its window and starts again from 'Use global default'; another
265
+ address on the same service keeps the running sign-in, which learns the repository to test. After the form
266
+ switched itself to the account of the address's owner, an address naming another owner on the same service puts
267
+ back the choice made before the switch and removes the line that said so; another repository of the same owner
268
+ keeps the owner's account. The choice made before the switch only comes back while the user has chosen no
269
+ account in this form, by the menu or by a sign-in: an account the user chose is never replaced by that earlier
270
+ choice.
271
+ """
272
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
273
+ if dialog is None:
274
+ return
275
+ account_row = self._read_clone_account_row(dialog)
276
+ if account_row is None or not account_row.origin:
277
+ self._close_clone_add_account(session_id)
278
+ dialog.account_origin = ""
279
+ if account_row is not None:
280
+ dialog.show_account_status("")
281
+ dialog.show_account_state(account_row.disabled_text, account_row.add_account_enabled)
282
+ return
283
+ selected_namespace = dialog.account_choice()
284
+ if account_row.origin != dialog.account_origin:
285
+ self._close_clone_add_account(session_id)
286
+ dialog.account_origin = account_row.origin
287
+ dialog.show_account_status("")
288
+ dialog.account_chosen_by_user = False
289
+ dialog.forget_owner_switch()
290
+ selected_namespace = None
291
+ if (not dialog.account_chosen_by_user and dialog.owner_switch_namespace and dialog.owner_switch_namespace == selected_namespace
292
+ and address_owner(dialog.repository_url()).casefold() != dialog.owner_switch_owner):
293
+ selected_namespace = dialog.account_before_owner_switch
294
+ dialog.forget_owner_switch()
295
+ add_dialog = dialog.add_account_dialog
296
+ if add_dialog is not None and isValid(add_dialog) and not add_dialog.closed:
297
+ add_dialog.set_known_check_url(account_row.origin, self._clone_check_url(dialog))
298
+ self._show_clone_account_row(dialog, account_row, selected_namespace)
299
+
300
+ def _read_clone_account_row(self, dialog: CloneDialog) -> CloneAccountRow | None:
301
+ """Read the connected accounts and describe the clone form's Account row, or say under the row why they could not be read.
302
+
303
+ None means the accounts could not be read: the row is then greyed without any entry, since nothing can be offered.
304
+ """
305
+ try:
306
+ return clone_account_row(self.authentication.catalogue(), self._clone_contacted_url(dialog))
307
+ except (OSError, ValueError, TypeError) as error:
308
+ dialog.show_account_state("", False)
309
+ dialog.show_account_status(Diagnostic("Clone authentication", "", "", [], str(error)).prepare().cause)
310
+ return None
311
+
312
+ def _show_clone_account_row(self, dialog: CloneDialog, account_row: CloneAccountRow, selected_namespace: str | None) -> None:
313
+ """Fill the clone form's menu like a This repository row ('Use global default — …' first, then the address's accounts), or grey it with its reason."""
314
+ if account_row.disabled_text or account_row.row is None:
315
+ dialog.show_account_state(account_row.disabled_text, account_row.add_account_enabled)
316
+ else:
317
+ fill_repository_combo(dialog.account_combo, account_row.row, selected_namespace)
318
+ dialog.add_account_button.setEnabled(account_row.add_account_enabled)
319
+ if account_row.row is not None:
320
+ dialog.account_combo.setToolTip(account_row.row.title)
321
+
322
+ def _clone_contacted_url(self, dialog: CloneDialog) -> str:
323
+ """Return the URL the clone form is about: the refused submodule's when one asked for authentication, else the typed repository URL."""
324
+ return dialog.authentication_endpoint or dialog.repository_url()
325
+
326
+ def _clone_check_url(self, dialog: CloneDialog) -> str:
327
+ """Return the repository on which an account added from the clone form is tested: the URL the form contacts, without its credentials part.
328
+
329
+ A URL the test cannot use is passed as typed, so the account service refuses it with its own reason instead of
330
+ the window asking for another repository.
331
+ """
332
+ url = self._clone_contacted_url(dialog).strip()
333
+ try:
334
+ return check_repository_url(url, dialog.account_origin)
335
+ except ValueError:
336
+ return url
337
+
338
+ def refresh_clone_account(self, session_id: str, selected_namespace: str | None) -> None:
339
+ """Show the clone form's menu again after the accounts changed, for the address it already shows, with this account chosen.
340
+
341
+ None as the choice follows 'Use global default'. A form whose address changed meanwhile is left to its own refresh.
342
+ """
343
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
344
+ if dialog is None or not dialog.account_origin:
345
+ return
346
+ account_row = self._read_clone_account_row(dialog)
347
+ if account_row is None or account_row.origin != dialog.account_origin:
348
+ return
349
+ self._show_clone_account_row(dialog, account_row, selected_namespace)
350
+
351
+ def _show_clone_add_account(self, session_id: str) -> None:
352
+ """Open the shared sign-in window with the address fixed to the one the clone will contact.
353
+
354
+ A username + password on that address is tested on the repository being cloned, or on the refused submodule,
355
+ so the window names that URL instead of asking for one.
356
+ """
357
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
358
+ if dialog is None or dialog.running or not dialog.account_origin or session_id not in self.session_registry_controller.welcome_by_session:
359
+ return
360
+ existing = dialog.add_account_dialog
361
+ if existing is not None and isValid(existing) and not existing.closed:
362
+ existing.showNormal()
363
+ existing.raise_()
364
+ existing.activateWindow()
365
+ return
366
+ add_dialog = AddAccountDialog(dialog, locked_origin=dialog.account_origin, service_name_by_origin=self.account_controller.service_name_by_origin(),
367
+ known_check_url_by_origin={dialog.account_origin: self._clone_check_url(dialog)},
368
+ browser_timeout_ms=self.authentication.browser_timeout_ms)
369
+ dialog.add_account_dialog = add_dialog
370
+
371
+ def finished() -> None:
372
+ """Forget the closed window so the next Add account… opens a fresh one."""
373
+ if isValid(dialog) and dialog.add_account_dialog is add_dialog:
374
+ dialog.add_account_dialog = None
375
+ add_dialog.deleteLater()
376
+
377
+ add_dialog.sign_in_requested.connect(lambda token_mode: self._start_clone_account_sign_in(session_id, dialog, add_dialog, token_mode))
378
+ add_dialog.cancel_requested.connect(lambda: self.account_controller.cancel_account_sign_in(add_dialog))
379
+ add_dialog.finished.connect(finished)
380
+ add_dialog.show()
381
+
382
+ def _start_clone_account_sign_in(self, session_id: str, dialog: CloneDialog, add_dialog: AddAccountDialog, token_mode: bool) -> None:
383
+ """Start a sign-in whose result is accepted only while the same clone form still shows the same address.
384
+
385
+ When a failed submodule asked for the sign-in, the result also needs the same partial clone, the same failed
386
+ submodule and an unchanged destination. The address sent is the fixed one, never the full URL, so a user name
387
+ written in the URL is not used as a sign-in hint. A username + password is tested on the URL the form contacts
388
+ at the moment of the click; a refused test leaves the partial clone as it is and retries nothing.
389
+ """
390
+ if dialog.running:
391
+ add_dialog.show_refusal("Wait until the clone stops before signing in.")
392
+ return
393
+ origin = dialog.account_origin
394
+ add_dialog.set_known_check_url(origin, self._clone_check_url(dialog))
395
+ run = self.clone.run_by_id.get(dialog.operation_id) if dialog.operation_id is not None else None
396
+ target = run.authentication_target if run is not None else None
397
+ clone_intent = run.intent if run is not None else None
398
+
399
+ def owner_is_current() -> bool:
400
+ """Accept a sign-in result only for its still-visible clone form and address."""
401
+ if target is not None:
402
+ try:
403
+ run.destination.verify()
404
+ except (OSError, ValueError):
405
+ return False
406
+ if run.authentication_target is not target or run.intent is not clone_intent or not self.operation_policy_controller.clone_is_current(clone_intent):
407
+ return False
408
+ return (self.session_registry_controller.clone_dialog_by_session.get(session_id) is dialog and session_id in self.session_registry_controller.welcome_by_session and not self.session_registry_controller.shutdown_requested
409
+ and dialog.account_origin == origin)
410
+
411
+ self.account_controller.start_account_sign_in(add_dialog, token_mode, owner_is_current, lambda outcome: self._clone_account_signed_in(session_id, outcome))
412
+
413
+ def _clone_account_signed_in(self, session_id: str, outcome: AuthOutcome) -> None:
414
+ """Show the new account in the clone form: through 'Use global default' when it became the default, else chosen explicitly.
415
+
416
+ After a submodule asked for authentication, the new account is always chosen explicitly for that submodule and
417
+ the submodules are retried at once; it is saved for that submodule only, after the retry succeeds. Otherwise the
418
+ branch list is read again, since the menu was changed by the program and the new account may see the branches
419
+ the last list could not read. The account signed in from the form is the user's choice: the form then forgets
420
+ its own switch to the account of the address's owner, so that no later address puts back the choice made before
421
+ that switch; the line saying how the sign-in went stays. A refusal notice an open Settings window shows for the
422
+ same address is cleared, as when the account is added from Settings itself.
423
+ """
424
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
425
+ if dialog is None or outcome.selection is None:
426
+ return
427
+ run = self.clone.run_by_id.get(dialog.operation_id) if dialog.operation_id is not None else None
428
+ for_submodule = run is not None and run.authentication_target is not None
429
+ selected_namespace = None if outcome.became_default and not for_submodule else outcome.selection.namespace
430
+ self.refresh_clone_account(session_id, selected_namespace)
431
+ dialog.show_account_status(sign_in_result_text(outcome, reconnect=False))
432
+ account = transfer_account(outcome.selection)
433
+ setting_dialog = self.session_registry_controller.setting_dialog
434
+ if account is not None and setting_dialog is not None and isValid(setting_dialog):
435
+ setting_dialog.account_page.clear_account_notice(account.address)
436
+ if for_submodule:
437
+ run.child_selection = outcome.selection
438
+ self._retry_clone_submodules(session_id)
439
+ return
440
+ dialog.account_chosen_by_user = True
441
+ dialog.forget_owner_switch()
442
+ dialog.restart_branch_list()
443
+
444
+ def _cancel_clone_authentication(self, session_id: str) -> None:
445
+ """Stop only the sign-in started from this clone form; its late result is then ignored."""
446
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
447
+ if dialog is None:
448
+ return
449
+ if self._clone_form_signing_in(dialog):
450
+ self.account_controller.cancel_account_sign_in(dialog.add_account_dialog)
451
+
452
+ def _close_clone_add_account(self, session_id: str) -> None:
453
+ """Cancel the clone form's sign-in and close its window, whose fixed address no longer matches the form."""
454
+ self._cancel_clone_authentication(session_id)
455
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
456
+ if dialog is None:
457
+ return
458
+ add_dialog = dialog.add_account_dialog
459
+ if add_dialog is not None and isValid(add_dialog) and not add_dialog.closed:
460
+ add_dialog.reject()
461
+
462
+ def clone_finished(self, run: CloneRun) -> None:
463
+ """Finish the clone's console whatever became of its form, then publish only current terminal clones in the form."""
464
+ output = self.clone_output_by_id.get(run.operation_id)
465
+ if output is not None and isValid(output):
466
+ failure_text, headline = self._clone_console_failure(run)
467
+ output.show_result(OperationStatus(run.recursive_status), failure_text=failure_text, failure_headline=headline)
468
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(run.intent.session_id)
469
+ if run.cancelled and dialog is not None and dialog.operation_id == run.operation_id:
470
+ cause = CLONE_CANCELLED_TEXT
471
+ if run.destination is not None and not run.destination_removed:
472
+ cause = CLONE_CANCELLED_PRESERVED_TEXT + run.destination.destination_dir
473
+ if run.incomplete_repository:
474
+ dialog.show_partial_submodules(cause, run.validation_status == OperationStatus.SUCCESS.value, cancelled=True)
475
+ else:
476
+ dialog.show_cancelled(cause, partial=run.clone_status == OperationStatus.SUCCESS.value)
477
+ return
478
+ if run.cancelled or not self.operation_policy_controller.clone_is_current(run.intent):
479
+ return
480
+ dialog = self.session_registry_controller.clone_dialog_by_session[run.intent.session_id]
481
+ if run.recursive_status == OperationStatus.SUCCESS.value:
482
+ if run.validation_status == OperationStatus.SUCCESS.value and run.intent.options.bare:
483
+ self._finish_bare_clone(run, dialog)
484
+ elif run.validation_status == OperationStatus.SUCCESS.value:
485
+ self._open_clone(run.intent.session_id)
486
+ else:
487
+ # Opening is offered again only for a repository with a working tree; a bare clone is never opened.
488
+ dialog.show_failure(failure_summary(run.diagnostic) if run.diagnostic is not None else "Repository validation failed.", partial=not run.intent.options.bare)
489
+ elif run.incomplete_repository:
490
+ partial_cause = failure_summary(run.diagnostic, self._submodule_transfer_account(run)) if run.diagnostic else run.recursive_status
491
+ dialog.show_partial_submodules(partial_cause, run.validation_status == OperationStatus.SUCCESS.value, explained=bool(run.checkout_refusal_text))
492
+ if run.authentication_target is not None:
493
+ if dialog.authentication_endpoint != run.authentication_target.endpoint:
494
+ # Forgetting the shown address makes the refresh below treat the submodule's address as new: any
495
+ # sign-in is cancelled and the row starts again from 'Use global default'.
496
+ dialog.account_origin = ""
497
+ run.child_selection = None
498
+ dialog.authentication_endpoint = run.authentication_target.endpoint
499
+ self._refresh_clone_endpoint(run.intent.session_id)
500
+ dialog.account_section.setEnabled(True)
501
+ cause = "Authentication required for submodule: " + run.authentication_target.entry.path + "\n" + run.authentication_target.endpoint
502
+ dialog.show_account_status(Diagnostic("Submodule authentication", "", "", [], cause).prepare().cause)
503
+ if run.diagnostic is not None:
504
+ subject = SUBMODULE_NOTICE_SUBJECT_TEXT.format(path=run.authentication_target.entry.path)
505
+ notice = account_notice(run.diagnostic, self._submodule_transfer_account(run), subject)
506
+ if notice is not None:
507
+ self.account_controller.offer_account_after_refusal(None, notice)
508
+ # Read after the account row above is refreshed, so the check and its later rewrite name the same account.
509
+ try:
510
+ submodule_selection = self._submodule_account_selection(run)
511
+ except ValueError:
512
+ submodule_selection = None
513
+ self.lookup_refused_account(submodule_selection, run.diagnostic, lambda: self._show_partial_clone_needs_reconnect(run, dialog))
514
+ elif run.diagnostic is None:
515
+ dialog.show_failure(run.clone_status)
516
+ else:
517
+ account = transfer_account(run.selection, run.intent.endpoint, self.authentication.missing_secret_namespace_set)
518
+ dialog.show_failure(failure_summary(run.diagnostic, account), explained=bool(failure_headline(run.diagnostic, account)))
519
+ self.lookup_refused_account(run.selection, run.diagnostic, lambda: self._show_clone_needs_reconnect(run, dialog))
520
+ notice = account_notice(run.diagnostic, account, CLONE_NOTICE_SUBJECT_TEXT)
521
+ if notice is not None:
522
+ self.account_controller.offer_account_after_refusal(None, notice)
523
+ # The form keeps the refusal as its red cause; the install window comes on top of it, and the user clicks Clone
524
+ # or Retry submodules again once the credential manager is installed.
525
+ if run.credential_manager_refusal is not None:
526
+ self.account_controller.offer_credential_manager_install(run.credential_manager_refusal, dialog)
527
+
528
+ def _finish_bare_clone(self, run: CloneRun, dialog: CloneDialog) -> None:
529
+ """End a successful bare clone without opening it: a bare repository has no working tree to show in a repository window.
530
+
531
+ The console says where the repository was written and stays on screen, the form closes without cancelling
532
+ anything, and the recent list is left as it was since nothing was opened.
533
+ """
534
+ run.trace.append_event(run.operation_id, "status", BARE_CLONE_DONE_TEXT + run.destination.destination_dir, ConsoleTone.SUCCESS)
535
+ welcome = self.session_registry_controller.welcome_by_session.get(run.intent.session_id)
536
+ if welcome is not None:
537
+ # A successful result closes the console by itself unless Keep dialog open is checked, and here the path it
538
+ # shows is the only answer the user gets, so it is shown again.
539
+ self.keep_clone_console(run.operation_id, welcome, show=True)
540
+ dialog.accept()
541
+
542
+ def _show_clone_needs_reconnect(self, run: CloneRun, dialog: CloneDialog) -> None:
543
+ """Say that the refused account needs a reconnect in the clone's console and in the clone form, while each still shows the failure of that same clone."""
544
+ output = self.clone_output_by_id.get(run.operation_id)
545
+ if output is not None and isValid(output) and not output.running:
546
+ failure_text, headline = self._clone_console_failure(run)
547
+ output.show_failure_text(failure_text, headline)
548
+ if (self.session_registry_controller.clone_dialog_by_session.get(run.intent.session_id) is not dialog or not isValid(dialog) or dialog.running or dialog.operation_id != run.operation_id
549
+ or dialog.status_label.tone is not ResultTone.FAILURE):
550
+ return
551
+ account = transfer_account(run.selection, run.intent.endpoint, self.authentication.missing_secret_namespace_set)
552
+ dialog.show_failure(failure_summary(run.diagnostic, account), explained=True)
553
+
554
+ def _show_partial_clone_needs_reconnect(self, run: CloneRun, dialog: CloneDialog) -> None:
555
+ """Say that the account refused for a submodule needs a reconnect, in the clone's console and in the clone form.
556
+
557
+ The form keeps its partial state (Retry submodules, Open repository) and is only rewritten while it still shows
558
+ that same partial clone.
559
+ """
560
+ output = self.clone_output_by_id.get(run.operation_id)
561
+ if output is not None and isValid(output) and not output.running:
562
+ failure_text, headline = self._clone_console_failure(run)
563
+ output.show_failure_text(failure_text, headline)
564
+ if (self.session_registry_controller.clone_dialog_by_session.get(run.intent.session_id) is not dialog or not isValid(dialog) or dialog.running or dialog.operation_id != run.operation_id
565
+ or not dialog.partial or dialog.status_label.tone is not ResultTone.FAILURE or run.diagnostic is None):
566
+ return
567
+ dialog.show_partial_submodules(failure_summary(run.diagnostic, self._submodule_transfer_account(run)), run.validation_status == OperationStatus.SUCCESS.value,
568
+ explained=bool(run.checkout_refusal_text))
569
+
570
+ def keep_clone_console(self, operation_id: str, owner: QWidget, *, show: bool) -> None:
571
+ """Move a clone's console under the window that stays on screen once the clone form closes.
572
+
573
+ The console is a child of the clone form, and the form goes away with its Open repository window: left there,
574
+ the console and the clone's output would disappear with them. It keeps its own window flags so it stays a
575
+ separate window. ``setParent`` hides a window, so a console that was on screen is shown again, and so is one the
576
+ caller asks to show.
577
+ """
578
+ output = self.clone_output_by_id.get(operation_id)
579
+ if output is None or not isValid(output):
580
+ return
581
+ visible = output.isVisible()
582
+ output.setParent(owner, output.windowFlags())
583
+ if not visible and not show:
584
+ return
585
+ output.show()
586
+ output.raise_()
587
+
588
+ def _open_clone_console(self, session_id: str, run: CloneRun, dialog: CloneDialog) -> None:
589
+ """Open the standard operation console live for a clone or its submodule retry, as Fetch and Push do.
590
+
591
+ A retry reuses the clone's console, even one the user already closed, since the retry writes to the same trace.
592
+ The clone service closes that trace itself once nothing can write to it anymore, so the console does not.
593
+ """
594
+ output = self.clone_output_by_id.get(run.operation_id)
595
+ if output is None or not isValid(output):
596
+ output = OperationDialog(run.trace, CLONE_CONSOLE_TITLE, dialog, preference=self.preference, console_operation=ConsoleOperation.CLONE)
597
+ self.clone_output_by_id[run.operation_id] = output
598
+ operation_id = run.operation_id
599
+ output.abort_requested.connect(lambda: self._abort_clone_console(session_id, operation_id))
600
+ output.set_running()
601
+ open_window_modal(output, keeps_window_buttons=True)
602
+
603
+ def _abort_clone_console(self, session_id: str, operation_id: str) -> None:
604
+ """Stop a clone from its console's Abort exactly as the form's Cancel clone does, or directly once no form shows it."""
605
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
606
+ if dialog is not None and dialog.operation_id == operation_id:
607
+ self.cancel_clone(session_id)
608
+ return
609
+ self.clone.cancel(operation_id)
610
+
611
+ def _clone_console_failure(self, run: CloneRun) -> tuple[str, str]:
612
+ """Return the explanation and the headline the clone's console shows in bold red, both empty when there is no failure to explain.
613
+
614
+ As for Fetch and Push, the headline names the address and the account used: the clone's own, or for a partial
615
+ clone the account used for the submodule that could not be fetched.
616
+ """
617
+ if run.cancelled or run.diagnostic is None or run.recursive_status == OperationStatus.SUCCESS.value:
618
+ return "", ""
619
+ if run.incomplete_repository:
620
+ account = self._submodule_transfer_account(run)
621
+ else:
622
+ account = transfer_account(run.selection, run.intent.endpoint, self.authentication.missing_secret_namespace_set)
623
+ return failure_summary(run.diagnostic), failure_headline(run.diagnostic, account)
624
+
625
+ def lookup_refused_account(self, selection: AccountSelection | None, diagnostic: PreparedDiagnostic | None, show_needs_reconnect: Callable[[], None]) -> None:
626
+ """After a transfer an account's authentication was refused for, check in the background whether that account's secret is gone.
627
+
628
+ Git and Git Credential Manager erase a refused secret by themselves. When the check finds it gone,
629
+ ``show_needs_reconnect`` rewrites the views still showing that failure; an open Settings window is marked either way.
630
+ Nothing is checked without an account or for another kind of failure.
631
+ """
632
+ if selection is None or diagnostic is None or not authentication_refused(diagnostic):
633
+ return
634
+
635
+ def answered(lookup: SecretLookup) -> None:
636
+ """Mark the account in Settings, and in the failure message once its secret is known to be gone."""
637
+ self.account_controller.show_account_status(lookup)
638
+ if selection.namespace in lookup.missing_namespace_set:
639
+ show_needs_reconnect()
640
+
641
+ self.authentication.lookup_secrets((selection,), answered)
642
+
643
+ def _submodule_account_selection(self, run: CloneRun) -> AccountSelection | None:
644
+ """Return the account used for the submodule a partial clone could not fetch, or None when no submodule asked for authentication or no account was used.
645
+
646
+ A retry uses the account the user chose for the submodule. A first attempt used the account Git was given for
647
+ that address: the parent's account for the same address, otherwise the service default. Raises ValueError when
648
+ that default account can no longer be read.
649
+ """
650
+ target = run.authentication_target
651
+ if target is None:
652
+ return None
653
+ if run.child_selection is not None:
654
+ return run.child_selection
655
+ if run.selection is not None and endpoint_origin(target.endpoint) == run.selection.origin:
656
+ return run.selection
657
+ return self.authentication.account_for_endpoint(target.endpoint)
658
+
659
+ def _submodule_transfer_account(self, run: CloneRun) -> TransferAccount | None:
660
+ """Name the address and account used for the submodule a partial clone could not fetch, or None when no submodule asked for authentication.
661
+
662
+ The account is marked as needing a reconnect once a check found its secret gone. When that account can no
663
+ longer be read, nothing is claimed rather than saying no account is connected.
664
+ """
665
+ target = run.authentication_target
666
+ if target is None:
667
+ return None
668
+ try:
669
+ selection = self._submodule_account_selection(run)
670
+ except ValueError:
671
+ return None
672
+ return transfer_account(selection, target.endpoint, self.authentication.missing_secret_namespace_set)
673
+
674
+ def _retry_clone_submodules(self, session_id: str) -> None:
675
+ """Resume the captured partial clone without asking for another URL or destination."""
676
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
677
+ if dialog is None or dialog.running or dialog.operation_id is None or session_id not in self.session_registry_controller.welcome_by_session:
678
+ return
679
+ run = self.clone.run_by_id[dialog.operation_id]
680
+ if self._clone_form_signing_in(dialog):
681
+ dialog.status_label.show_failure("Finish signing in successfully before retrying submodules.")
682
+ return
683
+ if run.authentication_target is not None:
684
+ namespace = dialog.account_choice()
685
+ child_selection = None
686
+ if namespace is not None:
687
+ child_selection = self._clone_listed_account(dialog, namespace)
688
+ if child_selection is None:
689
+ dialog.status_label.show_failure("Choose a confirmed account for this submodule before retrying.")
690
+ return
691
+ run.child_selection = child_selection
692
+ generation = self.session_registry_controller.clone_generation_by_session.get(session_id, 0) + 1
693
+ self.session_registry_controller.clone_generation_by_session[session_id] = generation
694
+ run.intent = replace(run.intent, generation=generation)
695
+ run.cancelled = False
696
+ dialog.set_running(True)
697
+ # The retry writes to the clone's own trace, so its output continues below the clone's in the same console.
698
+ self._open_clone_console(session_id, run, dialog)
699
+ self.clone.retry_submodules(run, self.repository.git_filename)
700
+
701
+ def _retry_clone_open(self, session_id: str) -> None:
702
+ """Capture a fresh explicit retry for the same completed clone without repeating Git clone."""
703
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
704
+ if dialog is None or dialog.running or dialog.operation_id is None or session_id not in self.session_registry_controller.welcome_by_session:
705
+ return
706
+ run = self.clone.run_by_id[dialog.operation_id]
707
+ generation = self.session_registry_controller.clone_generation_by_session.get(session_id, 0) + 1
708
+ self.session_registry_controller.clone_generation_by_session[session_id] = generation
709
+ run.intent = replace(run.intent, generation=generation)
710
+ run.cancelled = False
711
+ run.validation_status = "not_started"
712
+ self._open_clone(session_id)
713
+
714
+ def _open_clone(self, session_id: str) -> None:
715
+ """Pass a completed owned destination through the existing repository reader.
716
+
717
+ A bare clone is never opened: it has no working tree to show in a repository window.
718
+ """
719
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
720
+ if dialog is None or dialog.operation_id is None:
721
+ return
722
+ run = self.clone.run_by_id[dialog.operation_id]
723
+ if run.intent.options.bare or (run.clone_status != OperationStatus.SUCCESS.value and not run.incomplete_repository) or run.cancelled or not self.operation_policy_controller.clone_is_current(run.intent):
724
+ return
725
+ try:
726
+ run.destination.verify()
727
+ except (OSError, ValueError) as error:
728
+ diagnostic = Diagnostic("Open repository", run.destination.destination_dir, self.repository.git_filename, [], str(error)).prepare()
729
+ self.clone.open_result(run.operation_id, False, diagnostic)
730
+ dialog.show_failure(failure_summary(diagnostic), partial=True)
731
+ return
732
+ if run.validation_status != OperationStatus.SUCCESS.value:
733
+ dialog.set_running(True)
734
+
735
+ def validated() -> None:
736
+ """Retry readiness checks without repeating the native clone command."""
737
+ if run.cancelled and self.session_registry_controller.clone_dialog_by_session.get(session_id) is dialog:
738
+ dialog.show_cancelled(OPENING_CANCELLED_TEXT, partial=True)
739
+ return
740
+ if not self.operation_policy_controller.clone_is_current(run.intent) or run.cancelled:
741
+ return
742
+ if run.validation_status == OperationStatus.SUCCESS.value:
743
+ self._open_clone(session_id)
744
+ else:
745
+ dialog.show_failure(failure_summary(run.diagnostic), partial=True)
746
+
747
+ self.clone.validate_repository(run, self.repository.git_filename, validated)
748
+ return
749
+ run.open_status = "running"
750
+ dialog.set_running(True)
751
+ dialog.status_label.setText("Repository cloned. Opening repository…" if run.recursive_status == OperationStatus.SUCCESS.value else "Opening preserved repository; some submodules are still incomplete…")
752
+ self.open_requested.emit(session_id, run.destination.destination_dir)
753
+
754
+ def cancel_clone(self, session_id: str) -> None:
755
+ """Invalidate the form immediately and stop its native process without deleting files."""
756
+ self._cancel_clone_authentication(session_id)
757
+ self.session_registry_controller.clone_generation_by_session[session_id] = self.session_registry_controller.clone_generation_by_session.get(session_id, 0) + 1
758
+ dialog = self.session_registry_controller.clone_dialog_by_session.get(session_id)
759
+ if dialog is not None and dialog.operation_id is not None:
760
+ run = self.clone.run_by_id[dialog.operation_id]
761
+ # Read before cancelling: a clone still waiting in the queue finishes inside cancel() and shows its own
762
+ # cancellation text, which the opening-cancelled text must not replace.
763
+ clone_was_running = run.has_running_step()
764
+ self.clone.cancel(dialog.operation_id)
765
+ if not clone_was_running:
766
+ dialog.show_cancelled(OPENING_CANCELLED_TEXT, partial=run.clone_status == OperationStatus.SUCCESS.value)
767
+ if session_id in self.session_registry_controller.welcome_by_session and session_id in self.session_registry_controller.identity_by_session:
768
+ self.open_cancel_requested.emit(session_id)
769
+
770
+ def preference_changed_for_clone_forms(self, changed_value_by_key: dict[str, object]) -> None:
771
+ """Show again, in every open clone form, the account choice a change of the shared preferences touched."""
772
+ # A sign-in, a block save or another window may have changed the accounts; every clone form shows them without
773
+ # losing what the user chose.
774
+ if AUTHENTICATION_PREFERENCE_KEY in changed_value_by_key:
775
+ for session_id, clone_dialog in tuple(self.session_registry_controller.clone_dialog_by_session.items()):
776
+ self.refresh_clone_account(session_id, clone_dialog.account_choice())