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,724 @@
1
+ """Show the Accounts page of Settings, from the list of connected service accounts to the account each address uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QEvent, Qt, Signal
9
+ from PySide6.QtGui import QBrush, QColor, QFont
10
+ from PySide6.QtWidgets import (
11
+ QApplication,
12
+ QComboBox,
13
+ QGridLayout,
14
+ QGroupBox,
15
+ QHBoxLayout,
16
+ QHeaderView,
17
+ QLabel,
18
+ QMessageBox,
19
+ QPushButton,
20
+ QTreeWidget,
21
+ QTreeWidgetItem,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+ from shiboken6 import isValid
26
+
27
+ from gitpane.account.account_choice import CHOOSE_DEFAULT_TEXT, global_rows, offers_account, repository_account_view, service_text
28
+ from gitpane.account.authentication import AccountCatalogue, SignInMethod, origin_address
29
+ from gitpane.config import SPACING_MD, SPACING_SM
30
+ from gitpane.operation.operation_status import OperationStatus
31
+ from gitpane.ui.look.theme_color import is_dark_palette
32
+ from gitpane.ui.look.theme_look import PALETTE_EVENT_TYPE_SET
33
+ from gitpane.ui.setting.setting_block import GLOBAL_TITLE, REPOSITORY_TITLE, block_group
34
+ from gitpane.ui.widget.long_text import FRAME_SIDE_COUNT
35
+ from gitpane.ui.widget.plain_label import plain_label
36
+ from gitpane.ui.widget.result_label import ResultLabel, ResultTone, result_color
37
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
38
+
39
+ if TYPE_CHECKING:
40
+ from gitpane.account.account_choice import GlobalRow, RepositoryRow
41
+ from gitpane.account.authentication import AccountRemoval, AccountSelection, AuthOutcome
42
+ from gitpane.transport.remote_config import RemoteEntry
43
+ from gitpane.ui.setting.add_account_dialog import AddAccountDialog
44
+
45
+ SERVICE_COLUMN, ADDRESS_COLUMN, ACCOUNT_COLUMN, TYPE_COLUMN, STATUS_COLUMN = 0, 1, 2, 3, 4
46
+ ACCOUNT_COLUMN_TITLES = ("Service", "Address", "Account", "Type", "Status")
47
+ UNKNOWN_TYPE_TOOLTIP_TEXT = "This account was added before GitPane recorded how accounts sign in. Reconnect it to record it."
48
+ # Shown for an account whose stored secret is gone, typically erased by Git after a server refused it.
49
+ NEEDS_RECONNECT_STATUS_TEXT = "Needs reconnect"
50
+ NEEDS_RECONNECT_TOOLTIP_TEXT = "Its saved sign-in is no longer in the system credential store. Select it and click Reconnect."
51
+ # Shown for an account whose stored secret a check of the system credential store found.
52
+ OK_STATUS_TEXT = "OK"
53
+ # Every text the Status column can show; it keeps the width of the widest one so it never jumps or hides when a check answers.
54
+ STATUS_TEXTS = (OK_STATUS_TEXT, NEEDS_RECONNECT_STATUS_TEXT)
55
+ # Columns as wide as their content; Address takes the rest of the list and cuts a long address with an ellipsis.
56
+ CONTENT_WIDTH_COLUMNS = (SERVICE_COLUMN, ACCOUNT_COLUMN, TYPE_COLUMN)
57
+ # The single column of the hidden list that measures the Status texts.
58
+ PROBE_COLUMN = 0
59
+ ACCOUNT_LIST_STRETCH = 1
60
+ EMPTY_ACCOUNT_LIST_TEXT = "No connected accounts yet. Use Add account… to connect one."
61
+ ACCOUNTS_PAGE_NAME = "Accounts"
62
+ PLACE_SEPARATOR = " › "
63
+ UNSAVED_MARK = " *"
64
+ EMPTY_GLOBAL_TEXT = "No connected accounts yet. Use Add account… above."
65
+ GLOBAL_SAVED_TEXT = "Global settings saved."
66
+ NO_REPOSITORY_ACCOUNT_TEXT = "Open a repository to choose the account it uses."
67
+ READING_REMOTES_TEXT = "Reading this repository's remotes…"
68
+ NO_HTTPS_REMOTE_TEXT = "This repository has no HTTPS remote."
69
+ MISSING_CHOICE_TEXT = "The account chosen for this repository is no longer connected. Choose another account."
70
+ SUBMODULE_HELP_TEXT = "This account is used for this repository only. Submodules use their own setting, or the global default for their service."
71
+ REPOSITORY_SAVED_TEXT = "Repository settings saved."
72
+ CREDENTIAL_MANAGER_GROUP_TITLE = "Git credential manager"
73
+ # Shown in the credential manager line while the search started by the opening of Settings runs.
74
+ CREDENTIAL_MANAGER_CHECKING_TEXT = "Checking…"
75
+ SIGN_IN_PLACE_NAME = "Sign-in in progress"
76
+ ROW_LABEL_COLUMN, ROW_CHOICE_COLUMN = 0, 1
77
+ ROW_COLUMN_COUNT = 2
78
+ ROW_SPAN = 1
79
+ ROW_CHOICE_STRETCH = 1
80
+ NO_SELECTION_INDEX = -1
81
+ FOLLOW_DEFAULT_INDEX = 0
82
+ FIRST_LAYOUT_ITEM_INDEX = 0
83
+ EMPTY_CATALOGUE = AccountCatalogue((), {}, {})
84
+ REMOVE_ACCOUNT_BUTTON_TEXT = "Remove account…"
85
+ REMOVE_TITLE = "Remove account"
86
+ REMOVE_QUESTION_TEXT = "Remove {label} ({address}) from Connected accounts?"
87
+ REMOVE_DETAIL_TEXT = ("Its stored password or token is erased from this computer. Repositories and their files do not change. "
88
+ "Where this account was the default or a repository's choice, GitPane will ask for another account.")
89
+ REMOVE_CONFIRM_TEXT = "Remove account"
90
+ KEEP_ACCOUNT_TEXT = "Keep account"
91
+ REMOVING_TEXT = "Removing {label}…"
92
+ REMOVED_TEXT = "{label} was removed from Connected accounts."
93
+ DEFAULT_CLEARED_TEXT = " Choose a new default account for {address} in Global."
94
+ REPOSITORY_CHOICE_CLEARED_TEXT = " Repositories that used it must choose another account."
95
+ NOT_ERASED_TEXT = "{label} was not removed: its stored password or token could not be erased. "
96
+ # Start of the Accounts page result when the removal was refused before anything was erased.
97
+ ACCOUNT_NOT_REMOVED_TEXT = "{label} was not removed. "
98
+ LIST_NOT_SAVED_TEXT = "The stored password or token of {label} was erased, but Connected accounts could not be saved. "
99
+
100
+
101
+ def removal_result_text(selection: AccountSelection, removal: AccountRemoval) -> str:
102
+ """Say that the account is gone and what the user now has to choose again.
103
+
104
+ A cleared default is only worth mentioning while its address still has an account to choose; the next account added
105
+ there becomes its default by itself.
106
+ """
107
+ text = REMOVED_TEXT.format(label=selection.label)
108
+ if removal.default_cleared and removal.address_has_account:
109
+ text += DEFAULT_CLEARED_TEXT.format(address=origin_address(selection.origin))
110
+ if removal.repository_choice_count:
111
+ text += REPOSITORY_CHOICE_CLEARED_TEXT
112
+ return text
113
+
114
+
115
+ def removal_failure_text(selection: AccountSelection, outcome: AuthOutcome) -> str:
116
+ """Say which step of a removal failed, followed by the prepared cause, never the raw helper output.
117
+
118
+ A removal refused before anything ran, for example while a sign-in is still running or when the credential manager
119
+ found cannot be used, erased nothing, so only the service's reason is worth showing.
120
+ """
121
+ cause = outcome.diagnostic.cause if outcome.diagnostic is not None and outcome.diagnostic.cause else outcome.status.value
122
+ if outcome.status == OperationStatus.PARTIAL:
123
+ return LIST_NOT_SAVED_TEXT.format(label=selection.label) + cause
124
+ if outcome.refused_before_start:
125
+ return ACCOUNT_NOT_REMOVED_TEXT.format(label=selection.label) + cause
126
+ return NOT_ERASED_TEXT.format(label=selection.label) + cause
127
+
128
+
129
+ def fill_global_combo(combo: QComboBox, row: GlobalRow, selected_namespace: str | None) -> None:
130
+ """Offer every account of the row's address and show the chosen one; with none chosen the menu stays empty and asks for a choice.
131
+
132
+ There is no 'no default' entry: a default can only be replaced by another account. Signals are blocked so filling
133
+ the menu is never taken for a user choice.
134
+ """
135
+ combo.blockSignals(True)
136
+ combo.clear()
137
+ combo.setPlaceholderText(CHOOSE_DEFAULT_TEXT)
138
+ selected_index = NO_SELECTION_INDEX
139
+ for choice in row.choices:
140
+ combo.addItem(choice.text, choice.namespace)
141
+ if choice.namespace == selected_namespace:
142
+ selected_index = combo.count() - 1
143
+ combo.setCurrentIndex(selected_index)
144
+ combo.blockSignals(False)
145
+
146
+
147
+ def fill_repository_combo(combo: QComboBox, row: RepositoryRow, selected_namespace: str | None) -> None:
148
+ """Offer 'Use global default — …' first, then every account of the row's address, and show the chosen one.
149
+
150
+ The first entry carries no account, so choosing it means following the Global default. An address without any
151
+ connected account leaves only that entry, which then explains how to add one, in a disabled menu. Signals are
152
+ blocked so filling the menu is never taken for a user choice.
153
+ """
154
+ combo.blockSignals(True)
155
+ combo.clear()
156
+ combo.addItem(row.default_text, None)
157
+ selected_index = FOLLOW_DEFAULT_INDEX
158
+ for choice in row.choices:
159
+ combo.addItem(choice.text, choice.namespace)
160
+ if choice.namespace == selected_namespace:
161
+ selected_index = combo.count() - 1
162
+ combo.setCurrentIndex(selected_index)
163
+ combo.setEnabled(bool(row.choices))
164
+ combo.blockSignals(False)
165
+
166
+
167
+ def repository_draft_needed(row: RepositoryRow, namespace: str | None) -> bool:
168
+ """Tell whether choosing this account for the row leaves something to save.
169
+
170
+ A saved choice naming an account no longer listed must be replaced, so any choice on such a row is worth saving.
171
+ """
172
+ return row.missing_choice or namespace != row.chosen_namespace
173
+
174
+
175
+ def clear_grid(grid: QGridLayout) -> None:
176
+ """Remove and delete every widget of a grid so its rows can be built again."""
177
+ while grid.count():
178
+ item = grid.takeAt(FIRST_LAYOUT_ITEM_INDEX)
179
+ widget = item.widget()
180
+ if widget is not None:
181
+ widget.setParent(None)
182
+ widget.deleteLater()
183
+
184
+
185
+ def row_grid() -> QGridLayout:
186
+ """Build the two-column grid of a choice block: the address on the left, its menu on the right."""
187
+ grid = QGridLayout()
188
+ grid.setHorizontalSpacing(SPACING_MD)
189
+ grid.setVerticalSpacing(SPACING_SM)
190
+ grid.setColumnStretch(ROW_CHOICE_COLUMN, ROW_CHOICE_STRETCH)
191
+ return grid
192
+
193
+
194
+ class AccountPage(QWidget):
195
+ """List every connected account, let the user choose the accounts used, and ask the account controller to save; no account rule lives here.
196
+
197
+ Each choice block keeps a draft of the rows the user changed. A refresh rebuilds every row but shows the draft for a
198
+ changed row, so a new account, an automatic default or a write from another window never erases what the user chose.
199
+ """
200
+
201
+ add_account_requested = Signal()
202
+ reconnect_requested = Signal()
203
+ remove_account_requested = Signal(str)
204
+ save_global_requested = Signal()
205
+ save_repository_requested = Signal()
206
+
207
+ def __init__(self, repository_dir: str | None, parent: QWidget | None = None) -> None:
208
+ """Build the Connected accounts list and the choice blocks; ``repository_dir`` is the repository these settings were opened for, if any."""
209
+ super().__init__(parent)
210
+ self.repository_dir = repository_dir
211
+ self.sign_in_dialog: AddAccountDialog | None = None
212
+ self.remove_confirmation: QMessageBox | None = None
213
+ self.removing = False
214
+ self.catalogue = EMPTY_CATALOGUE
215
+ # Accounts whose stored secret a check found. Status says OK only for them: a check after a refused transfer
216
+ # covers a single account, so an answer says nothing about the accounts it did not check.
217
+ self.secret_found_namespace_set: set[str] = set()
218
+ self.global_rows: tuple[GlobalRow, ...] = ()
219
+ self.global_combo_by_origin: dict[str, QComboBox] = {}
220
+ self.global_label_by_origin: dict[str, QLabel] = {}
221
+ self.global_draft_namespace_by_origin: dict[str, str] = {}
222
+ # None until the repository's remotes have been read; the account controller bumps the generation for each new read.
223
+ self.remote_entries: tuple[RemoteEntry, ...] | None = None
224
+ self.remote_generation = 0
225
+ self.repository_rows: tuple[RepositoryRow, ...] = ()
226
+ self.repository_ssh_notes: tuple[str, ...] = ()
227
+ self.repository_combo_by_origin: dict[str, QComboBox] = {}
228
+ self.repository_label_by_origin: dict[str, QLabel] = {}
229
+ # None as a draft value means the row follows the Global default.
230
+ self.repository_draft_namespace_by_origin: dict[str, str | None] = {}
231
+ layout = QVBoxLayout(self)
232
+ layout.setContentsMargins(0, 0, 0, 0)
233
+ layout.setSpacing(SPACING_SM)
234
+ # Above the list, so the reason Settings opened by itself is the first thing read.
235
+ self.account_notice_label = ResultLabel()
236
+ self.account_notice_label.setVisible(False)
237
+ self.account_notice_address = ""
238
+ layout.addWidget(self.account_notice_label)
239
+ self.account_group = QGroupBox("Connected accounts")
240
+ body = QHBoxLayout(self.account_group)
241
+ body.setSpacing(SPACING_SM)
242
+ self.account_tree = QTreeWidget()
243
+ self.account_tree.setColumnCount(len(ACCOUNT_COLUMN_TITLES))
244
+ self.account_tree.setHeaderLabels(list(ACCOUNT_COLUMN_TITLES))
245
+ self.account_tree.setRootIsDecorated(False)
246
+ self.account_tree.setAccessibleName("Connected accounts")
247
+ header = self.account_tree.header()
248
+ header.setStretchLastSection(False)
249
+ for column in CONTENT_WIDTH_COLUMNS:
250
+ header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents)
251
+ header.setSectionResizeMode(ADDRESS_COLUMN, QHeaderView.ResizeMode.Stretch)
252
+ header.setSectionResizeMode(STATUS_COLUMN, QHeaderView.ResizeMode.Fixed)
253
+ self._fit_status_column()
254
+ body.addWidget(self.account_tree, ACCOUNT_LIST_STRETCH)
255
+ tools = QVBoxLayout()
256
+ self.add_account_button = QPushButton("Add account…")
257
+ self.add_account_button.setAutoDefault(False)
258
+ self.add_account_button.clicked.connect(self.add_account_requested)
259
+ self.reconnect_button = QPushButton("Reconnect")
260
+ self.reconnect_button.setAutoDefault(False)
261
+ self.reconnect_button.setEnabled(False)
262
+ self.reconnect_button.clicked.connect(self.reconnect_requested)
263
+ self.remove_account_button = QPushButton(REMOVE_ACCOUNT_BUTTON_TEXT)
264
+ self.remove_account_button.setAutoDefault(False)
265
+ self.remove_account_button.setEnabled(False)
266
+ self.remove_account_button.clicked.connect(self._confirm_removal)
267
+ tools.addWidget(self.add_account_button)
268
+ tools.addWidget(self.reconnect_button)
269
+ tools.addWidget(self.remove_account_button)
270
+ tools.addStretch()
271
+ body.addLayout(tools)
272
+ layout.addWidget(self.account_group)
273
+ self.status_label = ResultLabel()
274
+ layout.addWidget(self.status_label)
275
+ self.global_group, global_layout = block_group(GLOBAL_TITLE)
276
+ global_layout.addWidget(plain_label("Default account of each service address, used by every repository that does not choose its own."))
277
+ self.global_grid = row_grid()
278
+ global_layout.addLayout(self.global_grid)
279
+ self.global_empty_label = plain_label(EMPTY_GLOBAL_TEXT)
280
+ global_layout.addWidget(self.global_empty_label)
281
+ self.global_status_label = ResultLabel()
282
+ global_layout.addWidget(self.global_status_label)
283
+ self.save_global_button = QPushButton("Save global settings")
284
+ self.save_global_button.setAutoDefault(False)
285
+ self.save_global_button.clicked.connect(self.save_global_requested)
286
+ global_layout.addWidget(self.save_global_button, alignment=Qt.AlignmentFlag.AlignLeft)
287
+ layout.addWidget(self.global_group)
288
+ self.repository_group, repository_layout = block_group(REPOSITORY_TITLE)
289
+ self.repository_path_label = plain_label(repository_dir if repository_dir is not None else NO_REPOSITORY_ACCOUNT_TEXT)
290
+ repository_layout.addWidget(self.repository_path_label)
291
+ self.repository_remote_label = plain_label(READING_REMOTES_TEXT if repository_dir is not None else "")
292
+ repository_layout.addWidget(self.repository_remote_label)
293
+ self.repository_grid = row_grid()
294
+ repository_layout.addLayout(self.repository_grid)
295
+ self.repository_help_label = plain_label(SUBMODULE_HELP_TEXT)
296
+ repository_layout.addWidget(self.repository_help_label)
297
+ self.repository_status_label = ResultLabel()
298
+ repository_layout.addWidget(self.repository_status_label)
299
+ self.save_repository_button = QPushButton("Save this repository settings")
300
+ self.save_repository_button.setAutoDefault(False)
301
+ self.save_repository_button.clicked.connect(self.save_repository_requested)
302
+ repository_layout.addWidget(self.save_repository_button, alignment=Qt.AlignmentFlag.AlignLeft)
303
+ self.repository_group.setEnabled(repository_dir is not None)
304
+ layout.addWidget(self.repository_group)
305
+ # Last block, so the accounts come first. Read only and without any button: opening Settings again searches again.
306
+ self.credential_manager_group, credential_manager_layout = block_group(CREDENTIAL_MANAGER_GROUP_TITLE)
307
+ self.credential_manager_label = ResultLabel()
308
+ credential_manager_layout.addWidget(self.credential_manager_label)
309
+ layout.addWidget(self.credential_manager_group)
310
+ layout.addStretch()
311
+ self.account_tree.itemSelectionChanged.connect(self._selection_changed)
312
+ self._render_global()
313
+ self._refresh_block_state()
314
+
315
+ def show_credential_manager_checking(self) -> None:
316
+ """Say, in the ordinary tone, that the search for the credential manager is still running."""
317
+ self.credential_manager_label.show_result(CREDENTIAL_MANAGER_CHECKING_TEXT, ResultTone.NEUTRAL)
318
+
319
+ def show_credential_manager(self, text: str, refused: bool) -> None:
320
+ """Show which credential manager GitPane uses in the ordinary tone, or why none is usable in red, like the red line of a refused operation."""
321
+ if refused:
322
+ self.credential_manager_label.show_failure(text)
323
+ return
324
+ self.credential_manager_label.show_result(text, ResultTone.NEUTRAL)
325
+
326
+ def show_account_notice(self, text: str, address: str) -> None:
327
+ """Show in orange, above Connected accounts, why an operation needs an account here; a newer refusal replaces an older one.
328
+
329
+ ``address`` is kept so that only a sign-in on that same address clears the notice.
330
+ """
331
+ self.account_notice_address = address
332
+ self.account_notice_label.show_warning(text)
333
+ self.account_notice_label.setVisible(True)
334
+
335
+ def clear_account_notice(self, address: str = "") -> None:
336
+ """Hide the refusal notice once the account it asked for is connected; an empty address clears any notice.
337
+
338
+ Host names are compared regardless of case, since a typed URL keeps whatever case the user wrote.
339
+ """
340
+ if address and address.casefold() != self.account_notice_address.casefold():
341
+ return
342
+ self.account_notice_address = ""
343
+ self.account_notice_label.clear()
344
+ self.account_notice_label.setVisible(False)
345
+
346
+ def show_catalogue(self, catalogue: AccountCatalogue) -> None:
347
+ """Show a fresh read of the accounts: the list in the catalogue's order and every choice row, keeping the rows the user changed.
348
+
349
+ The Type column says how each account signs in: browser, token, or username + password.
350
+ """
351
+ self.catalogue = catalogue
352
+ selected_namespace = self.selected_namespace()
353
+ self.account_tree.clear()
354
+ selected_item = None
355
+ for selection in catalogue.accounts:
356
+ item = QTreeWidgetItem()
357
+ item.setText(SERVICE_COLUMN, service_text(catalogue, selection))
358
+ item.setText(ADDRESS_COLUMN, origin_address(selection.origin))
359
+ item.setText(ACCOUNT_COLUMN, selection.label)
360
+ sign_in_method = catalogue.sign_in_method_by_namespace.get(selection.namespace, SignInMethod.BROWSER_OR_TOKEN)
361
+ item.setText(TYPE_COLUMN, sign_in_method.type_label)
362
+ if sign_in_method == SignInMethod.BROWSER_OR_TOKEN:
363
+ item.setToolTip(TYPE_COLUMN, UNKNOWN_TYPE_TOOLTIP_TEXT)
364
+ item.setData(SERVICE_COLUMN, Qt.ItemDataRole.UserRole, selection.namespace)
365
+ self.account_tree.addTopLevelItem(item)
366
+ if selection.namespace == selected_namespace:
367
+ selected_item = item
368
+ self._render_status()
369
+ if selected_item is not None:
370
+ self.account_tree.setCurrentItem(selected_item)
371
+ if not catalogue.accounts:
372
+ self.status_label.setText(EMPTY_ACCOUNT_LIST_TEXT)
373
+ elif self.status_label.text() == EMPTY_ACCOUNT_LIST_TEXT:
374
+ self.status_label.clear()
375
+ self._selection_changed()
376
+ self._render_global()
377
+ self._render_repository()
378
+ self._refresh_block_state()
379
+
380
+ def show_needs_reconnect(self, namespace_set: frozenset[str], present_namespace_set: frozenset[str] = frozenset()) -> None:
381
+ """Show, in the Status column only, the answer of a check of the stored secrets; every other row and block stays as it is.
382
+
383
+ ``namespace_set`` names every listed account known to need a reconnect; ``present_namespace_set`` the accounts
384
+ this check found with their secret, which then say OK. Used when the check answers after the list was shown, so
385
+ the choice blocks and their open menus are never rebuilt under the user.
386
+ """
387
+ self.catalogue = replace(self.catalogue, needs_reconnect_namespace_set=frozenset(namespace_set))
388
+ self.secret_found_namespace_set -= namespace_set
389
+ self.secret_found_namespace_set |= present_namespace_set - namespace_set
390
+ self._render_status()
391
+
392
+ def changeEvent(self, event: QEvent) -> None:
393
+ """Pick the light or dark colours of the Status column again when the theme changes, and its width when the text size does."""
394
+ super().changeEvent(event)
395
+ if event.type() in PALETTE_EVENT_TYPE_SET:
396
+ self._render_status()
397
+ # A style or font change can arrive while the base class is still being built, before the list exists.
398
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(self, "account_tree"):
399
+ self._fit_status_column()
400
+
401
+ def _render_status(self) -> None:
402
+ """Write the Status column: ``Needs reconnect`` in bold error colour for an account whose secret is gone, ``OK`` in the success colour for one a check found, empty otherwise.
403
+
404
+ Nothing is said before a check answered for the account: an empty cell never claims the account works.
405
+ """
406
+ dark = is_dark_palette(QApplication.palette())
407
+ for index in range(self.account_tree.topLevelItemCount()):
408
+ item = self.account_tree.topLevelItem(index)
409
+ namespace = item.data(SERVICE_COLUMN, Qt.ItemDataRole.UserRole)
410
+ font = item.font(STATUS_COLUMN)
411
+ if namespace in self.catalogue.needs_reconnect_namespace_set:
412
+ item.setText(STATUS_COLUMN, NEEDS_RECONNECT_STATUS_TEXT)
413
+ item.setToolTip(STATUS_COLUMN, NEEDS_RECONNECT_TOOLTIP_TEXT)
414
+ item.setForeground(STATUS_COLUMN, QBrush(QColor(result_color(ResultTone.FAILURE, dark))))
415
+ font.setBold(True)
416
+ item.setFont(STATUS_COLUMN, font)
417
+ continue
418
+ font.setBold(False)
419
+ item.setFont(STATUS_COLUMN, font)
420
+ item.setToolTip(STATUS_COLUMN, "")
421
+ if namespace in self.secret_found_namespace_set:
422
+ item.setText(STATUS_COLUMN, OK_STATUS_TEXT)
423
+ item.setForeground(STATUS_COLUMN, QBrush(QColor(result_color(ResultTone.SUCCESS, dark))))
424
+ continue
425
+ item.setText(STATUS_COLUMN, "")
426
+ item.setData(STATUS_COLUMN, Qt.ItemDataRole.ForegroundRole, None)
427
+
428
+ def _fit_status_column(self) -> None:
429
+ """Give the Status column the width of its title or of its widest possible text, in bold, so a check's answer never widens it.
430
+
431
+ The texts are measured in a hidden one-column list with the same font and style, which gives the width the
432
+ list's own cells need, margins included.
433
+ """
434
+ tree = self.account_tree
435
+ probe = QTreeWidget()
436
+ probe.setFont(tree.font())
437
+ probe.setStyle(tree.style())
438
+ probe.setColumnCount(1)
439
+ probe.setRootIsDecorated(False)
440
+ bold_font = QFont(tree.font())
441
+ bold_font.setBold(True)
442
+ for text in STATUS_TEXTS:
443
+ item = QTreeWidgetItem([text])
444
+ item.setFont(PROBE_COLUMN, bold_font)
445
+ probe.addTopLevelItem(item)
446
+ header = tree.header()
447
+ header.resizeSection(STATUS_COLUMN, max(header.sectionSizeHint(STATUS_COLUMN), probe.sizeHintForColumn(PROBE_COLUMN)))
448
+ probe.deleteLater()
449
+
450
+ def needed_width(self) -> int:
451
+ """Return the page width at which Connected accounts shows every column whole, Status included, without scrolling sideways.
452
+
453
+ Settings opens at least this wide, within the screen. The list is the only control of its block whose width
454
+ depends on the accounts, so the block needs its own minimum plus what the list's columns need beyond the list's minimum.
455
+ """
456
+ tree = self.account_tree
457
+ header = tree.header()
458
+ columns_width = header.sectionSize(STATUS_COLUMN)
459
+ for column in (*CONTENT_WIDTH_COLUMNS, ADDRESS_COLUMN):
460
+ columns_width += max(header.sectionSizeHint(column), tree.sizeHintForColumn(column))
461
+ tree_width = columns_width + tree.frameWidth() * FRAME_SIDE_COUNT
462
+ group_width = self.account_group.minimumSizeHint().width() - tree.minimumSizeHint().width() + tree_width
463
+ return max(self.minimumSizeHint().width(), group_width)
464
+
465
+ def show_remotes(self, entries: tuple[RemoteEntry, ...]) -> None:
466
+ """Show a fresh read of this repository's remotes as one row per HTTPS address, keeping the rows the user changed."""
467
+ self.remote_entries = tuple(entries)
468
+ self.repository_remote_label.clear()
469
+ self._render_repository()
470
+ self._refresh_block_state()
471
+ if not self.repository_rows and not self.repository_ssh_notes:
472
+ self.repository_remote_label.setText(NO_HTTPS_REMOTE_TEXT)
473
+
474
+ def show_remote_error(self, text: str) -> None:
475
+ """Explain why the remotes could not be read; rows already shown and the draft stay as they are."""
476
+ self.repository_remote_label.setText(text)
477
+
478
+ def repository_changes(self) -> dict[str, str | None]:
479
+ """Return the rows changed in This repository and not saved yet; None means the row follows the Global default again."""
480
+ return dict(self.repository_draft_namespace_by_origin)
481
+
482
+ def repository_saved(self) -> None:
483
+ """Forget the This repository draft after a successful save; the Global block keeps its own draft."""
484
+ self.repository_draft_namespace_by_origin.clear()
485
+ self._render_repository()
486
+ self._refresh_block_state()
487
+ self.repository_status_label.show_success(REPOSITORY_SAVED_TEXT)
488
+
489
+ def show_repository_error(self, text: str) -> None:
490
+ """Explain a failed This repository save inside the block, keeping the draft so the user can retry."""
491
+ self.repository_status_label.show_failure(text)
492
+
493
+ def unsaved_place_names(self) -> list[str]:
494
+ """Name the places of this page holding unsaved work, in page order, so closing or saving Settings can say what would be lost."""
495
+ place_names = []
496
+ if self.global_draft_namespace_by_origin:
497
+ place_names.append(ACCOUNTS_PAGE_NAME + PLACE_SEPARATOR + GLOBAL_TITLE)
498
+ if self.repository_draft_namespace_by_origin:
499
+ place_names.append(ACCOUNTS_PAGE_NAME + PLACE_SEPARATOR + REPOSITORY_TITLE)
500
+ if self.sign_in_dialog is not None and isValid(self.sign_in_dialog) and self.sign_in_dialog.running:
501
+ place_names.append(ACCOUNTS_PAGE_NAME + PLACE_SEPARATOR + SIGN_IN_PLACE_NAME)
502
+ return place_names
503
+
504
+ def selected_namespace(self) -> str | None:
505
+ """Return the store namespace of the selected account, or None when no row is selected."""
506
+ items = self.account_tree.selectedItems()
507
+ if not items:
508
+ return None
509
+ first_item_index = 0
510
+ return items[first_item_index].data(SERVICE_COLUMN, Qt.ItemDataRole.UserRole)
511
+
512
+ def show_message(self, text: str, tone: ResultTone = ResultTone.NEUTRAL) -> None:
513
+ """Show the result of the last account action under the list, in green when it worked and in red when it failed."""
514
+ self.status_label.show_result(text, tone)
515
+
516
+ def global_changes(self) -> dict[str, str]:
517
+ """Return the defaults changed in the Global block and not saved yet, by service address."""
518
+ return dict(self.global_draft_namespace_by_origin)
519
+
520
+ def global_saved(self) -> None:
521
+ """Forget the Global draft after a successful save; the other block keeps its own draft."""
522
+ self.global_draft_namespace_by_origin.clear()
523
+ self._render_global()
524
+ self._refresh_block_state()
525
+ self.global_status_label.show_success(GLOBAL_SAVED_TEXT)
526
+
527
+ def show_global_error(self, text: str) -> None:
528
+ """Explain a failed Global save inside the block, keeping the draft so the user can retry."""
529
+ self.global_status_label.show_failure(text)
530
+
531
+ def abandon_sign_in(self) -> None:
532
+ """Close the open sign-in window, if any, so no late result can reach it and its token is dropped."""
533
+ dialog = self.sign_in_dialog
534
+ self.sign_in_dialog = None
535
+ if dialog is None or not isValid(dialog):
536
+ return
537
+ dialog.abandon()
538
+ dialog.reject()
539
+
540
+ def _render_global(self) -> None:
541
+ """Build one row per service address; a changed row shows its draft, others the saved default.
542
+
543
+ A draft that now equals the saved default, or names an account no longer offered, is dropped: nothing is left to save.
544
+ """
545
+ rows = global_rows(self.catalogue)
546
+ row_by_origin = {}
547
+ for row in rows:
548
+ row_by_origin[row.origin] = row
549
+ for origin in tuple(self.global_draft_namespace_by_origin):
550
+ row = row_by_origin.get(origin)
551
+ namespace = self.global_draft_namespace_by_origin[origin]
552
+ if row is None or namespace == row.default_namespace or not offers_account(row.choices, namespace):
553
+ del self.global_draft_namespace_by_origin[origin]
554
+ clear_grid(self.global_grid)
555
+ self.global_rows = rows
556
+ self.global_combo_by_origin = {}
557
+ self.global_label_by_origin = {}
558
+ for row in rows:
559
+ label = plain_label(row.title)
560
+ combo = QComboBox()
561
+ combo.setAccessibleName("Default account for " + row.address)
562
+ label.setBuddy(combo)
563
+ fill_global_combo(combo, row, self.global_draft_namespace_by_origin.get(row.origin, row.default_namespace))
564
+ combo.activated.connect(lambda _index, origin=row.origin: self._global_chosen(origin))
565
+ grid_row = self.global_grid.rowCount()
566
+ self.global_grid.addWidget(label, grid_row, ROW_LABEL_COLUMN)
567
+ self.global_grid.addWidget(combo, grid_row, ROW_CHOICE_COLUMN)
568
+ self.global_label_by_origin[row.origin] = label
569
+ self.global_combo_by_origin[row.origin] = combo
570
+ self.global_empty_label.setVisible(not rows)
571
+
572
+ def _global_chosen(self, origin: str) -> None:
573
+ """Record the user's choice for one address; choosing the saved default again means nothing is left to save there."""
574
+ combo = self.global_combo_by_origin.get(origin)
575
+ row = self._global_row(origin)
576
+ if combo is None or row is None:
577
+ return
578
+ namespace = combo.currentData()
579
+ if namespace is None or namespace == row.default_namespace:
580
+ self.global_draft_namespace_by_origin.pop(origin, None)
581
+ else:
582
+ self.global_draft_namespace_by_origin[origin] = namespace
583
+ self.global_status_label.clear()
584
+ self._refresh_block_state()
585
+
586
+ def _global_row(self, origin: str) -> GlobalRow | None:
587
+ """Find the displayed Global row of this address."""
588
+ for row in self.global_rows:
589
+ if row.origin == origin:
590
+ return row
591
+ return None
592
+
593
+ def _render_repository(self) -> None:
594
+ """Build one row per HTTPS address of the remotes and one note per SSH remote; a changed row shows its draft.
595
+
596
+ Nothing is built before the remotes have been read. A draft whose address left the remotes, that names an account
597
+ no longer offered, or that now equals the saved choice is dropped: nothing is left to save there.
598
+ """
599
+ clear_grid(self.repository_grid)
600
+ self.repository_combo_by_origin = {}
601
+ self.repository_label_by_origin = {}
602
+ if self.remote_entries is None:
603
+ self.repository_rows = ()
604
+ self.repository_ssh_notes = ()
605
+ return
606
+ view = repository_account_view(self.catalogue, self.remote_entries)
607
+ row_by_origin = {}
608
+ for row in view.rows:
609
+ row_by_origin[row.origin] = row
610
+ for origin in tuple(self.repository_draft_namespace_by_origin):
611
+ row = row_by_origin.get(origin)
612
+ namespace = self.repository_draft_namespace_by_origin[origin]
613
+ if row is None or (namespace is not None and not offers_account(row.choices, namespace)) or not repository_draft_needed(row, namespace):
614
+ del self.repository_draft_namespace_by_origin[origin]
615
+ self.repository_rows = view.rows
616
+ self.repository_ssh_notes = view.ssh_notes
617
+ for row in view.rows:
618
+ label = plain_label(row.title)
619
+ combo = QComboBox()
620
+ combo.setAccessibleName("Account for " + row.address + " in this repository")
621
+ label.setBuddy(combo)
622
+ drafted = row.origin in self.repository_draft_namespace_by_origin
623
+ fill_repository_combo(combo, row, self.repository_draft_namespace_by_origin[row.origin] if drafted else row.chosen_namespace)
624
+ if row.missing_choice and not drafted:
625
+ combo.setCurrentIndex(NO_SELECTION_INDEX)
626
+ combo.activated.connect(lambda _index, origin=row.origin: self._repository_chosen(origin))
627
+ grid_row = self.repository_grid.rowCount()
628
+ self.repository_grid.addWidget(label, grid_row, ROW_LABEL_COLUMN)
629
+ self.repository_grid.addWidget(combo, grid_row, ROW_CHOICE_COLUMN)
630
+ if row.missing_choice:
631
+ self.repository_grid.addWidget(plain_label(MISSING_CHOICE_TEXT), self.repository_grid.rowCount(), ROW_CHOICE_COLUMN)
632
+ self.repository_label_by_origin[row.origin] = label
633
+ self.repository_combo_by_origin[row.origin] = combo
634
+ for note in view.ssh_notes:
635
+ self.repository_grid.addWidget(plain_label(note), self.repository_grid.rowCount(), ROW_LABEL_COLUMN, ROW_SPAN, ROW_COLUMN_COUNT)
636
+
637
+ def _repository_chosen(self, origin: str) -> None:
638
+ """Record the user's choice for one address of this repository; choosing the saved one again leaves nothing to save there."""
639
+ combo = self.repository_combo_by_origin.get(origin)
640
+ row = self._repository_row(origin)
641
+ if combo is None or row is None:
642
+ return
643
+ namespace = combo.currentData()
644
+ if repository_draft_needed(row, namespace):
645
+ self.repository_draft_namespace_by_origin[origin] = namespace
646
+ else:
647
+ self.repository_draft_namespace_by_origin.pop(origin, None)
648
+ self.repository_status_label.clear()
649
+ self._refresh_block_state()
650
+
651
+ def _repository_row(self, origin: str) -> RepositoryRow | None:
652
+ """Find the displayed This repository row of this address."""
653
+ for row in self.repository_rows:
654
+ if row.origin == origin:
655
+ return row
656
+ return None
657
+
658
+ def _refresh_block_state(self) -> None:
659
+ """Mark a block with unsaved choices in its title and enable its Save button only then."""
660
+ global_changed = bool(self.global_draft_namespace_by_origin)
661
+ self.global_group.setTitle(GLOBAL_TITLE + UNSAVED_MARK if global_changed else GLOBAL_TITLE)
662
+ self.save_global_button.setEnabled(global_changed)
663
+ repository_changed = bool(self.repository_draft_namespace_by_origin)
664
+ self.repository_group.setTitle(REPOSITORY_TITLE + UNSAVED_MARK if repository_changed else REPOSITORY_TITLE)
665
+ self.save_repository_button.setEnabled(repository_changed and self.repository_dir is not None)
666
+
667
+ def set_removing(self, running: bool, label: str = "") -> None:
668
+ """Lock the account actions while a removal runs and say which account is being removed; unlock them when it ends."""
669
+ self.removing = running
670
+ self.add_account_button.setEnabled(not running)
671
+ self._selection_changed()
672
+ if running:
673
+ self.status_label.setText(REMOVING_TEXT.format(label=label))
674
+
675
+ def _confirm_removal(self) -> None:
676
+ """Ask before removing the selected account, without blocking the application; Keep account is the default and the answer of Escape.
677
+
678
+ Only one question is open at a time. The account is captured when asking, so changing the selection meanwhile
679
+ cannot redirect the removal to another account.
680
+ """
681
+ if self.remove_confirmation is not None and isValid(self.remove_confirmation):
682
+ self.remove_confirmation.raise_()
683
+ self.remove_confirmation.activateWindow()
684
+ return
685
+ selection = self._selected_account()
686
+ if selection is None or self.removing:
687
+ return
688
+ box = QMessageBox(QMessageBox.Icon.Warning, REMOVE_TITLE, REMOVE_QUESTION_TEXT.format(label=selection.label, address=origin_address(selection.origin)), parent=self)
689
+ prepare_message_box(box, REMOVE_TITLE)
690
+ box.setTextFormat(Qt.TextFormat.PlainText)
691
+ box.setInformativeText(REMOVE_DETAIL_TEXT)
692
+ remove_button = box.addButton(REMOVE_CONFIRM_TEXT, QMessageBox.ButtonRole.DestructiveRole)
693
+ keep_button = box.addButton(KEEP_ACCOUNT_TEXT, QMessageBox.ButtonRole.RejectRole)
694
+ box.setDefaultButton(keep_button)
695
+ box.setEscapeButton(keep_button)
696
+ self.remove_confirmation = box
697
+
698
+ def finished() -> None:
699
+ """Ask for the removal only on an explicit Remove account; any other ending keeps the account."""
700
+ self.remove_confirmation = None
701
+ confirmed = box.clickedButton() is remove_button
702
+ box.deleteLater()
703
+ if confirmed:
704
+ self.remove_account_requested.emit(selection.namespace)
705
+
706
+ box.finished.connect(finished)
707
+ open_window_modal(box)
708
+
709
+ def _selected_account(self) -> AccountSelection | None:
710
+ """Find the listed account of the selected row, or None when no row is selected."""
711
+ namespace = self.selected_namespace()
712
+ for selection in self.catalogue.accounts:
713
+ if selection.namespace == namespace:
714
+ return selection
715
+ return None
716
+
717
+ def _selection_changed(self) -> None:
718
+ """Offer Reconnect and Remove account for a selected account, but not while a removal runs.
719
+
720
+ Reconnect is always offered, since an expired sign-in cannot be known in advance.
721
+ """
722
+ selected = self.selected_namespace() is not None
723
+ self.reconnect_button.setEnabled(selected and not self.removing)
724
+ self.remove_account_button.setEnabled(selected and not self.removing)