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,1005 @@
1
+ """Add an account for any HTTPS server, or renew the sign-in of a listed one, from one small window.
2
+
3
+ The window picks the way to sign in from the server address. GitHub, gitlab.com and Bitbucket Cloud refuse the account
4
+ password over Git HTTPS, so for them it offers browser sign-in, with a personal access token checked by the service as
5
+ the other way. Any other server gets a username with a password or token, tested with Git on a repository of that
6
+ server before it is saved. When that test is refused, the window advises the method the server accepts: browser
7
+ sign-in for a server recognised as offering it, otherwise a personal access token and where to create one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import threading
14
+ from enum import Enum
15
+ from typing import TYPE_CHECKING
16
+
17
+ from PySide6.QtCore import QDeadlineTimer, QRect, Qt, QTimer, Signal
18
+ from PySide6.QtGui import QTextCursor
19
+ from PySide6.QtWidgets import QApplication, QDialog, QFormLayout, QHBoxLayout, QLabel, QLayout, QLineEdit, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget
20
+ from shiboken6 import isValid
21
+
22
+ from gitpane.account.authentication import (
23
+ BITBUCKET_PROVIDER,
24
+ BROWSER_TIMEOUT_STATUS_SET,
25
+ BROWSER_TIMEOUT_TEXT,
26
+ CHECK_FAILED_TEXT,
27
+ GENERIC_PROVIDER,
28
+ HTTPS_PREFIX,
29
+ endpoint_origin,
30
+ origin_address,
31
+ provider_for_browser_origin,
32
+ store_was_refused,
33
+ )
34
+ from gitpane.account.credential_manager import ignore_line
35
+ from gitpane.account.keyring_state import UNKNOWN_KEYRING_READING, KeyringProvider, KeyringReading, KeyringState
36
+ from gitpane.config import BROWSER_SIGN_IN_TIMEOUT_MS, MILLISECONDS_PER_SECOND, SPACING_SM, SharedText, control_height, fit_window
37
+ from gitpane.text.typed_text import typed_value
38
+ from gitpane.thread.weak_signal import hand_over_to_the_page, weak_signal_emitter
39
+ from gitpane.transport.git_refusal import RefusalKind, refusal_kind
40
+ from gitpane.ui.look.fixed_font import body_sized_fixed_font
41
+ from gitpane.ui.widget.busy_indicator import BusyIndicator
42
+ from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
43
+ from gitpane.ui.widget.content_measure import LayoutContentMeasure
44
+ from gitpane.ui.widget.dialog_layout import apply_left_aligned_form
45
+ from gitpane.ui.widget.long_text import FRAME_SIDE_COUNT
46
+ from gitpane.ui.widget.result_label import ResultLabel, ResultTone
47
+ from gitpane.ui.window_control.compositor_answer import after_compositor_answer
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Callable
51
+
52
+ from gitpane.account.authentication import AccountCheck, AccountSelection, AuthOutcome
53
+
54
+ ADD_ACCOUNT_WIDTH, ADD_ACCOUNT_HEIGHT = 560, 420
55
+ ADD_ACCOUNT_MIN_WIDTH, ADD_ACCOUNT_MIN_HEIGHT = 400, 280
56
+ # Lines of output the field shows at least when an output arrives: the form grows to give it that height, the rest scrolls.
57
+ ACCOUNT_OUTPUT_LINE_COUNT = 10
58
+ # The output document's margin is counted twice in the field's height: once above its text, once below it.
59
+ DOCUMENT_MARGIN_SIDE_COUNT = 2
60
+ ADD_ACCOUNT_TITLE = "Add account"
61
+ RECONNECT_TITLE = "Reconnect account"
62
+ ADD_BUTTON_TEXT = "Add account"
63
+ RECONNECT_BUTTON_TEXT = "Reconnect"
64
+ SERVER_URL_LABEL = "Server URL"
65
+ SERVICE_NAME_LABEL = "Service name"
66
+ USERNAME_LABEL = "Username"
67
+ PASSWORD_LABEL = "Password or token"
68
+ TOKEN_LABEL = "Personal access token"
69
+ BITBUCKET_EMAIL_LABEL = "Bitbucket API email"
70
+ CHECK_URL_LABEL = "Repository URL to test"
71
+ SERVER_URL_PLACEHOLDER = "https://gitlab.mycompany.example"
72
+ SERVICE_NAME_PLACEHOLDER = "Name of your service (GitHub, GitLab, etc.)"
73
+ CHECK_URL_PLACEHOLDER = "https://gitlab.mycompany.example/group/project.git"
74
+ SERVER_HELP_TEXT = ("Any HTTPS server works, including your company's own server: change the Server URL above. "
75
+ "The service name is only used in GitPane's account lists.")
76
+ PASSWORD_HELP_TEXT = ("Before saving the account, GitPane tests the username and password on a repository of this server. "
77
+ "Nothing is saved if the server refuses them.")
78
+ CLOUD_HELP_TEXT = ("GitHub, gitlab.com and Bitbucket Cloud do not accept your account password for Git. "
79
+ "Sign in with your browser, or use a personal access token created on the service's website.")
80
+ TOKEN_HELP_TEXT = "GitPane checks the personal access token with the service before saving it."
81
+ HELP_TEXT_SEPARATOR = "\n"
82
+ BROWSER_BUTTON_TEXT = "Sign in with browser"
83
+ # Once a token or password field is shown, the browser is the other way to sign in, and its button says so.
84
+ BROWSER_INSTEAD_BUTTON_TEXT = "Sign in with browser instead"
85
+ TOKEN_LINK_TEXT = "Use a token instead"
86
+ SELF_HOSTED_BROWSER_TEXT = ("On this server, sign in with your username and password or a token. Browser sign-in needs an OAuth application "
87
+ "installed on the server, so it is offered only if the server refuses passwords and supports it.")
88
+ # Said in orange, with a turning indicator, while the browser has not answered yet: the time left before GitPane stops
89
+ # waiting, updated every second.
90
+ BROWSER_COUNTDOWN_TEXT = "Waiting for browser response {minutes:02d}:{seconds:02d}"
91
+ COUNTDOWN_INTERVAL_MS = 1000
92
+ SECONDS_PER_MINUTE = 60
93
+ TOKEN_WAIT_TEXT = "Checking token…"
94
+ CHECKING_TEXT = "Testing the sign-in on {address}…"
95
+ CHECK_NOTE_TEXT = "The sign-in is tested with {url}."
96
+ # Said in orange when the server's answer says account passwords are refused for Git: not an error, another method is
97
+ # needed, the browser when the server was recognised as offering it, otherwise a personal access token.
98
+ BROWSER_INSTEAD_TEXT = "This server does not accept passwords for Git. Sign in with your browser instead."
99
+ TOKEN_INSTEAD_TEXT = "This server does not accept passwords for Git. Use a personal access token instead."
100
+ TOKEN_PAGE_TEXT = "Create a personal access token at {url}, then enter it above."
101
+ TOKEN_PAGE_GENERIC_TEXT = "Create a personal access token in your account settings on this server, then enter it above."
102
+ CREDENTIALS_REFUSED_TEXT = "The server refused the username or password. Nothing was saved. Check them, or use a personal access token instead."
103
+ # Status line of Add account after the user cancelled a sign-in.
104
+ SIGN_IN_CANCELLED_TEXT = "Sign-in cancelled. Sign in again when ready."
105
+ EMPTY_SERVER_TEXT = "Enter the server URL, for example " + SERVER_URL_PLACEHOLDER + "."
106
+ EMPTY_SERVICE_NAME_TEXT = "Enter the name of your service."
107
+ EMPTY_USERNAME_TEXT = "Enter the username you use on this server."
108
+ EMPTY_PASSWORD_TEXT = "Enter a password or token."
109
+ EMPTY_TOKEN_TEXT = "Enter a personal access token."
110
+ EMPTY_BITBUCKET_EMAIL_TEXT = "Enter the e-mail address of your Bitbucket account."
111
+ URL_SCHEME_SEPARATOR = "://"
112
+ # Settings keeps the account list without a Save: after a sign-in there, the user is told nothing is left to save.
113
+ SIGN_IN_SAVED_TEXT = "It is saved automatically; you can close this window."
114
+ # The keyring is read on a worker thread of this name, so the window never waits on the session bus.
115
+ KEYRING_READ_THREAD_NAME = "gitpane-keyring-read"
116
+ KEYRING_READ_FAILURE_LOG_TEMPLATE = "Reading the state of the keyring failed unexpectedly: {error_class}: {error}"
117
+
118
+
119
+ class AddAccountText(Enum):
120
+ """Texts added to the sign-in form, one member per text so a misspelt name is an error."""
121
+
122
+ OUTPUT_NAME = "Sign-in output"
123
+ """The accessible name of the field that shows the whole output of a failed sign-in."""
124
+ LIST_SAVE_FAILED = "Signed in and stored securely, but Connected accounts could not be saved."
125
+ """The sign-in and the secure store worked, but the account list could not be written."""
126
+ GENERIC_STORE_FAILED = "The password or token could not be saved in the system credential store."
127
+ """A username + password account passed its test, but the secure store refused to save it."""
128
+ STORE_FAILED = "Signed in, but the secure store could not save this account."
129
+ """The service accepted the sign-in, but the secure store refused to save it."""
130
+ SIGN_IN_FAILED = "Sign-in failed."
131
+ """The sign-in itself failed, before anything was saved."""
132
+ SIGN_IN_NOT_STARTED = "Sign-in did not start."
133
+ """The sign-in was stopped before anything was sent to the credential manager, because the keyring could not store it."""
134
+ KEYRING_FIRST_SESSION = "This is a known bug of the GNOME keyring in the very first session of a new system or user: log out, log back in, then try again."
135
+ """Said in orange when the keyring's default collection is not published on the session bus, which happens in the very first session only."""
136
+ KEYRING_LOCKED = "The GNOME keyring is locked: unlock it when GNOME asks, then try again."
137
+ """Said in orange when the secure store refused to save while the keyring's default collection is still locked."""
138
+ KEYRING_NO_DEFAULT_COLLECTION = "The keyring has no default collection to save the account in: create one when the desktop asks for it, or in Passwords and Keys, then try again."
139
+ """Said in orange when the secure store refused to save while the keyring still has no default collection."""
140
+ KDE_WALLET_CLOSED = "KDE Wallet is closed: open it when KDE asks, then try again."
141
+ """Said in orange when KDE Wallet holds the Secret Service and its default wallet is closed."""
142
+ KDE_WALLET_NO_DEFAULT_WALLET = "KDE Wallet has no default wallet to save the account in: create one when KDE asks for it, or in KWalletManager, then try again."
143
+ """Said in orange when KDE Wallet holds the Secret Service but no wallet stands for its default one."""
144
+ KEEPASSXC_LOCKED = "The KeePassXC database is locked: unlock it when KeePassXC asks, then try again."
145
+ """Said in orange when KeePassXC holds the Secret Service and the database it shares is locked."""
146
+ KEEPASSXC_NO_SHARED_DATABASE = "KeePassXC shares no database with other applications: open a database and expose one of its groups in its Secret Service settings, then try again."
147
+ """Said in orange when KeePassXC holds the Secret Service but exposes no group of an open database."""
148
+ NAMED_KEYRING_LOCKED = "The keyring ({program}) is locked: unlock it when it asks, then try again."
149
+ """Said in orange when a keyring GitPane names only by its program holds a locked default collection."""
150
+ NAMED_KEYRING_NO_DEFAULT_COLLECTION = "The keyring ({program}) has no default collection to save the account in: create one, then try again."
151
+ """Said in orange when a keyring GitPane names only by its program has no default collection."""
152
+ NAMED_KEYRING_UNPUBLISHED = "The keyring ({program}) does not publish its default collection: check it, then try again."
153
+ """Said in orange when a keyring other than the GNOME keyring names a default collection absent from the bus."""
154
+ UNNAMED_KEYRING_LOCKED = "The keyring is locked: unlock it when it asks, then try again."
155
+ """Said in orange when the program that holds the Secret Service could not be read and its default collection is locked."""
156
+ UNNAMED_KEYRING_NO_DEFAULT_COLLECTION = "The keyring has no default collection to save the account in: create one, then try again."
157
+ """Said in orange when the program that holds the Secret Service could not be read and it has no default collection."""
158
+ UNNAMED_KEYRING_UNPUBLISHED = "The keyring does not publish its default collection: check it, then try again."
159
+ """Said in orange when the program that holds the Secret Service could not be read and its default collection is absent from the bus."""
160
+
161
+
162
+ # The orange sentence of each keyring and state that gives the user something to do. The key comes from the keyring
163
+ # read, and the sentence belongs to this window. The GNOME keyring keeps its own sentences; KDE Wallet and KeePassXC get
164
+ # theirs for the states they are known to give, and the neutral sentence that names them otherwise; any other program
165
+ # is named as read, and a program that could not be read is not named. A state missing from the table (available, no
166
+ # service, unknown) gives no sentence.
167
+ KEYRING_ADVICE_BY_PROVIDER_AND_STATE: dict[tuple[KeyringProvider, KeyringState], AddAccountText] = {
168
+ (KeyringProvider.GNOME_KEYRING, KeyringState.UNPUBLISHED): AddAccountText.KEYRING_FIRST_SESSION,
169
+ (KeyringProvider.GNOME_KEYRING, KeyringState.LOCKED): AddAccountText.KEYRING_LOCKED,
170
+ (KeyringProvider.GNOME_KEYRING, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.KEYRING_NO_DEFAULT_COLLECTION,
171
+ (KeyringProvider.KDE_WALLET, KeyringState.LOCKED): AddAccountText.KDE_WALLET_CLOSED,
172
+ (KeyringProvider.KDE_WALLET, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.KDE_WALLET_NO_DEFAULT_WALLET,
173
+ (KeyringProvider.KDE_WALLET, KeyringState.UNPUBLISHED): AddAccountText.NAMED_KEYRING_UNPUBLISHED,
174
+ (KeyringProvider.KEEPASSXC, KeyringState.LOCKED): AddAccountText.KEEPASSXC_LOCKED,
175
+ (KeyringProvider.KEEPASSXC, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.KEEPASSXC_NO_SHARED_DATABASE,
176
+ (KeyringProvider.KEEPASSXC, KeyringState.UNPUBLISHED): AddAccountText.NAMED_KEYRING_UNPUBLISHED,
177
+ (KeyringProvider.OTHER, KeyringState.LOCKED): AddAccountText.NAMED_KEYRING_LOCKED,
178
+ (KeyringProvider.OTHER, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.NAMED_KEYRING_NO_DEFAULT_COLLECTION,
179
+ (KeyringProvider.OTHER, KeyringState.UNPUBLISHED): AddAccountText.NAMED_KEYRING_UNPUBLISHED,
180
+ (KeyringProvider.UNREADABLE, KeyringState.LOCKED): AddAccountText.UNNAMED_KEYRING_LOCKED,
181
+ (KeyringProvider.UNREADABLE, KeyringState.NO_DEFAULT_COLLECTION): AddAccountText.UNNAMED_KEYRING_NO_DEFAULT_COLLECTION,
182
+ (KeyringProvider.UNREADABLE, KeyringState.UNPUBLISHED): AddAccountText.UNNAMED_KEYRING_UNPUBLISHED,
183
+ }
184
+
185
+
186
+ def keyring_advice(reading: KeyringReading) -> str | None:
187
+ """Return the orange sentence the form adds for a keyring reading, ready to show, or None when it gives the user nothing to do.
188
+
189
+ The sentence is chosen by the keyring that holds the Secret Service and the state of its default collection, so a
190
+ KDE user never reads a sentence about the GNOME keyring. A sentence that names the program gets the name as read,
191
+ shown as plain text by the label. Any state GitPane could not read says nothing rather than something it does not know.
192
+ """
193
+ advice = KEYRING_ADVICE_BY_PROVIDER_AND_STATE.get((reading.provider, reading.state))
194
+ if advice is None:
195
+ return None
196
+ return advice.value.format(program=reading.program)
197
+
198
+
199
+ def run_keyring_read(read: Callable[[], KeyringReading], hand_over: Callable[[KeyringReading], None], log_line: Callable[[str], None] = ignore_line) -> None:
200
+ """Read the keyring on the worker thread and hand the reading over to the form's thread, UNKNOWN when the read failed.
201
+
202
+ The form always gets an answer, so a sign-in waiting on it is never left waiting. An error nobody expects shows a
203
+ defect of GitPane, not of the keyring: it is written through ``log_line``, and the answer stays UNKNOWN, which says
204
+ nothing to the user.
205
+ """
206
+ reading = UNKNOWN_KEYRING_READING
207
+ try:
208
+ reading = read()
209
+ except (OSError, ValueError, RuntimeError):
210
+ reading = UNKNOWN_KEYRING_READING
211
+ except Exception as error: # noqa: BLE001 -- the worker must always answer the form; the error is written to the --log journal
212
+ reading = UNKNOWN_KEYRING_READING
213
+ log_line(KEYRING_READ_FAILURE_LOG_TEMPLATE.format(error_class=type(error).__name__, error=error))
214
+ finally:
215
+ hand_over_to_the_page(hand_over, reading)
216
+
217
+
218
+ def sign_in_result_text(outcome: AuthOutcome, *, reconnect: bool, saved_note: bool = False) -> str:
219
+ """Describe a successful sign-in in the words the user expects: added, renewed, or reconnected.
220
+
221
+ saved_note adds the sentence saying the account is already saved and the window can be closed. Only Settings asks
222
+ for it: the Clone form that also signs in stays open for the clone the user is setting up.
223
+ """
224
+ label = outcome.selection.label
225
+ if reconnect:
226
+ text = label + " was reconnected."
227
+ elif outcome.renewed:
228
+ text = label + " was already connected. Its sign-in was renewed."
229
+ else:
230
+ text = label + " was added to Connected accounts."
231
+ if outcome.became_default:
232
+ text += " It is now the default account for " + origin_address(outcome.selection.origin) + "."
233
+ if saved_note:
234
+ text += " " + SIGN_IN_SAVED_TEXT
235
+ return text
236
+
237
+
238
+ def sign_in_failure_text(outcome: AuthOutcome) -> str:
239
+ """Say which step failed: the sign-in, the secure store or the account list, never the output of the program that ran.
240
+
241
+ That output is shown whole in the form's own output field, under this sentence. A failed Git test of a username +
242
+ password account is described by ``check_failure_text`` instead. Whether the secure store is the step that failed is
243
+ decided by ``store_was_refused`` alone, the rule the keyring read after a refusal also follows, so a refusal before
244
+ any program ran or an account GitPane refused, which never reached the store, says the sign-in failed.
245
+ """
246
+ if outcome.stored:
247
+ return AddAccountText.LIST_SAVE_FAILED.value
248
+ if not store_was_refused(outcome):
249
+ return AddAccountText.SIGN_IN_FAILED.value
250
+ if outcome.selection is not None and outcome.selection.provider == GENERIC_PROVIDER:
251
+ return AddAccountText.GENERIC_STORE_FAILED.value
252
+ return AddAccountText.STORE_FAILED.value
253
+
254
+
255
+ def outcome_cause(outcome: AuthOutcome) -> str:
256
+ """Return the prepared cause of a failed exchange, already filtered of secrets, or an empty text when there is none."""
257
+ if outcome.diagnostic is None:
258
+ return ""
259
+ return outcome.diagnostic.cause
260
+
261
+
262
+ def check_failure_text(check: AccountCheck) -> str:
263
+ """Say in plain words why the Git test of a username + password failed, read from the words of the server's answer.
264
+
265
+ A plain refusal is the usual answer to a mistyped password, so it asks to check them. Anything else says the test
266
+ could not be done. An answer saying passwords are not accepted is no error and is told apart by ``passwords_refused``.
267
+ """
268
+ if refusal_kind(check.git_output) is RefusalKind.CREDENTIALS_REFUSED:
269
+ return CREDENTIALS_REFUSED_TEXT
270
+ return CHECK_FAILED_TEXT.format(url=check.url)
271
+
272
+
273
+ def passwords_refused(check: AccountCheck | None) -> bool:
274
+ """Tell whether the server answered the Git test by saying it refuses account passwords, so only another method can work."""
275
+ return check is not None and refusal_kind(check.git_output) is RefusalKind.PASSWORD_NOT_ACCEPTED
276
+
277
+
278
+ class AddAccountDialog(ContentGrowthMixin, QDialog):
279
+ """Show the fields that fit the typed server and report what the user asked for; the account controller runs the exchange.
280
+
281
+ GitHub, gitlab.com and Bitbucket Cloud get browser sign-in with a button beside it to use a personal access token; any other
282
+ server gets a username with a password or token. Opened from Settings, which is modal for the whole application,
283
+ the window is modal over it; opened from a clone form, it is a plain window over that form. In Reconnect mode the
284
+ server, service name and username of the renewed account are shown and cannot be changed; a clone form fixes the
285
+ server only.
286
+
287
+ While the browser has not answered a browser sign-in, the window counts down the time left in orange beside a
288
+ turning indicator, so the user is not left wondering whether GitPane froze. Cancel stops the sign-in and keeps the
289
+ window open; the close button and Escape stop it and close the window: a browser tab the user closed must not keep
290
+ them waiting until the end of the countdown, which only stays as a limit for a forgotten tab.
291
+
292
+ The window's height follows its rows both ways: an output grows it until its field shows ACCOUNT_OUTPUT_LINE_COUNT
293
+ lines, and a new try or a cancelled sign-in, which hide the field, bring it back to the height it had before. While
294
+ the output shows, the user cannot make the window shorter than those lines.
295
+ """
296
+
297
+ sign_in_requested = Signal(bool)
298
+ cancel_requested = Signal()
299
+ # The number of the keyring read and the KeyringReading it gave, handed over from the worker thread.
300
+ keyring_state_read = Signal(int, object)
301
+
302
+ def __init__(self, parent: QWidget | None = None, *, reconnect: AccountSelection | None = None, locked_origin: str = "", service_name: str = "",
303
+ service_name_by_origin: dict[str, str] | None = None, known_check_url_by_origin: dict[str, str] | None = None,
304
+ browser_timeout_ms: int = BROWSER_SIGN_IN_TIMEOUT_MS) -> None:
305
+ """Build the form; ``reconnect`` renews that account (``service_name`` is its address's name), ``locked_origin`` fixes the server of a new account.
306
+
307
+ ``service_name_by_origin`` holds the names already given to known addresses, so adding a second account to one
308
+ of them proposes the same name instead of renaming the address by accident. ``known_check_url_by_origin`` holds
309
+ the repository URL the caller already knows for an address (a remote of the open repository, the URL being
310
+ cloned): a username + password on that address is tested there, and the window says so instead of asking.
311
+ ``browser_timeout_ms`` is how long the account service waits for the browser; the countdown starts from it.
312
+ """
313
+ super().__init__(parent)
314
+ self.reconnect = reconnect
315
+ self.service_name_by_origin = dict(service_name_by_origin or {})
316
+ self.known_check_url_by_origin = dict(known_check_url_by_origin or {})
317
+ self.operation_id = ""
318
+ self.generation = 0
319
+ self.closed = False
320
+ self.running = False
321
+ self.token_mode = False
322
+ self.browser_timeout_ms = browser_timeout_ms
323
+ self.browser_waiting = False
324
+ self.browser_deadline = QDeadlineTimer(browser_timeout_ms)
325
+ self.countdown_timer = QTimer(self)
326
+ self.countdown_timer.setInterval(COUNTDOWN_INTERVAL_MS)
327
+ self.countdown_timer.timeout.connect(self._show_countdown)
328
+ # The failed Git test of the typed address, kept until the address changes: it decides which method is advised next.
329
+ self.refused_check: AccountCheck | None = None
330
+ self.prefilled_service_name = ""
331
+ # What to do with the keyring reading the running read gives; it may hold the typed secret, so it is dropped as
332
+ # soon as the answer arrives, the sign-in is cancelled or the window closes.
333
+ self.keyring_answer: Callable[[KeyringReading], None] | None = None
334
+ # Only the answer of the latest read is used: a read started for an earlier, cancelled try answers into nothing.
335
+ self.keyring_read_number = 0
336
+ # The refused store whose keyring read again is still awaited: Cancel during that read shows this refusal, which
337
+ # is already known, instead of saying the sign-in was cancelled.
338
+ self.refused_outcome: AuthOutcome | None = None
339
+ # True while the form shows the refusal of a credential manager and its install lines, from the try that met
340
+ # it until anything else is shown; only then may a later search that finds one take that refusal away.
341
+ self.credential_manager_refusal_shown = False
342
+ self.server_locked = reconnect is not None or bool(locked_origin)
343
+ self.account_locked = reconnect is not None
344
+ self.setModal(False)
345
+ self.setWindowTitle(RECONNECT_TITLE if reconnect is not None else ADD_ACCOUNT_TITLE)
346
+ self.resize(ADD_ACCOUNT_WIDTH, ADD_ACCOUNT_HEIGHT)
347
+ layout = QVBoxLayout(self)
348
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
349
+ layout.setSpacing(SPACING_SM)
350
+ self.form = QFormLayout()
351
+ self.form.setSpacing(SPACING_SM)
352
+ apply_left_aligned_form(self.form)
353
+ self.server_edit = self._line_edit(SERVER_URL_LABEL, SERVER_URL_PLACEHOLDER)
354
+ self.form.addRow(SERVER_URL_LABEL, self.server_edit)
355
+ self.service_name_edit = self._line_edit(SERVICE_NAME_LABEL, SERVICE_NAME_PLACEHOLDER)
356
+ self.form.addRow(SERVICE_NAME_LABEL, self.service_name_edit)
357
+ self.username_edit = self._line_edit(USERNAME_LABEL, "")
358
+ self.form.addRow(USERNAME_LABEL, self.username_edit)
359
+ self.check_url_edit = self._line_edit(CHECK_URL_LABEL, CHECK_URL_PLACEHOLDER)
360
+ self.form.addRow(CHECK_URL_LABEL, self.check_url_edit)
361
+ self.check_note_label = self._plain_label("")
362
+ self.form.addRow(self.check_note_label)
363
+ self.password_label = QLabel(PASSWORD_LABEL)
364
+ self.password_edit = self._line_edit(PASSWORD_LABEL, "")
365
+ self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
366
+ self.form.addRow(self.password_label, self.password_edit)
367
+ self.token_page_label = self._plain_label("")
368
+ self.form.addRow(self.token_page_label)
369
+ self.bitbucket_email_edit = self._line_edit(BITBUCKET_EMAIL_LABEL, "")
370
+ self.form.addRow(BITBUCKET_EMAIL_LABEL, self.bitbucket_email_edit)
371
+ layout.addLayout(self.form)
372
+ self.help_label = self._plain_label("")
373
+ layout.addWidget(self.help_label)
374
+ self.browser_button = QPushButton(BROWSER_BUTTON_TEXT)
375
+ self.browser_button.setAutoDefault(False)
376
+ self.browser_button.clicked.connect(lambda: self.sign_in_requested.emit(False))
377
+ # The token is the other way to sign in on the same services, so its button stands beside the browser's, as an
378
+ # ordinary button: a flat link-like one did not look clickable.
379
+ self.token_link_button = QPushButton(TOKEN_LINK_TEXT)
380
+ self.token_link_button.setAccessibleName(TOKEN_LINK_TEXT)
381
+ self.token_link_button.setAutoDefault(False)
382
+ self.token_link_button.clicked.connect(self._token_link_clicked)
383
+ browser_layout = QHBoxLayout()
384
+ browser_layout.addWidget(self.browser_button)
385
+ browser_layout.addWidget(self.token_link_button)
386
+ browser_layout.addStretch()
387
+ layout.addLayout(browser_layout)
388
+ self.add_button = QPushButton(RECONNECT_BUTTON_TEXT if reconnect is not None else ADD_BUTTON_TEXT)
389
+ self.add_button.setAutoDefault(False)
390
+ self.add_button.clicked.connect(lambda: self.sign_in_requested.emit(True))
391
+ self.self_hosted_label = self._plain_label(SELF_HOSTED_BROWSER_TEXT)
392
+ layout.addWidget(self.self_hosted_label)
393
+ self.busy_indicator = BusyIndicator()
394
+ self.status_label = ResultLabel()
395
+ status_layout = QHBoxLayout()
396
+ status_layout.addWidget(self.busy_indicator, 0, Qt.AlignmentFlag.AlignTop)
397
+ status_layout.addWidget(self.status_label, 1)
398
+ layout.addLayout(status_layout)
399
+ # The orange sentence about the keyring, under the red line of the step and above the output, shown only
400
+ # when the keyring explains the failure.
401
+ self.keyring_label = ResultLabel()
402
+ self.keyring_label.hide()
403
+ layout.addWidget(self.keyring_label)
404
+ # The whole output of a failed sign-in, as the program wrote it once its secrets were filtered: plain text the
405
+ # user can read, select and copy into a bug report or an assistant. It takes the height given to the window;
406
+ # while it is hidden, the stretch below takes it back.
407
+ self.output_edit = QPlainTextEdit()
408
+ self.output_edit.setReadOnly(True)
409
+ # A read-only text field only keeps mouse selection: the keyboard selection lets Ctrl+A select it all.
410
+ self.output_edit.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
411
+ self.output_edit.setAccessibleName(AddAccountText.OUTPUT_NAME.value)
412
+ self.output_edit.hide()
413
+ layout.addWidget(self.output_edit, 1)
414
+ self.copy_output_button = QPushButton(SharedText.COPY_DETAILS.value)
415
+ self.copy_output_button.setAutoDefault(False)
416
+ self.copy_output_button.clicked.connect(self.copy_output)
417
+ self.copy_output_button.hide()
418
+ copy_layout = QHBoxLayout()
419
+ copy_layout.addWidget(self.copy_output_button)
420
+ copy_layout.addStretch()
421
+ layout.addLayout(copy_layout)
422
+ # The exact text the output field shows, which Copy details copies.
423
+ self.output_text = ""
424
+ layout.addStretch()
425
+ # The confirm button sits just left of Cancel in the bottom-right row, as in every other dialog, whatever the
426
+ # way to sign in shown above.
427
+ action_layout = QHBoxLayout()
428
+ action_layout.addStretch()
429
+ action_layout.addWidget(self.add_button)
430
+ self.cancel_button = QPushButton("Cancel")
431
+ self.cancel_button.setAutoDefault(False)
432
+ self.cancel_button.clicked.connect(self._cancel_clicked)
433
+ action_layout.addWidget(self.cancel_button)
434
+ layout.addLayout(action_layout)
435
+ self._apply_lock(reconnect, locked_origin, service_name)
436
+ self.service_name_edit.textEdited.connect(self._service_name_edited)
437
+ self.server_edit.textChanged.connect(self._server_changed)
438
+ self.keyring_state_read.connect(self._keyring_state_answered, Qt.ConnectionType.QueuedConnection)
439
+ self._prefill_service_name()
440
+ self._update_presentation()
441
+ self.refresh_metrics()
442
+ # The form has no scroll area: its own layout, whose minimum counts the output field at its height of
443
+ # ACCOUNT_OUTPUT_LINE_COUNT lines while it is shown, tells the height its rows need.
444
+ self.install_content_growth(LayoutContentMeasure(self), OpeningFit.GROW_ONLY, ADD_ACCOUNT_HEIGHT)
445
+
446
+ def server_url(self) -> str:
447
+ """Return the typed server address; one typed without a scheme is taken as HTTPS, anything else is left for the service to check."""
448
+ text = typed_value(self.server_edit.text())
449
+ if not text or URL_SCHEME_SEPARATOR in text:
450
+ return text
451
+ return HTTPS_PREFIX + text
452
+
453
+ def address(self) -> str:
454
+ """Return the address this window is about, as shown to the user: the renewed account's, else the typed server's, else the typed text."""
455
+ if self.reconnect is not None:
456
+ return origin_address(self.reconnect.origin)
457
+ try:
458
+ return origin_address(self.server_url())
459
+ except ValueError:
460
+ return self.server_url()
461
+
462
+ def check_url(self) -> str:
463
+ """Return the URL of the repository on which the username and password are tested before the account is saved.
464
+
465
+ It is the URL the caller already knows for this address when there is one, since the field is then hidden;
466
+ otherwise it is the typed one.
467
+ """
468
+ known_check_url = self.known_check_url()
469
+ if known_check_url:
470
+ return known_check_url
471
+ return typed_value(self.check_url_edit.text())
472
+
473
+ def service_name(self) -> str:
474
+ """Return the typed service name without the blanks around it; a name made only of blanks counts as empty."""
475
+ return typed_value(self.service_name_edit.text())
476
+
477
+ def username(self) -> str:
478
+ """Return the typed username without the blanks around it."""
479
+ return typed_value(self.username_edit.text())
480
+
481
+ def bitbucket_email(self) -> str:
482
+ """Return the typed Bitbucket e-mail without the blanks around it."""
483
+ return typed_value(self.bitbucket_email_edit.text())
484
+
485
+ def known_check_url(self) -> str:
486
+ """Return the repository URL the caller already knows for this address, or an empty text when the user has to type one.
487
+
488
+ Only a username + password account is tested with Git, so any other account has none.
489
+ """
490
+ if not self.asks_username():
491
+ return ""
492
+ origin = self._origin()
493
+ if not origin:
494
+ return ""
495
+ return self.known_check_url_by_origin.get(origin, "")
496
+
497
+ def set_known_check_url(self, origin: str, url: str) -> None:
498
+ """Remember the repository URL to test for this address, learnt after the window opened, and show it if that address is the one shown."""
499
+ self.known_check_url_by_origin[origin] = url
500
+ self._update_presentation()
501
+
502
+ def asks_check_url(self) -> bool:
503
+ """Tell whether a repository to test is asked: only for a username + password account, and only when no URL of its address is known."""
504
+ return self.asks_username() and not self.known_check_url()
505
+
506
+ def cloud_provider(self) -> str | None:
507
+ """Name the online service (GitHub, GitLab or Bitbucket) whose own sign-in fits this address, or None for any other server.
508
+
509
+ A username + password account being reconnected keeps its kind whatever its address, so it has none.
510
+ """
511
+ if self._generic_reconnect():
512
+ return None
513
+ return self._address_provider()
514
+
515
+ def browser_provider(self) -> str | None:
516
+ """Name the service whose browser sign-in fits this address, or None when only a password or token can be used.
517
+
518
+ A service account being reconnected on another server has no browser sign-in: only a token can renew it. Any
519
+ other server offers the browser sign-in of the provider it was recognised as after a refused test, if any.
520
+ """
521
+ provider = self.cloud_provider()
522
+ if provider is None:
523
+ return self._recognised_browser_provider()
524
+ if self.reconnect is None:
525
+ return provider
526
+ return self.reconnect.provider
527
+
528
+ def checks_token(self) -> bool:
529
+ """Tell whether the typed secret is a service token that the service's API checks before anything is saved."""
530
+ if self.reconnect is not None:
531
+ return self.reconnect.provider != GENERIC_PROVIDER
532
+ return self.cloud_provider() is not None
533
+
534
+ def asks_token(self) -> bool:
535
+ """Tell whether the secret field asks for a personal access token, because the server refuses account passwords or checks the token."""
536
+ return self.checks_token() or self._address_provider() is not None or passwords_refused(self.refused_check)
537
+
538
+ def asks_username(self) -> bool:
539
+ """Tell whether a username is part of this account: only a username + password account has one."""
540
+ if self.reconnect is not None:
541
+ return self.reconnect.provider == GENERIC_PROVIDER
542
+ return self.cloud_provider() is None
543
+
544
+ def asks_bitbucket_email(self) -> bool:
545
+ """Tell whether a Bitbucket token is being typed, which the Bitbucket API only checks together with the account e-mail."""
546
+ return self.browser_provider() == BITBUCKET_PROVIDER
547
+
548
+ def set_running(self, running: bool, *, token: bool = False) -> None:
549
+ """Block duplicate submissions and discard the typed password as soon as a sign-in starts or ends.
550
+
551
+ A browser sign-in (``token`` false) starts the countdown and the turning indicator; the other ways to sign in
552
+ show a plain wait text. Every sign-in can be cancelled.
553
+ """
554
+ self.running = running
555
+ self.server_edit.setEnabled(not running and not self.server_locked)
556
+ for control in (self.service_name_edit, self.username_edit):
557
+ control.setEnabled(not running and not self.account_locked)
558
+ for control in (self.check_url_edit, self.password_edit, self.bitbucket_email_edit, self.add_button, self.browser_button, self.token_link_button):
559
+ control.setEnabled(not running)
560
+ self.password_edit.clear()
561
+ self._set_browser_waiting(running and not token)
562
+ if not running:
563
+ return
564
+ self._hide_output()
565
+ self.show_keyring_note(UNKNOWN_KEYRING_READING)
566
+ if token:
567
+ self.status_label.setText(self._wait_text())
568
+ return
569
+ self._show_countdown()
570
+
571
+ def clear_password(self) -> None:
572
+ """Drop the typed password or token, for example after a form check refused the request."""
573
+ self.password_edit.clear()
574
+
575
+ def show_refusal(self, text: str) -> None:
576
+ """Say in red what the form still misses; the output and keyring sentence of an earlier try no longer apply and go away."""
577
+ self._hide_output()
578
+ self.show_keyring_note(UNKNOWN_KEYRING_READING)
579
+ self.status_label.show_failure(text)
580
+
581
+ def read_keyring_state(self, read: Callable[[], KeyringReading], answer: Callable[[KeyringReading], None], log_line: Callable[[str], None] = ignore_line) -> None:
582
+ """Read the keyring on a worker thread, then call ``answer`` with the reading on the window's thread.
583
+
584
+ The read may wait on the session bus, so it never runs on the window's thread. ``answer`` is kept only until the
585
+ answer arrives, the sign-in is cancelled or the window closes; an answer arriving after that, or the answer of an
586
+ earlier read, calls nothing.
587
+ """
588
+ self.keyring_read_number += 1
589
+ read_number = self.keyring_read_number
590
+ self.keyring_answer = answer
591
+ emit = weak_signal_emitter(self, lambda dialog: dialog.keyring_state_read)
592
+
593
+ def hand_over(reading: KeyringReading) -> None:
594
+ """Hand the reading over to the window's thread together with the number of the read that gave it."""
595
+ emit(read_number, reading)
596
+
597
+ thread = threading.Thread(target=run_keyring_read, args=(read, hand_over, log_line), name=KEYRING_READ_THREAD_NAME, daemon=True)
598
+ thread.start()
599
+
600
+ def forget_keyring_answer(self) -> None:
601
+ """Drop what the running keyring read would call, so its late answer does nothing and keeps no typed secret alive."""
602
+ self.keyring_answer = None
603
+
604
+ def show_keyring_note(self, reading: KeyringReading) -> None:
605
+ """Show in orange the sentence that explains this keyring reading, under the red line, or hide the sentence when the reading gives none."""
606
+ advice = keyring_advice(reading)
607
+ if advice is None:
608
+ self.keyring_label.clear()
609
+ self.keyring_label.hide()
610
+ return
611
+ self.keyring_label.show_warning(advice)
612
+ self.keyring_label.show()
613
+
614
+ def show_keyring_refusal(self, reading: KeyringReading) -> None:
615
+ """End the wait of a sign-in stopped before anything was sent, because the keyring could not store it, and say why.
616
+
617
+ Nothing ran, so there is no output: the red line only says the sign-in did not start, and the orange sentence
618
+ under it says what to do about the keyring.
619
+ """
620
+ self.set_running(False)
621
+ self._hide_output()
622
+ self.status_label.show_failure(AddAccountText.SIGN_IN_NOT_STARTED.value)
623
+ self.show_keyring_note(reading)
624
+
625
+ def show_failure(self, outcome: AuthOutcome, keyring_reading: KeyringReading = UNKNOWN_KEYRING_READING) -> None:
626
+ """End the wait, say in red which step failed and show below it the whole output of the program that ran.
627
+
628
+ The status line says the step; the output field shows what the program that ran wrote, the prepared cause exactly
629
+ as received, already filtered of secrets, never summarised, cut or sorted, so the user can copy it whole. The
630
+ field stays hidden when there is no output. A cause that is GitPane's own sentence rather than a program's
631
+ output goes on the status line after the step, with no output field: a refusal before any program ran, or an
632
+ account the sign-in returned that GitPane refused (signed in as someone else than the account being
633
+ reconnected). A browser that did not answer in time gets its own short sentence only: the exchange was stopped
634
+ and nothing changed.
635
+
636
+ ``keyring_reading`` is the keyring read again after the secure store refused to save: when it explains the
637
+ refusal (a collection still locked, not published, or no default collection at all), its sentence, chosen by
638
+ the keyring that holds the Secret Service, comes in orange between the red line and the output, which does not
639
+ change. Every other result hides that sentence.
640
+
641
+ A failed Git test of a username + password says why in plain words, with Git's own output in the field; without
642
+ any output from Git, the cause (a time limit, for example) is shown there instead. A server that says it refuses
643
+ account passwords is no error: the window says in orange which method to use instead and switches to it, keeping
644
+ the typed username. The browser is only offered, never opened by the window itself.
645
+ """
646
+ browser_waited = self.browser_waiting
647
+ self.set_running(False)
648
+ self.credential_manager_refusal_shown = False
649
+ self.show_keyring_note(UNKNOWN_KEYRING_READING)
650
+ if browser_waited and outcome.status in BROWSER_TIMEOUT_STATUS_SET:
651
+ self._hide_output()
652
+ self.status_label.show_failure(BROWSER_TIMEOUT_TEXT)
653
+ return
654
+ cause = outcome_cause(outcome)
655
+ if outcome.check is None:
656
+ if outcome.refused_before_start or outcome.refused_by_gitpane:
657
+ self._hide_output()
658
+ self.status_label.show_failure(sign_in_failure_text(outcome) + (" " + cause if cause else ""))
659
+ return
660
+ self.status_label.show_failure(sign_in_failure_text(outcome))
661
+ # The sentence is shown before the output, so the output's growth measures the final form.
662
+ self.show_keyring_note(keyring_reading)
663
+ self._show_output(cause)
664
+ return
665
+ self.refused_check = outcome.check
666
+ if passwords_refused(outcome.check):
667
+ self.status_label.show_warning(BROWSER_INSTEAD_TEXT if self._recognised_browser_provider() is not None else TOKEN_INSTEAD_TEXT)
668
+ else:
669
+ self.status_label.show_failure(check_failure_text(outcome.check))
670
+ # The fields of the advised method are shown before the output, so the output's growth measures the final form.
671
+ self._update_presentation()
672
+ self._show_output(outcome.check.git_output or cause)
673
+
674
+ def show_install_text(self, lines: tuple[str, ...]) -> None:
675
+ """Show, in the output field under the refusal, the install lines of the credential manager the sign-in could not find.
676
+
677
+ The install window says the same over the form, but the user reads it, closes it, then installs: the form keeps
678
+ the lines so they stay in sight until the next try. They are selectable and copyable, so a command can be copied.
679
+ """
680
+ self._show_output("\n".join(lines))
681
+ self.credential_manager_refusal_shown = True
682
+
683
+ def clear_credential_manager_refusal(self) -> None:
684
+ """Take away the refusal of the credential manager, its install lines and Copy details, once a later search found a usable one.
685
+
686
+ The form then looks as before the try, so it no longer says signing in is impossible; what the user typed stays.
687
+ A form that shows something else since, the result of a newer try for example, is left as it is.
688
+ """
689
+ if not self.credential_manager_refusal_shown:
690
+ return
691
+ self._hide_output()
692
+ self.show_keyring_note(UNKNOWN_KEYRING_READING)
693
+ self.status_label.clear()
694
+
695
+ def show_cancelled(self) -> None:
696
+ """End the wait after the user cancelled, so the form is ready for another try."""
697
+ self.set_running(False)
698
+ self._hide_output()
699
+ self.show_keyring_note(UNKNOWN_KEYRING_READING)
700
+ self.status_label.setText(SIGN_IN_CANCELLED_TEXT)
701
+
702
+ def copy_output(self) -> None:
703
+ """Copy exactly the already-filtered text the output field shows."""
704
+ QApplication.clipboard().setText(self.output_text)
705
+
706
+ def abandon(self) -> None:
707
+ """Stop accepting any result for this window and drop the typed password, cancelling a running sign-in."""
708
+ self.closed = True
709
+ self.generation += 1
710
+ self.forget_keyring_answer()
711
+ self.password_edit.clear()
712
+ if self.running:
713
+ self.cancel_requested.emit()
714
+
715
+ def reject(self) -> None:
716
+ """Close the window, also from Escape and the close button; a running sign-in is cancelled first and the typed password is never kept."""
717
+ self.closed = True
718
+ if self.running:
719
+ self.cancel_requested.emit()
720
+ self.forget_keyring_answer()
721
+ self.password_edit.clear()
722
+ super().reject()
723
+
724
+ def refresh_metrics(self, available: QRect | None = None) -> None:
725
+ """Size controls for the current font and keep the window inside the usable screen.
726
+
727
+ The output field asks at least ACCOUNT_OUTPUT_LINE_COUNT lines of its font, with the document margins above and
728
+ below them and its frame around them: the window's layout counts that height while the field is shown, so the
729
+ growth rule gives the form room for those lines when an output arrives, and takes it back when the field hides.
730
+ """
731
+ self.output_edit.setFont(body_sized_fixed_font())
732
+ for control in (self.server_edit, self.service_name_edit, self.username_edit, self.check_url_edit, self.password_edit, self.bitbucket_email_edit, self.add_button,
733
+ self.browser_button, self.token_link_button, self.cancel_button, self.copy_output_button):
734
+ control.setMinimumHeight(control_height(control.fontMetrics().height()))
735
+ document_margin = math.ceil(self.output_edit.document().documentMargin())
736
+ output_viewport_height = self.output_edit.fontMetrics().lineSpacing() * ACCOUNT_OUTPUT_LINE_COUNT + DOCUMENT_MARGIN_SIDE_COUNT * document_margin
737
+ self.output_edit.setMinimumHeight(output_viewport_height + FRAME_SIDE_COUNT * self.output_edit.frameWidth())
738
+ fit_window(self, ADD_ACCOUNT_MIN_WIDTH, ADD_ACCOUNT_MIN_HEIGHT, available)
739
+
740
+ def _show_output(self, text: str) -> None:
741
+ """Show the whole output in its field, scrolled to its end where a program writes its error, with Copy details under it; an empty text hides both.
742
+
743
+ The window follows the field both ways through the common growth rule: it grows, inside the usable area, until
744
+ the field shows ACCOUNT_OUTPUT_LINE_COUNT lines, the rest of the output scrolling, and takes back its height when
745
+ the field hides. While the field is shown, the window cannot be made shorter than its rows with those
746
+ ACCOUNT_OUTPUT_LINE_COUNT lines: the field asks them as its minimum height, and Qt keeps a window at its layout's
747
+ minimum. Above that, a height the user set by hand stays as long as the content asks for no more at the same width.
748
+ """
749
+ if not text:
750
+ self._hide_output()
751
+ return
752
+ # The field is shown before its text is set: a text set on a field that has no size yet stays scrolled to its
753
+ # middle, whereas once shown, moving the cursor to the end keeps the view there, even when the field grows.
754
+ self.output_edit.show()
755
+ self.copy_output_button.show()
756
+ self.output_text = text
757
+ self.output_edit.setPlainText(text)
758
+ self.output_edit.moveCursor(QTextCursor.MoveOperation.End)
759
+ after_compositor_answer(self, self._scroll_output_to_end)
760
+
761
+ def _keyring_state_answered(self, read_number: int, reading: KeyringReading) -> None:
762
+ """Pass the keyring reading of the latest read to what the sign-in asked, once, unless the window closed meanwhile."""
763
+ if read_number != self.keyring_read_number:
764
+ return
765
+ answer = self.keyring_answer
766
+ self.keyring_answer = None
767
+ if answer is None or self.closed:
768
+ return
769
+ answer(reading)
770
+
771
+ def _scroll_output_to_end(self) -> None:
772
+ """Put the view back on the end of the output once the form has grown, where a program writes its error.
773
+
774
+ Under Wayland the growth waits for the compositor's answer to the show; the form may be gone or the field hidden
775
+ by a new try by then, and nothing is done.
776
+ """
777
+ if not isValid(self) or not self.output_edit.isVisibleTo(self):
778
+ return
779
+ self.layout().activate()
780
+ self.output_edit.moveCursor(QTextCursor.MoveOperation.End)
781
+
782
+ def _hide_output(self) -> None:
783
+ """Empty and hide the output field and its Copy details button, for a new try or a result without output."""
784
+ self.credential_manager_refusal_shown = False
785
+ self.output_edit.clear()
786
+ self.output_edit.hide()
787
+ self.copy_output_button.hide()
788
+ self.output_text = ""
789
+
790
+ def _generic_reconnect(self) -> bool:
791
+ """Tell whether this window renews a username + password account."""
792
+ return self.reconnect is not None and self.reconnect.provider == GENERIC_PROVIDER
793
+
794
+ def _origin(self) -> str:
795
+ """Return the service address this window is about in its stored form, or an empty text while the typed server is not valid."""
796
+ if self.reconnect is not None:
797
+ return self.reconnect.origin
798
+ try:
799
+ return endpoint_origin(self.server_url())
800
+ except ValueError:
801
+ return ""
802
+
803
+ def _recognised_browser_provider(self) -> str | None:
804
+ """Name the provider whose browser sign-in the server offers, as recognised after a refused test of a new account, else None.
805
+
806
+ A Reconnect keeps its username + password account, so it never switches to a browser account.
807
+ """
808
+ if self.reconnect is not None or self.refused_check is None or not self.refused_check.browser_provider:
809
+ return None
810
+ return self.refused_check.browser_provider
811
+
812
+ def _address_provider(self) -> str | None:
813
+ """Name the online service at exactly the typed address, whatever kind of account is being added or renewed."""
814
+ try:
815
+ origin = endpoint_origin(self.server_url())
816
+ except ValueError:
817
+ return None
818
+ return provider_for_browser_origin(origin)
819
+
820
+ def _wait_text(self) -> str:
821
+ """Say what a sign-in with a typed secret waits for: the check of a service token, or the Git test of a password."""
822
+ if self.checks_token():
823
+ return TOKEN_WAIT_TEXT
824
+ return CHECKING_TEXT.format(address=self.address())
825
+
826
+ def _set_browser_waiting(self, waiting: bool) -> None:
827
+ """Start or end the browser wait: the countdown and the turning indicator."""
828
+ self.browser_waiting = waiting
829
+ if not waiting:
830
+ self.countdown_timer.stop()
831
+ self.busy_indicator.stop()
832
+ return
833
+ self.browser_deadline = QDeadlineTimer(self.browser_timeout_ms)
834
+ self.countdown_timer.start()
835
+ self.busy_indicator.start()
836
+
837
+ def _show_countdown(self) -> None:
838
+ """Show in orange the time left before GitPane stops waiting for the browser, rounded up to the second and never below zero.
839
+
840
+ The countdown stops at zero; the indicator keeps turning until the account service reports the end of the wait.
841
+ """
842
+ remaining_seconds = math.ceil(self.browser_deadline.remainingTime() / MILLISECONDS_PER_SECOND)
843
+ if remaining_seconds <= 0:
844
+ remaining_seconds = 0
845
+ self.countdown_timer.stop()
846
+ minutes, seconds = divmod(remaining_seconds, SECONDS_PER_MINUTE)
847
+ self.status_label.show_warning(BROWSER_COUNTDOWN_TEXT.format(minutes=minutes, seconds=seconds))
848
+
849
+ def _apply_lock(self, reconnect: AccountSelection | None, locked_origin: str, service_name: str) -> None:
850
+ """Show and fix the server, service name and username of the renewed account, or only the server given by the caller."""
851
+ if reconnect is not None:
852
+ self.server_edit.setText(reconnect.origin)
853
+ self.service_name_edit.setText(service_name)
854
+ self.username_edit.setText(reconnect.label)
855
+ elif locked_origin:
856
+ self.server_edit.setText(locked_origin)
857
+ self.server_edit.setEnabled(not self.server_locked)
858
+ self.service_name_edit.setEnabled(not self.account_locked)
859
+ self.username_edit.setEnabled(not self.account_locked)
860
+
861
+ def _server_changed(self) -> None:
862
+ """Start over for a new address: back to its first way of signing in, without the secret typed for the previous one.
863
+
864
+ What the last try said stays: its red sentence and its output, which the user may be reading or copying, until
865
+ the next try or the window closes. Only the orange advice, which follows the way to sign in reset here, and a
866
+ neutral line said for the previous address go away.
867
+ """
868
+ self.token_mode = False
869
+ self.refused_check = None
870
+ self.password_edit.clear()
871
+ if self.status_label.tone is not ResultTone.FAILURE:
872
+ self.status_label.clear()
873
+ self._prefill_service_name()
874
+ self._update_presentation()
875
+
876
+ def _service_name_edited(self) -> None:
877
+ """Remember that the user typed the service name, so a later address change never replaces it."""
878
+ self.prefilled_service_name = ""
879
+
880
+ def _prefill_service_name(self) -> None:
881
+ """Propose the address's name: the one already saved for it, else the online service's own name.
882
+
883
+ Only an empty field or one still holding the previous proposal is written, so a name the user typed is kept; a
884
+ proposal left untouched goes away with the address it was made for.
885
+ """
886
+ if self.account_locked:
887
+ return
888
+ current = self.service_name()
889
+ if current and current != self.prefilled_service_name:
890
+ return
891
+ proposal = self._proposed_service_name()
892
+ self.service_name_edit.setText(proposal)
893
+ self.prefilled_service_name = proposal
894
+
895
+ def _proposed_service_name(self) -> str:
896
+ """Return the name already given to the typed address, the online service's name, or nothing for an unknown server."""
897
+ try:
898
+ origin = endpoint_origin(self.server_url())
899
+ except ValueError:
900
+ return ""
901
+ saved_name = self.service_name_by_origin.get(origin, "")
902
+ if saved_name:
903
+ return saved_name
904
+ return provider_for_browser_origin(origin) or ""
905
+
906
+ def _token_link_clicked(self) -> None:
907
+ """Switch from browser sign-in to a personal access token and move the focus to it.
908
+
909
+ A red line here can only come from a browser try, since the link is hidden once a secret field is shown: the
910
+ click leaves that way to sign in, so what its try said (the red line, the keyring sentence and the credential
911
+ manager's output) no longer applies to the token and goes away. An orange advice is what pointed to this link,
912
+ after a server refused an account password: it stays, with Git's output that explains the refusal. The link is
913
+ disabled while a try runs; any keyring answer still awaited is dropped anyway, as Cancel does, with the refused
914
+ store it was read for, so it can never show a sentence on a form that has moved on.
915
+ """
916
+ self.forget_keyring_answer()
917
+ self.refused_outcome = None
918
+ if self.status_label.tone is ResultTone.FAILURE:
919
+ self._hide_output()
920
+ self.show_keyring_note(UNKNOWN_KEYRING_READING)
921
+ self.status_label.clear()
922
+ self.token_mode = True
923
+ self._update_presentation()
924
+ self.password_edit.setFocus()
925
+
926
+ def _update_presentation(self) -> None:
927
+ """Show only the fields and buttons of the way to sign in that fits the address and the account being renewed."""
928
+ browser_offered = self.browser_provider() is not None
929
+ # After a plain refusal the password may just be mistyped, so its field stays next to the browser offered by
930
+ # the recognised server; only a server that refuses passwords puts the browser first.
931
+ password_kept = self._recognised_browser_provider() is not None and not passwords_refused(self.refused_check)
932
+ secret_visible = not browser_offered or self.token_mode or password_kept
933
+ self.browser_button.setVisible(browser_offered)
934
+ self.browser_button.setText(BROWSER_INSTEAD_BUTTON_TEXT if secret_visible else BROWSER_BUTTON_TEXT)
935
+ self.token_link_button.setVisible(browser_offered and not secret_visible)
936
+ self.form.setRowVisible(self.username_edit, self.asks_username())
937
+ known_check_url = self.known_check_url()
938
+ self.form.setRowVisible(self.check_url_edit, self.asks_check_url())
939
+ self.form.setRowVisible(self.check_note_label, bool(known_check_url))
940
+ self.check_note_label.setText(CHECK_NOTE_TEXT.format(url=known_check_url) if known_check_url else "")
941
+ self.form.setRowVisible(self.password_edit, secret_visible)
942
+ self.form.setRowVisible(self.bitbucket_email_edit, secret_visible and self.asks_bitbucket_email())
943
+ self.add_button.setVisible(secret_visible)
944
+ secret_label = TOKEN_LABEL if self.asks_token() else PASSWORD_LABEL
945
+ self.password_label.setText(secret_label)
946
+ self.password_edit.setAccessibleName(secret_label)
947
+ token_page_text = self._token_page_text()
948
+ self.token_page_label.setText(token_page_text)
949
+ self.form.setRowVisible(self.token_page_label, secret_visible and bool(token_page_text))
950
+ self.self_hosted_label.setVisible(not browser_offered and not self._generic_reconnect() and self._address_valid())
951
+ self.help_label.setText(self._help_text())
952
+ self.browser_button.setDefault(not secret_visible)
953
+ self.add_button.setDefault(secret_visible)
954
+
955
+ def _help_text(self) -> str:
956
+ """Explain the way to sign in shown: why the online services need a browser or a token, or what a password account is."""
957
+ if self._address_provider() is not None:
958
+ return CLOUD_HELP_TEXT
959
+ if self.checks_token():
960
+ return TOKEN_HELP_TEXT
961
+ return SERVER_HELP_TEXT + HELP_TEXT_SEPARATOR + PASSWORD_HELP_TEXT
962
+
963
+ def _token_page_text(self) -> str:
964
+ """Say where to create a personal access token once the server refused the typed password for Git; empty otherwise.
965
+
966
+ The page is named when the server was recognised; a server that refuses passwords but was not recognised gets
967
+ the general advice.
968
+ """
969
+ check = self.refused_check
970
+ if check is None:
971
+ return ""
972
+ if check.token_page_url:
973
+ return TOKEN_PAGE_TEXT.format(url=check.token_page_url)
974
+ if passwords_refused(check):
975
+ return TOKEN_PAGE_GENERIC_TEXT
976
+ return ""
977
+
978
+ def _address_valid(self) -> bool:
979
+ """Tell whether the typed server is an address the service would accept."""
980
+ try:
981
+ endpoint_origin(self.server_url())
982
+ except ValueError:
983
+ return False
984
+ return True
985
+
986
+ def _cancel_clicked(self) -> None:
987
+ """Cancel only the running sign-in and keep the window open; with nothing running, close the window."""
988
+ if self.running:
989
+ self.cancel_requested.emit()
990
+ return
991
+ self.reject()
992
+
993
+ def _line_edit(self, accessible_name: str, placeholder: str) -> QLineEdit:
994
+ """Create one form field whose accessible name matches its visible label."""
995
+ edit = QLineEdit()
996
+ edit.setAccessibleName(accessible_name)
997
+ edit.setPlaceholderText(placeholder)
998
+ return edit
999
+
1000
+ def _plain_label(self, text: str) -> QLabel:
1001
+ """Create a wrapping label that shows account and diagnostic text literally."""
1002
+ label = QLabel(text)
1003
+ label.setTextFormat(Qt.TextFormat.PlainText)
1004
+ label.setWordWrap(True)
1005
+ return label