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,677 @@
1
+ """Manage the accounts GitPane uses to sign in to remote Git servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shiboken6 import isValid
8
+
9
+ from gitpane.account.account_choice import check_url_by_origin
10
+ from gitpane.account.authentication import EMPTY_CHECK_URL_TEXT, GENERIC_PROVIDER, store_was_refused, transfer_account
11
+ from gitpane.account.credential_manager import INSTALL_WINDOW_PROBLEM_SET, CredentialManagerProblem, credential_manager_check_again_text, credential_manager_setting_text, ignore_line
12
+ from gitpane.account.keyring_state import UNKNOWN_KEYRING_READING, keyring_stops_sign_in
13
+ from gitpane.config import AUTHENTICATION_PREFERENCE_KEY
14
+ from gitpane.history.file_history import WindowCapability
15
+ from gitpane.operation.diagnostic import Diagnostic
16
+ from gitpane.operation.operation_status import OperationStatus
17
+ from gitpane.tool.git_executable import terminal_git_filename
18
+ from gitpane.transport.refusal_notice import account_notice_text
19
+ from gitpane.ui.setting.account_page import removal_failure_text, removal_result_text
20
+ from gitpane.ui.setting.add_account_dialog import EMPTY_BITBUCKET_EMAIL_TEXT, EMPTY_PASSWORD_TEXT, EMPTY_SERVER_TEXT, EMPTY_SERVICE_NAME_TEXT, EMPTY_TOKEN_TEXT, EMPTY_USERNAME_TEXT, SELF_HOSTED_BROWSER_TEXT, AddAccountDialog, sign_in_result_text
21
+ from gitpane.ui.setting.credential_manager_dialog import CredentialManagerDialog
22
+ from gitpane.ui.setting.ssh_widget import SshPromptDialog
23
+ from gitpane.ui.widget.result_label import ResultTone
24
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ from PySide6.QtWidgets import QWidget
30
+
31
+ from gitpane.account.authentication import AccountSelection, AuthenticationService, AuthOutcome, SecretLookup
32
+ from gitpane.account.credential_manager import CredentialManagerDetection
33
+ from gitpane.account.keyring_state import KeyringReading
34
+ from gitpane.account.ssh_prompt import SshQuestion
35
+ from gitpane.controller.operation_policy import OperationPolicy
36
+ from gitpane.controller.session_registry import SessionRegistry
37
+ from gitpane.controller.setting_controller import SettingController
38
+ from gitpane.history.repository import GitRepository
39
+ from gitpane.history.repository_value import RepositoryContext
40
+ from gitpane.operation.diagnostic import DiagnosticLog
41
+ from gitpane.transport.refusal_notice import AccountNotice
42
+ from gitpane.transport.remote import RemoteService
43
+ from gitpane.transport.remote_config import RemoteSnapshot
44
+ from gitpane.ui.setting.account_page import AccountPage
45
+ from gitpane.ui.setting.setting_dialog import SettingDialog
46
+ from gitpane.worktree.worktree_service import WorktreeService
47
+
48
+ # The --log lines of the keyring reads made for a sign-in: the state's value and the name of the program that holds the
49
+ # Secret Service, never a collection name or a secret.
50
+ KEYRING_STATE_LOG_TEMPLATE = "Keyring before sign-in: {state}, held by {program}"
51
+ KEYRING_STATE_AFTER_REFUSAL_LOG_TEMPLATE = "Keyring after a refused store: {state}, held by {program}"
52
+ UNREADABLE_PROGRAM_LOG_TEXT = "an unreadable program"
53
+
54
+ class AccountController:
55
+ """The accounts page of Settings, its sign-in windows, the SSH questions of running operations and the window explaining how to install the credential manager."""
56
+
57
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, setting_controller: SettingController, repository: GitRepository, worktree_service: WorktreeService, remote: RemoteService,
58
+ authentication: AuthenticationService, diagnostic_log: DiagnosticLog | None, read_keyring: Callable[[], KeyringReading]) -> None:
59
+ """Receive the services and the lower controllers the accounts go through; the wiring builds it after the Settings controller, whose opening and preference changes reach it through the wiring.
60
+
61
+ ``read_keyring`` reads, without changing anything, the state of the keyring the credential manager stores
62
+ secrets in and the program that holds it; it may wait on the session bus, so it is only called on a worker thread.
63
+ """
64
+ self.session_registry_controller = session_registry_controller
65
+ self.operation_policy_controller = operation_policy_controller
66
+ self.setting_controller = setting_controller
67
+ self.repository = repository
68
+ self.worktree_service = worktree_service
69
+ self.remote = remote
70
+ self.authentication = authentication
71
+ self.diagnostic_log = diagnostic_log
72
+ self.read_keyring = read_keyring
73
+ # The only window explaining how to install the credential manager; a refusal while it is open adds only its red line.
74
+ self.credential_manager_dialog: CredentialManagerDialog | None = None
75
+ # The sign-in form whose refusal the open install window explains, kept while that window is open: once Check
76
+ # again finds a usable credential manager, the form no longer says signing in is impossible.
77
+ self.credential_manager_refusal_form: AddAccountDialog | None = None
78
+ self.ssh_dialog_by_id: dict[str, SshPromptDialog] = {}
79
+
80
+ def bind_setting_account_page(self, dialog: SettingDialog, account_page: AccountPage, session_id: str | None, context: RepositoryContext | None) -> None:
81
+ """Connect the accounts page of a Settings window being built, then fill it: accounts, their stored secrets, the remotes and the credential manager.
82
+
83
+ ``session_id`` is the window that asked for Settings, None from the welcome window; ``context`` is the repository
84
+ Settings opened for, None without one, and then no remote is read.
85
+ """
86
+ account_page.add_account_requested.connect(lambda: self.show_add_account(session_id))
87
+ account_page.reconnect_requested.connect(lambda: self.show_reconnect(session_id))
88
+ account_page.remove_account_requested.connect(lambda namespace: self.remove_account(session_id, namespace))
89
+ account_page.save_global_requested.connect(self.save_global_accounts)
90
+ account_page.save_repository_requested.connect(self.save_repository_accounts)
91
+ if self.refresh_account_page():
92
+ self.lookup_account_secrets(dialog)
93
+ if context is not None:
94
+ self.read_account_remotes(dialog)
95
+ self.check_credential_manager_for_setting(dialog)
96
+
97
+ def offer_account_after_refusal(self, session_id: str | None, notice: AccountNotice) -> None:
98
+ """Open Settings on Accounts with an orange notice naming what Git refused, so the user can add or reconnect the account.
99
+
100
+ The failed operation is never started again: the user runs it again once the account works. An already open
101
+ Settings window is brought back as it is, keeping every unsaved draft; a window that may not manage settings
102
+ opens nothing.
103
+ """
104
+ dialog = self.setting_controller.show_setting(session_id, section="accounts")
105
+ if dialog is None:
106
+ return
107
+ dialog.account_page.show_account_notice(account_notice_text(notice), notice.address)
108
+
109
+ def offer_credential_manager_install(self, detection: CredentialManagerDetection, parent: QWidget | None) -> bool:
110
+ """Open the window explaining how to install the credential manager, for a refusal that stopped an operation the user asked for.
111
+
112
+ Returns True when an install window is open after the call, whether it was just opened or already open; a second
113
+ refusal never opens a second window. An open install window is offered again only while it can take clicks: once
114
+ a modal window it does not belong to opened after it, such as Settings, Qt blocks it, so it is closed and a new
115
+ one opens over ``parent``. A console blocks it the same way, so it is replaced too, when the console is modal for
116
+ the whole application, as on macOS, or modal for the install window's own repository window; only a console over
117
+ another repository window on Windows and Linux leaves it taking clicks, so it stays. Does nothing for an
118
+ unsupported system, which has nothing to install, nor while GitPane is closing. No operation is started again:
119
+ the user runs it again once the credential manager is installed.
120
+ """
121
+ if detection.problem not in INSTALL_WINDOW_PROBLEM_SET:
122
+ return False
123
+ if self.session_registry_controller.shutdown_requested:
124
+ return False
125
+ existing = self.credential_manager_dialog
126
+ if existing is not None and isValid(existing):
127
+ if not existing.blocked_by_modal:
128
+ return True
129
+ # Its finished signal forgets it, and only if it is still the window kept here, so the new one stays kept.
130
+ existing.close()
131
+ dialog = CredentialManagerDialog(self.authentication.credential_manager.install_lines(detection), detection, parent)
132
+ self.credential_manager_dialog = dialog
133
+ dialog.finished.connect(lambda _result, dialog=dialog: self._forget_credential_manager_dialog(dialog))
134
+ dialog.check_again_requested.connect(lambda dialog=dialog: self._check_credential_manager_again(dialog))
135
+ dialog.show()
136
+ return True
137
+
138
+ def _check_credential_manager_again(self, dialog: CredentialManagerDialog) -> None:
139
+ """Search again for the credential manager of the Git the operations will use, once the user says it is installed.
140
+
141
+ The window closes when a usable credential manager is found, and the sign-in form whose refusal it explained, if
142
+ still open, takes that refusal and its install lines away; otherwise the window says what changed and its install
143
+ text becomes the one of the refusal found, and the form keeps its refusal. No operation is started again: the
144
+ user runs the refused one again. The search runs off the window's thread.
145
+ """
146
+ dialog.set_checking(True)
147
+
148
+ def found(detection: CredentialManagerDetection | None, error: Exception | None) -> None:
149
+ """Report the answer in the install window, only while it is still the open one."""
150
+ # An open Settings window shows what this search found too, even once the install window is gone.
151
+ self._show_credential_manager_setting(detection, error)
152
+ if self.credential_manager_dialog is not dialog or not isValid(dialog):
153
+ return
154
+ dialog.set_checking(False)
155
+ if error is not None:
156
+ dialog.show_check_result(str(error))
157
+ return
158
+ if detection.problem == CredentialManagerProblem.NONE:
159
+ form = self.credential_manager_refusal_form
160
+ if form is not None and isValid(form) and not form.closed:
161
+ form.clear_credential_manager_refusal()
162
+ dialog.accept()
163
+ return
164
+ platform = self.authentication.credential_manager.platform
165
+ text = credential_manager_check_again_text(dialog.detection, detection, platform)
166
+ # The text is rewritten for every refusal found, so the short answer of a later Check again always refers
167
+ # to what the window shows.
168
+ dialog.show_detection(self.authentication.credential_manager.install_lines(detection), detection)
169
+ dialog.show_check_result(text)
170
+
171
+ self.authentication.locate_credential_manager(terminal_git_filename(self.repository.git_filename), found)
172
+
173
+ def check_credential_manager_for_setting(self, dialog: SettingDialog) -> None:
174
+ """Search in the background for the credential manager shown in the Accounts page of a Settings window that just opened.
175
+
176
+ A success already kept for this Git is shown at once; the search started next confirms it without starting
177
+ --version again. The answer only reaches the Settings window that asked while it is still the open one, and
178
+ never opens the install window.
179
+ """
180
+ git_filename = terminal_git_filename(self.repository.git_filename)
181
+ # Workaround: the kept success is read without the search's lock, because a running search may hold that lock
182
+ # up to the probe time limit of each candidate and the window's thread must never wait for it. Reading one dict
183
+ # entry is atomic, and the search started just below confirms or replaces what is shown.
184
+ kept = self.authentication.credential_manager.detection_by_git_filename.get(git_filename)
185
+ if kept is not None:
186
+ dialog.account_page.show_credential_manager(credential_manager_setting_text(kept, self.authentication.credential_manager.platform), False)
187
+ else:
188
+ dialog.account_page.show_credential_manager_checking()
189
+
190
+ def found(detection: CredentialManagerDetection | None, error: Exception | None) -> None:
191
+ """Show the answer only while the Settings window that asked is still the open one."""
192
+ if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
193
+ return
194
+ self._show_credential_manager_setting(detection, error)
195
+
196
+ self.authentication.locate_credential_manager(git_filename, found)
197
+
198
+ def _show_credential_manager_setting(self, detection: CredentialManagerDetection | None, error: Exception | None) -> None:
199
+ """Write in the credential manager line of the open Settings window what a search found; does nothing while Settings is closed."""
200
+ dialog = self.session_registry_controller.setting_dialog
201
+ if dialog is None or not isValid(dialog):
202
+ return
203
+ if error is not None:
204
+ dialog.account_page.show_credential_manager(str(error), True)
205
+ return
206
+ text = credential_manager_setting_text(detection, self.authentication.credential_manager.platform)
207
+ dialog.account_page.show_credential_manager(text, detection.problem != CredentialManagerProblem.NONE)
208
+
209
+ def _forget_credential_manager_dialog(self, dialog: CredentialManagerDialog) -> None:
210
+ """Forget the install window once it closes, with the sign-in form it explained, unless a newer one already replaced it, so the next refusal can open one again."""
211
+ if self.credential_manager_dialog is dialog:
212
+ self.credential_manager_dialog = None
213
+ self.credential_manager_refusal_form = None
214
+
215
+ def show_ssh_question(self, question: SshQuestion) -> None:
216
+ """Open a private credential question owned by the captured running operation."""
217
+ dialog = SshPromptDialog(question)
218
+ self.ssh_dialog_by_id[question.operation_id] = dialog
219
+ dialog.finished.connect(lambda: self.ssh_dialog_by_id.pop(question.operation_id, None))
220
+ open_window_modal(dialog)
221
+
222
+ def close_ssh_question(self, operation_id: str) -> None:
223
+ """Discard a question when its socket closes or its operation is cancelled."""
224
+ dialog = self.ssh_dialog_by_id.pop(operation_id, None)
225
+ if dialog is not None and isValid(dialog):
226
+ dialog.reject()
227
+ dialog.deleteLater()
228
+
229
+ def show_add_account(self, session_id: str | None) -> None:
230
+ """Open the sign-in window that adds a new account to Connected accounts."""
231
+ self._open_account_sign_in(session_id, None)
232
+
233
+ def show_reconnect(self, session_id: str | None) -> None:
234
+ """Open the sign-in window that renews the account selected in Connected accounts."""
235
+ dialog = self.session_registry_controller.setting_dialog
236
+ if dialog is None:
237
+ return
238
+ namespace = dialog.account_page.selected_namespace()
239
+ if namespace is None:
240
+ return
241
+ catalogue = self.authentication.catalogue()
242
+ for selection in catalogue.accounts:
243
+ if selection.namespace == namespace:
244
+ self._open_account_sign_in(session_id, selection, catalogue.service_name_by_origin.get(selection.origin, ""))
245
+ return
246
+
247
+ def _open_account_sign_in(self, session_id: str | None, reconnect: AccountSelection | None, service_name: str = "") -> None:
248
+ """Show one sign-in window at a time for Settings; the window that asked must be allowed to manage settings.
249
+
250
+ ``service_name`` is the name of the renewed account's address, shown in the Reconnect window. The remotes already
251
+ read for This repository give the window a repository URL per address, so a username + password on the address
252
+ of one of them is tested there without asking; remotes read later reach the open window as they arrive.
253
+ """
254
+ dialog = self.session_registry_controller.setting_dialog
255
+ if dialog is None or session_id is not None and not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
256
+ return
257
+ page = dialog.account_page
258
+ if page.sign_in_dialog is not None and isValid(page.sign_in_dialog):
259
+ page.sign_in_dialog.showNormal()
260
+ page.sign_in_dialog.raise_()
261
+ page.sign_in_dialog.activateWindow()
262
+ return
263
+ add_dialog = AddAccountDialog(dialog, reconnect=reconnect, service_name=service_name, service_name_by_origin=self.service_name_by_origin(),
264
+ known_check_url_by_origin=check_url_by_origin(page.remote_entries or ()), browser_timeout_ms=self.authentication.browser_timeout_ms)
265
+ page.sign_in_dialog = add_dialog
266
+
267
+ def owner_is_current() -> bool:
268
+ """Publish only while the Settings window that opened the sign-in is still the open one."""
269
+ return self.session_registry_controller.setting_dialog is dialog
270
+
271
+ def on_success(outcome: AuthOutcome) -> None:
272
+ """Show the refreshed list and the result in the Settings window that asked."""
273
+ self._account_signed_in(dialog, outcome, reconnect is not None)
274
+
275
+ def finished() -> None:
276
+ """Forget the closed window so the next Add account or Reconnect opens a fresh one.
277
+
278
+ Nothing is raised here: a browser sign-in ends while the browser is in front, and raising a window asks macOS
279
+ to bring GitPane forward. Settings stays above the other GitPane windows because it is modal.
280
+ """
281
+ if isValid(page) and page.sign_in_dialog is add_dialog:
282
+ page.sign_in_dialog = None
283
+ add_dialog.deleteLater()
284
+
285
+ add_dialog.sign_in_requested.connect(lambda token_mode: self.start_account_sign_in(add_dialog, token_mode, owner_is_current, on_success))
286
+ add_dialog.cancel_requested.connect(lambda: self.cancel_account_sign_in(add_dialog))
287
+ add_dialog.finished.connect(finished)
288
+ # Modal over Settings: a form left non-modal would take the level of the modal Settings on macOS and stay above
289
+ # the browser while GitPane waits for a browser sign-in.
290
+ open_window_modal(add_dialog)
291
+
292
+ def start_account_sign_in(self, add_dialog: AddAccountDialog, token_mode: bool, owner_is_current: Callable[[], bool], on_success: Callable[[AuthOutcome], None]) -> None:
293
+ """Capture the window's authority and generation before the private sign-in exchange starts.
294
+
295
+ ``token_mode`` is true when a secret was typed, false for browser sign-in. On GitHub, gitlab.com and Bitbucket
296
+ Cloud a typed secret is a personal access token whose identity the service's API checks; on any other server it
297
+ is the password or token of a generic account, saved only once Git lists the repository typed in the window with
298
+ them; a failed test is also written to the --log file, without any secret. Which browser sign-in
299
+ fits the address is only the window's answer. Reconnect renews exactly the selected account. Any window that
300
+ owns an Add account or Reconnect window can use this: ``owner_is_current`` tells whether the window that opened
301
+ it still accepts results, and ``on_success`` shows the result there. No repository is reserved, because the
302
+ account list belongs to no repository.
303
+
304
+ Before any exchange, the keyring is read off the window's thread: when the GNOME keyring's default collection is
305
+ not published on the session bus, the credential manager could not store the account, so nothing is sent and the
306
+ window says why. The typed secret is kept only until that read answers.
307
+ """
308
+ if add_dialog.operation_id:
309
+ return
310
+ reconnect = add_dialog.reconnect
311
+ refusal = self._account_form_refusal(add_dialog, token_mode)
312
+ if refusal:
313
+ add_dialog.clear_password()
314
+ add_dialog.show_refusal(refusal)
315
+ return
316
+ # The password or token is the one value taken exactly as typed, blanks included: a space may be part of a secret.
317
+ secret = add_dialog.password_edit.text() if token_mode else ""
318
+ check_url = add_dialog.check_url()
319
+ api_email = add_dialog.bitbucket_email() if add_dialog.asks_bitbucket_email() else ""
320
+ username = add_dialog.username()
321
+ service_name = add_dialog.service_name()
322
+ server_url = add_dialog.server_url()
323
+ cloud_provider = add_dialog.cloud_provider()
324
+ browser_provider = add_dialog.browser_provider()
325
+ generation = add_dialog.generation
326
+ add_dialog.refused_outcome = None
327
+ add_dialog.set_running(True, token=token_mode)
328
+
329
+ def is_current() -> bool:
330
+ """Authorize nonsecret publication only while the captured sign-in window still owns it."""
331
+ return owner_is_current() and isValid(add_dialog) and not add_dialog.closed and add_dialog.generation == generation
332
+
333
+ def complete(outcome: AuthOutcome) -> None:
334
+ """Close the window after a full success; otherwise keep it open with the failed step and its cause.
335
+
336
+ When the secure store refused to save, the keyring is read again first: a collection still locked, not
337
+ published, or no default collection at all explains the refusal and is said with it. The refusal is kept on
338
+ the window during that read, so Cancel there shows it rather than a cancelled try.
339
+ """
340
+ if not is_current():
341
+ return
342
+ add_dialog.operation_id = ""
343
+ if outcome.status == OperationStatus.SUCCESS and outcome.selection is not None:
344
+ add_dialog.set_running(False)
345
+ on_success(outcome)
346
+ add_dialog.accept()
347
+ return
348
+ if not store_was_refused(outcome):
349
+ self._report_sign_in_failure(add_dialog, outcome, UNKNOWN_KEYRING_READING)
350
+ return
351
+
352
+ def keyring_read_after_refusal(reading: KeyringReading) -> None:
353
+ """Show the refused store with what the keyring read again says, unless the window no longer accepts results."""
354
+ if not is_current():
355
+ return
356
+ add_dialog.refused_outcome = None
357
+ self._log_keyring_reading(KEYRING_STATE_AFTER_REFUSAL_LOG_TEMPLATE, reading)
358
+ self._report_sign_in_failure(add_dialog, outcome, reading)
359
+
360
+ add_dialog.refused_outcome = outcome
361
+ add_dialog.read_keyring_state(self.read_keyring, keyring_read_after_refusal, log_line=self._log_line())
362
+
363
+ def start_exchange() -> None:
364
+ """Start the exchange the window asked for: renew the selected account, or sign in with a token, a password or the browser."""
365
+ if reconnect is not None:
366
+ add_dialog.operation_id = self.authentication.approve(reconnect, secret, complete, api_email=api_email, is_current=is_current, check_url=check_url,
367
+ git_filename=self.repository.git_filename)
368
+ return
369
+ if token_mode and cloud_provider is not None:
370
+ add_dialog.operation_id = self.authentication.sign_in(cloud_provider, server_url, complete, token=secret, api_email=api_email, service_name=service_name,
371
+ git_filename=self.repository.git_filename, is_current=is_current)
372
+ return
373
+ if token_mode:
374
+ add_dialog.operation_id = self.authentication.sign_in(GENERIC_PROVIDER, server_url, complete, token=secret, username=username, service_name=service_name,
375
+ git_filename=self.repository.git_filename, is_current=is_current, check_url=check_url)
376
+ return
377
+ add_dialog.operation_id = self.authentication.sign_in(browser_provider, server_url, complete, service_name=service_name, git_filename=self.repository.git_filename,
378
+ is_current=is_current)
379
+
380
+ def keyring_answered(reading: KeyringReading) -> None:
381
+ """Stop before anything is sent when the keyring cannot store the account; any other reading lets the exchange start as before.
382
+
383
+ Only the answer already seen with the GNOME keyring stops it: its default collection not published, in the
384
+ very first session of a new system or user. The same answer from KDE Wallet, KeePassXC, any other program or
385
+ a program that could not be read lets the exchange go, so the credential manager's own output says what
386
+ happened. A locked collection does not stop it: the keyring asks for its password when the credential
387
+ manager stores the account. A keyring with no default collection does not stop it either: the credential
388
+ manager's library asks the desktop to create one when it stores the account, and the user decides. A state
389
+ that could not be read says nothing and stops nothing.
390
+ """
391
+ if not is_current():
392
+ return
393
+ self._log_keyring_reading(KEYRING_STATE_LOG_TEMPLATE, reading)
394
+ if keyring_stops_sign_in(reading):
395
+ add_dialog.show_keyring_refusal(reading)
396
+ return
397
+ start_exchange()
398
+
399
+ add_dialog.read_keyring_state(self.read_keyring, keyring_answered, log_line=self._log_line())
400
+
401
+ def _account_form_refusal(self, add_dialog: AddAccountDialog, token_mode: bool) -> str:
402
+ """Say what the sign-in form still misses before anything starts, or return an empty text when it can start.
403
+
404
+ Fields are checked in the order the form shows them. A new account needs a service name, whatever the way it
405
+ signs in. A Reconnect only needs the new secret: its server, service name and username are fixed, and a
406
+ Bitbucket account already has its e-mail saved. A username + password account, new or reconnected, also needs a
407
+ repository of its server to be tested on.
408
+ """
409
+ adding = add_dialog.reconnect is None
410
+ if adding and not add_dialog.server_url():
411
+ return EMPTY_SERVER_TEXT
412
+ if adding and not add_dialog.service_name():
413
+ return EMPTY_SERVICE_NAME_TEXT
414
+ if not token_mode:
415
+ if adding and add_dialog.browser_provider() is None:
416
+ return SELF_HOSTED_BROWSER_TEXT
417
+ return ""
418
+ if adding and add_dialog.asks_username() and not add_dialog.username():
419
+ return EMPTY_USERNAME_TEXT
420
+ if add_dialog.asks_check_url() and not add_dialog.check_url():
421
+ return EMPTY_CHECK_URL_TEXT.format(address=add_dialog.address())
422
+ # A secret made only of spaces is still a secret, so it is checked exactly as typed.
423
+ if not add_dialog.password_edit.text():
424
+ return EMPTY_TOKEN_TEXT if add_dialog.asks_token() else EMPTY_PASSWORD_TEXT
425
+ if adding and add_dialog.asks_bitbucket_email() and not add_dialog.bitbucket_email():
426
+ return EMPTY_BITBUCKET_EMAIL_TEXT
427
+ return ""
428
+
429
+ def service_name_by_origin(self) -> dict[str, str]:
430
+ """Read the names already given to service addresses, for the sign-in window to propose; an unreadable list proposes none."""
431
+ try:
432
+ return self.authentication.catalogue().service_name_by_origin
433
+ except (OSError, ValueError, TypeError, KeyError):
434
+ return {}
435
+
436
+ def _report_sign_in_failure(self, add_dialog: AddAccountDialog, outcome: AuthOutcome, keyring_reading: KeyringReading) -> None:
437
+ """Keep the sign-in window open with the failed step, the keyring sentence when the keyring explains it, and the cause.
438
+
439
+ A failed Git test is also written to the --log journal, without any secret. A refused credential manager adds
440
+ its install lines under the refusal and offers the window that explains how to install it.
441
+ """
442
+ if outcome.check is not None and outcome.diagnostic is not None and self.diagnostic_log is not None:
443
+ self.diagnostic_log.write(outcome.diagnostic)
444
+ add_dialog.show_failure(outcome, keyring_reading=keyring_reading)
445
+ # The form keeps the refusal sentence and the install lines; closing the install window brings the user back
446
+ # to it, to click the sign-in button again once the credential manager is installed. Only Check again that
447
+ # finds a usable one takes them away.
448
+ refusal = outcome.credential_manager_refusal
449
+ if refusal is not None:
450
+ if refusal.problem in INSTALL_WINDOW_PROBLEM_SET:
451
+ add_dialog.show_install_text(self.authentication.credential_manager.install_lines(refusal))
452
+ if self.offer_credential_manager_install(refusal, add_dialog):
453
+ self.credential_manager_refusal_form = add_dialog
454
+
455
+ def cancel_account_sign_in(self, add_dialog: AddAccountDialog) -> None:
456
+ """Stop the exact running exchange and make sure its late result is ignored.
457
+
458
+ Once the exchange has ended with a refused store, only the keyring read that explains it is still running: the
459
+ refusal is already known, so Cancel shows it, with its output and without any keyring sentence, rather than
460
+ saying the sign-in was cancelled; the keyring's late answer then changes nothing.
461
+ """
462
+ if not isValid(add_dialog):
463
+ return
464
+ refused_outcome = add_dialog.refused_outcome
465
+ if refused_outcome is not None and not add_dialog.operation_id:
466
+ add_dialog.refused_outcome = None
467
+ add_dialog.generation += 1
468
+ add_dialog.forget_keyring_answer()
469
+ if add_dialog.closed:
470
+ add_dialog.set_running(False)
471
+ return
472
+ self._report_sign_in_failure(add_dialog, refused_outcome, UNKNOWN_KEYRING_READING)
473
+ return
474
+ add_dialog.refused_outcome = None
475
+ if add_dialog.operation_id:
476
+ self.authentication.cancel(add_dialog.operation_id)
477
+ add_dialog.operation_id = ""
478
+ add_dialog.generation += 1
479
+ add_dialog.forget_keyring_answer()
480
+ add_dialog.set_running(False)
481
+ if not add_dialog.closed:
482
+ add_dialog.show_cancelled()
483
+
484
+ def _log_line(self) -> Callable[[str], None]:
485
+ """Return what writes one line to the --log journal from any thread, or what writes nothing without a journal."""
486
+ if self.diagnostic_log is None:
487
+ return ignore_line
488
+ return self.diagnostic_log.write_line
489
+
490
+ def _log_keyring_reading(self, template: str, reading: KeyringReading) -> None:
491
+ """Write a keyring reading made for a sign-in to the --log journal: the state's value and the program's name, never a collection name or a secret."""
492
+ program = reading.program or UNREADABLE_PROGRAM_LOG_TEXT
493
+ self._log_line()(template.format(state=reading.state.value, program=program))
494
+
495
+ def _account_signed_in(self, dialog: SettingDialog, outcome: AuthOutcome, reconnect: bool) -> None:
496
+ """Reread the account list into Settings and say what happened to the account, and that it is already saved.
497
+
498
+ A refusal notice for the same address is cleared, since the account it asked for is now connected; the refused
499
+ operation is not started again, the user runs it again.
500
+ """
501
+ if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
502
+ return
503
+ account = transfer_account(outcome.selection)
504
+ if account is not None:
505
+ dialog.account_page.clear_account_notice(account.address)
506
+ if not self.refresh_account_page():
507
+ return
508
+ dialog.account_page.show_message(sign_in_result_text(outcome, reconnect=reconnect, saved_note=True), ResultTone.SUCCESS)
509
+
510
+ def remove_account(self, session_id: str | None, namespace: str) -> None:
511
+ """Remove the confirmed account: its stored secret is erased, then it leaves Connected accounts; no repository is touched.
512
+
513
+ The window that asked must still be allowed to manage settings. The result is shown only in the Settings window
514
+ that asked; if it closed meanwhile, the service still finishes a removal whose secret was erased.
515
+ """
516
+ dialog = self.session_registry_controller.setting_dialog
517
+ if dialog is None or not isValid(dialog) or session_id is not None and not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
518
+ return
519
+ page = dialog.account_page
520
+ try:
521
+ catalogue = self.authentication.catalogue()
522
+ except (OSError, ValueError, TypeError) as error:
523
+ page.show_message(Diagnostic("Read accounts", "", "", [], str(error)).prepare().cause, ResultTone.FAILURE)
524
+ return
525
+ selection = None
526
+ for account in catalogue.accounts:
527
+ if account.namespace == namespace:
528
+ selection = account
529
+ if selection is None:
530
+ self.refresh_account_page()
531
+ return
532
+ page.set_removing(True, selection.label)
533
+
534
+ def is_current() -> bool:
535
+ """Accept the result only while the Settings window that asked is still the open one."""
536
+ return self.session_registry_controller.setting_dialog is dialog
537
+
538
+ def complete(outcome: AuthOutcome) -> None:
539
+ """Show the refreshed list and say what happened: removed and what to choose again, or which step failed."""
540
+ if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
541
+ return
542
+ page.set_removing(False)
543
+ self.refresh_account_page()
544
+ if outcome.status == OperationStatus.SUCCESS and outcome.removal is not None:
545
+ page.show_message(removal_result_text(selection, outcome.removal), ResultTone.SUCCESS)
546
+ return
547
+ page.show_message(removal_failure_text(selection, outcome), ResultTone.FAILURE)
548
+ if outcome.diagnostic is not None and self.diagnostic_log is not None:
549
+ self.diagnostic_log.write(outcome.diagnostic)
550
+ # The account stays listed and the page says it was not removed; the install window comes on top of Settings.
551
+ if outcome.credential_manager_refusal is not None:
552
+ self.offer_credential_manager_install(outcome.credential_manager_refusal, dialog)
553
+
554
+ self.authentication.remove_account(selection, complete, is_current=is_current)
555
+
556
+ def refresh_account_page(self) -> bool:
557
+ """Reread the accounts into the open Settings window; the page keeps every row the user changed.
558
+
559
+ Returns False when the read failed, after showing why on the page, or when no Settings window is open.
560
+ """
561
+ dialog = self.session_registry_controller.setting_dialog
562
+ if dialog is None or not isValid(dialog):
563
+ return False
564
+ page = dialog.account_page
565
+ try:
566
+ page.show_catalogue(self.authentication.catalogue(dialog.git_setting_widget.context))
567
+ except (OSError, ValueError, TypeError) as error:
568
+ page.show_message(Diagnostic("Read accounts", "", "", [], str(error)).prepare().cause, ResultTone.FAILURE)
569
+ return False
570
+ return True
571
+
572
+ def lookup_account_secrets(self, dialog: SettingDialog) -> None:
573
+ """Check, in the background, which listed accounts lost their stored secret, and mark them in this Settings window when it answers.
574
+
575
+ The check never prompts, never contacts a server and writes nothing; it does not delay the opening of Settings,
576
+ and its answer only reaches the window that asked for it while it is still the open one.
577
+ """
578
+ try:
579
+ accounts = self.authentication.catalogue().accounts
580
+ except (OSError, ValueError, TypeError, KeyError):
581
+ return
582
+ if not accounts:
583
+ return
584
+
585
+ def is_current() -> bool:
586
+ """Accept the answer only while the Settings window that asked is still the open one."""
587
+ return self.session_registry_controller.setting_dialog is dialog and isValid(dialog)
588
+
589
+ self.authentication.lookup_secrets(accounts, self.show_account_status, is_current=is_current)
590
+
591
+ def show_account_status(self, lookup: SecretLookup) -> None:
592
+ """Mark again, in the open Settings window, the listed accounts whose stored secret is known to be gone, and those this check found."""
593
+ dialog = self.session_registry_controller.setting_dialog
594
+ if dialog is None or not isValid(dialog):
595
+ return
596
+ dialog.account_page.show_needs_reconnect(frozenset(self.authentication.missing_secret_namespace_set), lookup.present_namespace_set)
597
+
598
+ def save_global_accounts(self) -> None:
599
+ """Write only the defaults changed in the Global block; a refusal is shown in that block and its draft is kept."""
600
+ dialog = self.session_registry_controller.setting_dialog
601
+ if dialog is None or not isValid(dialog):
602
+ return
603
+ page = dialog.account_page
604
+ changes = page.global_changes()
605
+ if not changes:
606
+ return
607
+ try:
608
+ self.authentication.save_global_defaults(changes)
609
+ except (OSError, ValueError, TypeError) as error:
610
+ diagnostic = Diagnostic("Save global settings", "", "", [], str(error)).prepare()
611
+ page.show_global_error(diagnostic.cause)
612
+ if self.diagnostic_log is not None:
613
+ self.diagnostic_log.write(diagnostic)
614
+ return
615
+ page.global_saved()
616
+
617
+ def save_repository_accounts(self) -> None:
618
+ """Write only the rows changed in This repository, for the repository Settings was opened for; a refusal stays in that block with its draft."""
619
+ dialog = self.session_registry_controller.setting_dialog
620
+ if dialog is None or not isValid(dialog):
621
+ return
622
+ context = dialog.git_setting_widget.context
623
+ page = dialog.account_page
624
+ changes = page.repository_changes()
625
+ if context is None or not changes:
626
+ return
627
+ try:
628
+ self.authentication.save_repository_choices(changes, context)
629
+ except (OSError, ValueError, TypeError) as error:
630
+ diagnostic = Diagnostic("Save this repository settings", context.repository_dir, "", [], str(error)).prepare()
631
+ page.show_repository_error(diagnostic.cause)
632
+ if self.diagnostic_log is not None:
633
+ self.diagnostic_log.write(diagnostic)
634
+ return
635
+ page.repository_saved()
636
+
637
+ def read_account_remotes(self, dialog: SettingDialog) -> None:
638
+ """Read the remotes of the repository Settings was opened for, so This repository can show one row per HTTPS address.
639
+
640
+ Only reads: nothing is written to Git or to the preferences. The result reaches the page only while the same
641
+ Settings window is open and no newer read was started for it; an Add account or Reconnect window already open
642
+ from that page also learns the repository URL of each address, to test a username + password there.
643
+ """
644
+ context = dialog.git_setting_widget.context
645
+ if context is None:
646
+ return
647
+ page = dialog.account_page
648
+ page.remote_generation += 1
649
+ generation = page.remote_generation
650
+ git_filename = self.repository.git_filename
651
+
652
+ def complete(snapshot: RemoteSnapshot | None, error: Exception | None) -> None:
653
+ """Show the remotes, or why they could not be read, in the page that asked for this very read."""
654
+ if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog) or not isValid(page) or generation != page.remote_generation:
655
+ return
656
+ if error is not None or snapshot is None:
657
+ diagnostic = Diagnostic("Read remotes", context.repository_dir, "", [], str(error)).prepare()
658
+ page.show_remote_error(diagnostic.cause)
659
+ if self.diagnostic_log is not None:
660
+ self.diagnostic_log.write(diagnostic)
661
+ return
662
+ entries = tuple(snapshot.entry_by_name.values())
663
+ page.show_remotes(entries)
664
+ sign_in_dialog = page.sign_in_dialog
665
+ if sign_in_dialog is None or not isValid(sign_in_dialog):
666
+ return
667
+ for origin, url in check_url_by_origin(entries).items():
668
+ sign_in_dialog.set_known_check_url(origin, url)
669
+
670
+ self.worktree_service.run(context, git_filename, self.remote.capture(context, git_filename, generation), complete)
671
+
672
+ def preference_changed_for_accounts(self, changed_value_by_key: dict[str, object]) -> None:
673
+ """Show again, in an open Settings window, the accounts a change of the shared preferences touched."""
674
+ # A sign-in, a block save or another window may have changed the accounts; an open Settings shows them without
675
+ # losing what the user chose.
676
+ if AUTHENTICATION_PREFERENCE_KEY in changed_value_by_key:
677
+ self.refresh_account_page()