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,592 @@
1
+ """Edit the shared settings of GitPane, starting with the Git it runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ from PySide6.QtCore import QObject, Signal
12
+ from shiboken6 import isValid
13
+
14
+ from gitpane.account.credential_manager import ignore_line
15
+ from gitpane.account.ssh import effective_source
16
+ from gitpane.config import SharedText
17
+ from gitpane.history.file_history import WindowCapability
18
+ from gitpane.history.repository_value import RepositoryFailure, RequestIdentity
19
+ from gitpane.operation.diagnostic import Diagnostic
20
+ from gitpane.system.desktop_icon_fact import read_session_desktop_icon_facts
21
+ from gitpane.system.desktop_shortcut import create_installed_desktop_shortcut
22
+ from gitpane.system.linux_distribution import DistributionFamily, DistributionRelease
23
+ from gitpane.tool.difftool import DifftoolRecipe
24
+ from gitpane.tool.git_executable import detect_git_candidates
25
+ from gitpane.tool.kdiff3 import detect_kdiff3_filename
26
+ from gitpane.tool.kdiff3_install import kdiff3_install_line
27
+ from gitpane.tool.mergetool_recipe import MERGETOOL_COMMAND_PREFERENCE_KEY, MERGETOOL_FILENAME_PREFERENCE_KEY, MergetoolRecipe
28
+ from gitpane.transport.push_tracking import push_tracking_choice, save_push_tracking_choice
29
+ from gitpane.ui.console.error_dialog import ErrorDialog
30
+ from gitpane.ui.setting.account_page import ACCOUNTS_PAGE_NAME, AccountPage
31
+ from gitpane.ui.setting.git_setting_widget import GitSettingWidget
32
+ from gitpane.ui.setting.setting_dialog import GIT_AVAILABLE_TEMPLATE, SSH_PAGE_NAME, SettingDialog
33
+ from gitpane.ui.setting.ssh_widget import SshWidget
34
+ from gitpane.ui.setting.utility_page import UtilityPage
35
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Callable
39
+
40
+ from gitpane.account.ssh import SshService, SshVerification
41
+ from gitpane.controller.history_read_controller import HistoryReadController
42
+ from gitpane.controller.operation_policy import OperationPolicy
43
+ from gitpane.controller.session_registry import SessionRegistry
44
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
45
+ from gitpane.controller.window_activation import WindowActivationFilter
46
+ from gitpane.git_config.git_setting import GitSettingService
47
+ from gitpane.history.repository import GitRepository
48
+ from gitpane.history.repository_value import RepositoryContext
49
+ from gitpane.operation.diagnostic import DiagnosticLog
50
+ from gitpane.preference.preference_store import PreferenceStore
51
+ from gitpane.sequencer.mergetool import MergetoolService
52
+ from gitpane.system.desktop_icon_support import DesktopIconFacts
53
+ from gitpane.system.desktop_shortcut import ShortcutReport
54
+ from gitpane.tool.git_executable import GitExecutableValidator
55
+ from gitpane.tool.system_editor_default import SystemEditorDefault
56
+ from gitpane.tool.terminal_recipe import TerminalRegistry
57
+ from gitpane.transport.push_tracking import PushTrackingChoice
58
+
59
+ SETTING_FAILED_DRAFTS_TEXT = "Settings could not all be saved. Your failed drafts are retained."
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class WaitingOpening:
64
+ """One opening asked before the startup check of Git answered, with the Git it was asked with.
65
+
66
+ ``checks_folder`` says that the opening, once released, asks whether its folder exists before Git reads it; only the
67
+ openings of the welcome window do, a clone's destination being known to exist.
68
+ """
69
+
70
+ identity: RequestIdentity
71
+ repository_dir: str
72
+ git_filename: str
73
+ checks_folder: bool = False
74
+
75
+
76
+ class SettingController(QObject):
77
+ """The Settings window with its drafts, and the checks of the Git GitPane runs: at startup, after a change made elsewhere, and from the window."""
78
+
79
+ # Settings window just built, its accounts page, the session that asked (or None), the repository it opened for (or None):
80
+ # the accounts page is filled and connected while the window is being built, before its own buttons are connected.
81
+ setting_opened = Signal(object, object, object, object)
82
+ # The startup check found no Git, or none that works: the welcome window says so and opens Settings, where a failure
83
+ # of the last Git tried is shown next.
84
+ git_missing = Signal()
85
+ # An opening asked during the startup check of Git, released with the Git that check accepted: the opening
86
+ # controller goes on with it.
87
+ waiting_opening_released = Signal(object)
88
+
89
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, window_activation_controller: WindowActivationFilter,
90
+ history_read_controller: HistoryReadController, preference: PreferenceStore, repository: GitRepository, git_setting_service: GitSettingService, ssh: SshService, mergetool: MergetoolService,
91
+ diagnostic_log: DiagnosticLog | None, terminal_registry: TerminalRegistry, validator: GitExecutableValidator, system_editor_default: SystemEditorDefault,
92
+ desktop_shortcut: Callable[[], ShortcutReport] = create_installed_desktop_shortcut,
93
+ desktop_icon_facts: Callable[[], DesktopIconFacts] = read_session_desktop_icon_facts, distribution_family: DistributionFamily = DistributionFamily.UNKNOWN,
94
+ distribution_release: DistributionRelease = DistributionRelease.OTHER, parent: QObject | None = None) -> None:
95
+ """Receive the services and the lower controllers Settings works with; the wiring builds it after the history read controller, and the accounts, the clone forms and the welcome learn from its signals what it opened or found.
96
+
97
+ ``desktop_shortcut`` writes the desktop shortcut of the running installation when the Utilities page asks for
98
+ it; tests give their own, which writes only in their temporary folder. ``desktop_icon_facts`` reads from the
99
+ session whether the desktop can show the shortcut's icon, each time the Utilities page is shown, on the page's
100
+ worker thread; tests give their own, which reads nothing from the machine. ``diagnostic_log``, when a ``--log``
101
+ journal is kept, also receives the errors nobody expects of these two workers. ``system_editor_default`` keeps the
102
+ text editor the system names, which the editor detection follows while no editor choice is saved.
103
+ ``distribution_family`` and ``distribution_release`` name the Linux distribution of the session, read once by the
104
+ wiring, so the help and the refusals of a missing diff or merge tool say how to install KDiff3 there.
105
+ """
106
+ super().__init__(parent)
107
+ self.session_registry_controller = session_registry_controller
108
+ self.operation_policy_controller = operation_policy_controller
109
+ self.staging_session_registry_controller = staging_session_registry_controller
110
+ self.window_activation_controller = window_activation_controller
111
+ self.history_read_controller = history_read_controller
112
+ self.preference = preference
113
+ self.repository = repository
114
+ self.git_setting_service = git_setting_service
115
+ self.ssh = ssh
116
+ self.mergetool = mergetool
117
+ self.diagnostic_log = diagnostic_log
118
+ self.terminal_registry = terminal_registry
119
+ self.validator = validator
120
+ self.system_editor_default = system_editor_default
121
+ self.desktop_shortcut = desktop_shortcut
122
+ self.desktop_icon_facts = desktop_icon_facts
123
+ self.distribution_family = distribution_family
124
+ self.distribution_release = distribution_release
125
+ self.setting_session_id = uuid.uuid4().hex
126
+ self.setting_generation = 0
127
+ self.setting_identity: RequestIdentity | None = None
128
+ self.save_requested = False
129
+ self.startup_identity: RequestIdentity | None = None
130
+ self.launch_validation_identity: RequestIdentity | None = None
131
+ # The Git the open Settings draft has just validated, and the one a save in progress is writing with that proof.
132
+ self.setting_validated_git_filename = ""
133
+ self.saving_validated_git_filename = ""
134
+ # Set while the check in progress was asked by Verify Git, the only check whose success is announced with a box.
135
+ self.setting_verify_clicked = False
136
+ # Warning about the tools folder of the Git the current Settings check accepts; cleared with that check.
137
+ self.setting_tools_folder_warning = ""
138
+ self.candidate_filenames: list[str] = []
139
+ # Openings asked while the startup check of Git is still running, with the Git each one was asked with.
140
+ self.waiting_opening_by_session: dict[str, WaitingOpening] = {}
141
+
142
+ def _validate_selected_git(self, git_filename: str) -> None:
143
+ """Check a Git accepted elsewhere, in a session of its own, so a slow answer cannot revive an older choice."""
144
+ if self.launch_validation_identity is not None:
145
+ self.validator.cancel(self.launch_validation_identity.session_id)
146
+ self.launch_validation_identity = RequestIdentity(uuid.uuid4().hex)
147
+ self.validator.validate(git_filename, self.launch_validation_identity)
148
+
149
+ def show_setting(self, session_id: str | None = None, *, section: str | None = None) -> SettingDialog | None:
150
+ """Open shared settings and reveal a requested section without discarding an existing draft."""
151
+ if session_id is not None and not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
152
+ return None
153
+ if self.session_registry_controller.setting_dialog is not None:
154
+ self.session_registry_controller.setting_dialog.showNormal()
155
+ self.session_registry_controller.setting_dialog.raise_()
156
+ self.session_registry_controller.setting_dialog.activateWindow()
157
+ if section == "mergetool":
158
+ self.session_registry_controller.setting_dialog.show_mergetool_setting()
159
+ if section == "editor":
160
+ self.session_registry_controller.setting_dialog.show_editor_setting()
161
+ if section == "accounts":
162
+ self.session_registry_controller.setting_dialog.show_page(ACCOUNTS_PAGE_NAME)
163
+ return self.session_registry_controller.setting_dialog
164
+ mergetool_filename = self.preference.initialize_mergetool(save_detected=False)
165
+ merge_warning = self.preference.warning
166
+ difftool_filename = self.preference.initialize_difftool(save=False)
167
+ # The notice asks for a Save only when the merge tool shown would not be used without one. A detected KDiff3 is used
168
+ # as it is by the conflict action, so it gets no notice even though it is not saved yet.
169
+ detected_kdiff3_filename = detect_kdiff3_filename(sys.platform)
170
+ mergetool_suggested = bool(mergetool_filename) and mergetool_filename not in (self.preference.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY], detected_kdiff3_filename)
171
+ context = self.session_registry_controller.session_by_id[session_id].repository_context if session_id is not None else None
172
+ editor_recipe = self.preference.initialize_editor(repository_dirs=(context.repository_dir,) if context is not None else (),
173
+ system_editor_id=self.system_editor_default.system_editor_id)
174
+ # Read again for the next opening and for Show in text editor, so a default editor changed on the desktop is followed.
175
+ self.system_editor_default.request_read()
176
+ git_setting_widget = self._git_setting_widget(context)
177
+ account_page = AccountPage(context.repository_dir if context is not None else None)
178
+ # The form gets only the saved client: a detected one shown there would be saved by the next Save.
179
+ ssh_widget = SshWidget(self.ssh.saved_configuration(), *self._effective_ssh())
180
+ utility_page = UtilityPage(self.desktop_shortcut, self.desktop_icon_facts, log_line=self.diagnostic_log.write_line if self.diagnostic_log is not None else ignore_line)
181
+ dialog = SettingDialog(str(self.preference.value_by_key["git_filename"]) or self.repository.git_filename,
182
+ difftool_filename=difftool_filename, difftool_command=str(self.preference.value_by_key["difftool_command"]), git_setting_widget=git_setting_widget,
183
+ account_page=account_page, ssh_widget=ssh_widget, utility_page=utility_page, mergetool_filename=mergetool_filename,
184
+ mergetool_command=str(self.preference.value_by_key[MERGETOOL_COMMAND_PREFERENCE_KEY]), mergetool_suggested=mergetool_suggested,
185
+ editor_recipe=editor_recipe, kdiff3_install_line="" if detected_kdiff3_filename else kdiff3_install_line(sys.platform, self.distribution_family, self.distribution_release))
186
+ # Settings are shared by every window and have no Qt parent; they open over the window that asked for them.
187
+ dialog.placement_anchor = self.session_registry_controller.window_by_session.get(session_id) if session_id is not None else next(iter(self.session_registry_controller.welcome_by_session.values()), None)
188
+ self.session_registry_controller.setting_dialog = dialog
189
+ ssh_widget.verification_requested.connect(self._verify_ssh)
190
+ self.setting_opened.emit(dialog, account_page, session_id, context)
191
+ dialog.validation_requested.connect(self._validate_setting)
192
+ dialog.git_verify_requested.connect(self._verify_setting)
193
+ dialog.detection_requested.connect(self._detect_setting)
194
+ dialog.draft_changed.connect(self._invalidate_setting)
195
+ dialog.finished.connect(self._close_setting)
196
+ dialog.installEventFilter(self.window_activation_controller)
197
+ if self.repository.git_version:
198
+ dialog.status_label.setText(GIT_AVAILABLE_TEMPLATE.format(version=self.repository.git_version))
199
+ if self.preference.warning:
200
+ dialog.status_label.setText(self.preference.warning)
201
+ if merge_warning:
202
+ dialog.status_label.setText(merge_warning)
203
+ # Modal for the whole application on every system: no other GitPane window can come in front of Settings or take
204
+ # a click while it is open, and the windows it opens are modal over it. On macOS AppKit keeps a running modal
205
+ # session above the application's other windows, and puts it back to the normal level while another
206
+ # application is active, so Settings never floats above the browser of a sign-in.
207
+ open_window_modal(dialog, blocks_application=True)
208
+ if section == "mergetool":
209
+ dialog.show_mergetool_setting()
210
+ if section == "editor":
211
+ dialog.show_editor_setting()
212
+ if section == "accounts":
213
+ dialog.show_page(ACCOUNTS_PAGE_NAME)
214
+ return dialog
215
+
216
+ def _git_setting_widget(self, context: RepositoryContext | None) -> GitSettingWidget:
217
+ """Build the Git page; opened from a repository, it also offers that repository's tracking choice, kept in the preferences file.
218
+
219
+ The choice is keyed by the shared Git folder of the repository Settings was opened from, so linked worktrees share
220
+ it and no other repository's entry is touched.
221
+ """
222
+ if context is None:
223
+ return GitSettingWidget(self.git_setting_service, None, self.repository.git_filename)
224
+
225
+ def save_push_tracking(choice: PushTrackingChoice) -> None:
226
+ """Keep the choice for the repository Settings was opened from."""
227
+ save_push_tracking_choice(self.preference, context.common_git_dir, choice)
228
+
229
+ return GitSettingWidget(self.git_setting_service, context, self.repository.git_filename,
230
+ push_tracking_choice=push_tracking_choice(self.preference, context.common_git_dir), save_push_tracking=save_push_tracking)
231
+
232
+ def _verify_ssh(self, save: bool = False) -> None:
233
+ """Verify a captured SSH candidate and discard results from a closed Settings view.
234
+
235
+ With Executable empty the candidate is the client operations use now, so Verify is useful without any setting;
236
+ that check never marks the draft as verified.
237
+ """
238
+ dialog = self.session_registry_controller.setting_dialog
239
+ if dialog is None or dialog.ssh_widget.request_id:
240
+ return
241
+ widget = dialog.ssh_widget
242
+ configuration = widget.verification_configuration()
243
+ generation = self.setting_generation
244
+ widget.set_verifying()
245
+
246
+ def complete(result: SshVerification) -> None:
247
+ """Continue an explicit Save only if its original candidate is still current."""
248
+ if self.session_registry_controller.setting_dialog is not dialog or not isValid(widget):
249
+ return
250
+ widget.show_result(result)
251
+ if save and result.succeeded and generation == self.setting_generation:
252
+ self._validate_setting(dialog.typed_git_filename(), True)
253
+
254
+ widget.request_id = self.ssh.verify(configuration, complete)
255
+
256
+ def _save_ssh(self, dialog: SettingDialog) -> bool:
257
+ """Persist tool paths independently and retain a failed SSH draft on partial saves."""
258
+ widget = dialog.ssh_widget
259
+ configuration = widget.configuration()
260
+ try:
261
+ configuration.validate(optional=True)
262
+ if widget.request_id:
263
+ raise ValueError("Wait for SSH verification to finish before saving settings.")
264
+ if configuration.ssh_filename and widget.verified_configuration != configuration:
265
+ raise ValueError("Verify the SSH client before saving settings.")
266
+ self.preference.update({"ssh_filename": configuration.ssh_filename, "git_utility_dir": configuration.utility_dir})
267
+ except (OSError, TypeError, ValueError) as error:
268
+ widget.status_label.show_failure(str(error))
269
+ dialog.reveal_widget(SSH_PAGE_NAME, widget.status_label)
270
+ return False
271
+ widget.show_effective(*self._effective_ssh())
272
+ return True
273
+
274
+ def _effective_ssh(self) -> tuple[str, str]:
275
+ """Name the SSH client operations use now, and the source Settings shows for it: saved, detected or none."""
276
+ return self.ssh.configuration().ssh_filename, effective_source(self.ssh.saved_configuration(), self.ssh.detection())
277
+
278
+ def _verify_setting(self, git_filename: str) -> None:
279
+ """Check the Git path on an explicit Verify Git click, announcing a success so the click visibly did something."""
280
+ self._validate_setting(git_filename, False, announce=True)
281
+
282
+ def _validate_setting(self, git_filename: str, save: bool, announce: bool = False) -> None:
283
+ """Validate the current draft with a unique generation before any save; ``announce`` shows a success in green with a confirmation box."""
284
+ if self.session_registry_controller.setting_dialog is not None and self.session_registry_controller.setting_dialog.git_setting_widget.saving:
285
+ return
286
+ if save and self.session_registry_controller.setting_dialog is not None:
287
+ widget = self.session_registry_controller.setting_dialog.ssh_widget
288
+ if widget.request_id:
289
+ return
290
+ if widget.configuration().ssh_filename and widget.verified_configuration != widget.configuration():
291
+ self._verify_ssh(save=True)
292
+ return
293
+ self._invalidate_setting()
294
+ if self.startup_identity is not None:
295
+ self.validator.cancel(self.startup_identity.session_id)
296
+ self.startup_identity = None
297
+ self._abandon_waiting_openings()
298
+ if self.session_registry_controller.setting_dialog is None:
299
+ return
300
+ if save:
301
+ draft = self.session_registry_controller.setting_dialog.tool_draft()
302
+ try:
303
+ DifftoolRecipe(draft.difftool_filename, draft.difftool_command).validate(family=self.distribution_family, release=self.distribution_release)
304
+ MergetoolRecipe(draft.mergetool_filename, draft.mergetool_command).validate(optional=True, family=self.distribution_family, release=self.distribution_release)
305
+ draft.editor_recipe.validate()
306
+ except ValueError as error:
307
+ self.session_registry_controller.setting_dialog.status_label.show_failure(str(error))
308
+ return
309
+ if git_filename == self.preference.value_by_key["git_filename"]:
310
+ self._save_setting(git_filename, self.repository.git_version)
311
+ return
312
+ self.save_requested = save
313
+ self.setting_verify_clicked = announce
314
+ self.setting_identity = RequestIdentity(self.setting_session_id, self.setting_generation)
315
+ self.session_registry_controller.setting_dialog.set_loading(True)
316
+ self.validator.validate(git_filename, self.setting_identity, read_tools_folder=True)
317
+
318
+ def _invalidate_setting(self) -> None:
319
+ """Ensure an edited or closed draft cannot accept an older validation result."""
320
+ self.setting_generation += 1
321
+ self.setting_identity = None
322
+ self.save_requested = False
323
+ self.setting_verify_clicked = False
324
+ self.setting_validated_git_filename = ""
325
+ self.setting_tools_folder_warning = ""
326
+ self.validator.cancel(self.setting_session_id)
327
+
328
+ def _detect_setting(self) -> None:
329
+ """Put an explicitly detected candidate in the draft without changing active Git."""
330
+ candidate_filenames = detect_git_candidates()
331
+ if self.session_registry_controller.setting_dialog is None:
332
+ return
333
+ if not candidate_filenames:
334
+ self.session_registry_controller.setting_dialog.status_label.setText("Git was not found\nNo usable Git executable was found. Choose its path in Settings.")
335
+ return
336
+ first_candidate_index = 0
337
+ self.session_registry_controller.setting_dialog.path_edit.setText(candidate_filenames[first_candidate_index])
338
+ self._validate_setting(self.session_registry_controller.setting_dialog.typed_git_filename(), False)
339
+
340
+ def _close_setting(self) -> None:
341
+ """Abandon the draft and its validation without touching accepted preferences."""
342
+ self._invalidate_setting()
343
+ dialog = self.session_registry_controller.setting_dialog
344
+ if dialog is not None:
345
+ if dialog.ssh_widget.request_id:
346
+ self.repository.runner.cancel(dialog.ssh_widget.request_id)
347
+ dialog.account_page.abandon_sign_in()
348
+ dialog.git_setting_widget.stop_read()
349
+ dialog.deleteLater()
350
+ self.session_registry_controller.setting_dialog = None
351
+
352
+ def _restore_welcome_setting_label(self) -> None:
353
+ """Give every welcome window's settings button its usual label back once a validated Git is in use."""
354
+ for welcome in self.session_registry_controller.welcome_by_session.values():
355
+ welcome.setting_button.setText(SharedText.SETTING_BUTTON.value)
356
+
357
+ def initialize_git(self) -> None:
358
+ """Initialize the merge default and validate saved Git before considering detected choices.
359
+
360
+ Run once at launch, after the welcome window is shown. Without any Git to try, the welcome window learns it
361
+ through ``git_missing``; otherwise the answer comes back to ``git_validated``, which tries the next candidate.
362
+ """
363
+ self.preference.initialize_mergetool(save_detected=False)
364
+ saved_filename = str(self.preference.read("git_filename"))
365
+ self.candidate_filenames = [saved_filename] if saved_filename else detect_git_candidates()
366
+ if self.launch_validation_identity is not None:
367
+ self.validator.cancel(self.launch_validation_identity.session_id)
368
+ self.launch_validation_identity = None
369
+ self.startup_identity = RequestIdentity(uuid.uuid4().hex)
370
+ if not self.candidate_filenames:
371
+ self.startup_identity = None
372
+ self.git_missing.emit()
373
+ return
374
+ first_candidate_index = 0
375
+ candidate_filename = self.candidate_filenames.pop(first_candidate_index)
376
+ # Opening no longer asks Git for its version: an opening asked before this check answers waits for it, and
377
+ # resumes only with the Git the check accepted. The detected path is still shown right away in the settings.
378
+ self.repository.git_filename = candidate_filename
379
+ self.validator.validate(candidate_filename, self.startup_identity)
380
+
381
+ def forget_waiting_opening(self, session_id: str) -> None:
382
+ """Drop the opening a closed chooser was waiting to start, so the end of the startup check never starts it."""
383
+ self.waiting_opening_by_session.pop(session_id, None)
384
+
385
+ def git_validated(self, identity: RequestIdentity, git_filename: str, version: str, failure: RepositoryFailure | None) -> None:
386
+ """Publish the accepted executable only after the latest validation and successful sync."""
387
+ if identity == self.startup_identity:
388
+ if failure is not None and self.candidate_filenames:
389
+ first_candidate_index = 0
390
+ self.validator.validate(self.candidate_filenames.pop(first_candidate_index), identity)
391
+ return
392
+ self.startup_identity = None
393
+ if failure is not None:
394
+ self._abandon_waiting_openings()
395
+ self.git_missing.emit()
396
+ self._show_setting_failure(failure)
397
+ return
398
+ self.repository.git_filename = git_filename
399
+ self.repository.git_version = version
400
+ self._restore_welcome_setting_label()
401
+ self._resume_waiting_openings(git_filename)
402
+ return
403
+ if identity == self.launch_validation_identity:
404
+ self.launch_validation_identity = None
405
+ if failure is not None:
406
+ return
407
+ if self.repository.git_filename == git_filename or os.path.abspath(self.repository.git_filename) == git_filename:
408
+ self.repository.git_version = version
409
+ return
410
+ if identity != self.setting_identity or self.session_registry_controller.setting_dialog is None:
411
+ return
412
+ self.setting_identity = None
413
+ if failure is None:
414
+ self.setting_validated_git_filename = git_filename
415
+ self.session_registry_controller.setting_dialog.set_loading(False)
416
+ if failure is not None:
417
+ self._show_setting_failure(failure)
418
+ return
419
+ if self.setting_verify_clicked and not self.save_requested:
420
+ self.session_registry_controller.setting_dialog.show_git_verified(version, git_filename, self.setting_tools_folder_warning)
421
+ else:
422
+ self.session_registry_controller.setting_dialog.show_git_available(version, self.setting_tools_folder_warning)
423
+ widget = self.session_registry_controller.setting_dialog.git_setting_widget
424
+ if widget.git_filename != git_filename and not widget.dirty_target_set:
425
+ widget.stop_read()
426
+ widget.closed = False
427
+ widget.git_filename = git_filename
428
+ widget.reload()
429
+ if not self.save_requested:
430
+ return
431
+ self._save_setting(git_filename, version)
432
+
433
+ def git_tools_folder_checked(self, identity: RequestIdentity, _git_filename: str, warning: str) -> None:
434
+ """Keep the tools folder warning of the current Settings check until its validation is shown; the startup and launch checks never ask for one."""
435
+ if identity != self.setting_identity:
436
+ return
437
+ self.setting_tools_folder_warning = warning
438
+
439
+ def _save_setting(self, git_filename: str, version: str) -> None:
440
+ """Report preference and Git saves independently without losing either failed draft."""
441
+ if self.session_registry_controller.setting_dialog is None:
442
+ return
443
+ dialog = self.session_registry_controller.setting_dialog
444
+ generation = self.setting_generation
445
+ ssh_saved = self._save_ssh(dialog)
446
+ preference_saved = True
447
+ # The proof that this very save was validated exists only while it is being written. Its change signal arrives
448
+ # during the call and is the only one allowed to reuse it, which is what tells a real save apart from a change
449
+ # made elsewhere to the same path.
450
+ self.saving_validated_git_filename = git_filename if self.setting_validated_git_filename == git_filename else ""
451
+ draft = dialog.tool_draft()
452
+ try:
453
+ self.preference.update({"git_filename": git_filename, "difftool_filename": draft.difftool_filename, "difftool_command": draft.difftool_command,
454
+ MERGETOOL_FILENAME_PREFERENCE_KEY: draft.mergetool_filename, MERGETOOL_COMMAND_PREFERENCE_KEY: draft.mergetool_command,
455
+ **draft.editor_recipe.preference_value()})
456
+ except (OSError, TypeError, ValueError) as error:
457
+ self._show_setting_failure(RepositoryFailure("Save preferences", "", git_filename, [], str(error)))
458
+ preference_saved = False
459
+ finally:
460
+ self.saving_validated_git_filename = ""
461
+ if preference_saved:
462
+ self.repository.git_filename = git_filename
463
+ self.repository.git_version = version
464
+ self._restore_welcome_setting_label()
465
+ dialog.mergetool_save_notice_label.hide()
466
+ dialog.set_loading(True)
467
+
468
+ def complete(saved: bool) -> None:
469
+ """Close only after every requested domain succeeds in the original dialog.
470
+
471
+ This Save never writes account choices, so while Accounts still holds unsaved work the window stays open and
472
+ says where, instead of dropping that work by closing. The tools folder warning of the Git this Save checked
473
+ follows that line, since the window is still there to show it.
474
+ """
475
+ if self.session_registry_controller.setting_dialog is not dialog or not isValid(dialog):
476
+ return
477
+ dialog.set_loading(False)
478
+ if saved and preference_saved and ssh_saved and generation == self.setting_generation:
479
+ place_names = dialog.account_page.unsaved_place_names()
480
+ if place_names:
481
+ dialog.mark_saved()
482
+ dialog.show_success_with_warning("Settings saved. Unsaved changes remain in: " + ", ".join(place_names) + ".", self.setting_tools_folder_warning)
483
+ return
484
+ dialog.accept()
485
+ elif not ssh_saved:
486
+ dialog.status_label.show_failure("Other application preferences saved. SSH settings need attention." if preference_saved else SETTING_FAILED_DRAFTS_TEXT)
487
+ elif not saved:
488
+ dialog.status_label.show_failure("Application preferences saved. Some Git settings need attention." if preference_saved else SETTING_FAILED_DRAFTS_TEXT)
489
+ elif not preference_saved:
490
+ # Git and SSH saved but the Tools preferences did not: without this branch the bottom line kept the
491
+ # "Verifying" wait text, and nothing said that the Tools drafts were not saved.
492
+ dialog.status_label.show_failure(SETTING_FAILED_DRAFTS_TEXT)
493
+
494
+ dialog.git_setting_widget.save(complete)
495
+
496
+ def _show_setting_failure(self, failure: RepositoryFailure) -> None:
497
+ """Present a sanitized cause and copyable details while preserving the Git draft."""
498
+ if self.session_registry_controller.setting_dialog is None:
499
+ return
500
+ diagnostic = Diagnostic(failure.operation, failure.repository_dir, failure.program_filename, failure.arguments,
501
+ f"Cannot use {failure.program_filename}: {failure.cause}. Correct the path, then verify Git again.", failure.stderr, failure.return_code).prepare()
502
+ self.session_registry_controller.setting_dialog.show_failure(diagnostic)
503
+ if self.diagnostic_log is not None:
504
+ self.diagnostic_log.write(diagnostic)
505
+ dialog = ErrorDialog(diagnostic, self.session_registry_controller.setting_dialog)
506
+ self.session_registry_controller.dialogs.append(dialog)
507
+
508
+ def finish() -> None:
509
+ """Release the diagnostic and return focus to the editable Git path."""
510
+ if dialog in self.session_registry_controller.dialogs:
511
+ self.session_registry_controller.dialogs.remove(dialog)
512
+ if self.session_registry_controller.setting_dialog is not None:
513
+ self.session_registry_controller.setting_dialog.path_edit.setFocus()
514
+ dialog.deleteLater()
515
+
516
+ dialog.finished.connect(finish)
517
+ open_window_modal(dialog)
518
+
519
+ def preference_changed(self, changed_value_by_key: dict[str, object]) -> None:
520
+ """Apply successful shared changes to future reads and any currently open chooser.
521
+
522
+ A new Git or Git utilities folder also detects terminals again, since both decide where Git Bash is found.
523
+ """
524
+ if any(key in changed_value_by_key for key in ("git_filename", MERGETOOL_FILENAME_PREFERENCE_KEY, MERGETOOL_COMMAND_PREFERENCE_KEY)):
525
+ for session_id in tuple(self.session_registry_controller.session_by_id):
526
+ self.mergetool.invalidate_session(session_id)
527
+ if "git_filename" in changed_value_by_key:
528
+ self.repository.git_filename = str(changed_value_by_key["git_filename"])
529
+ self.repository.git_version = ""
530
+ self._abandon_waiting_openings()
531
+ if self.saving_validated_git_filename and self.saving_validated_git_filename == self.repository.git_filename:
532
+ # This very save was already validated, so checking it again would only repeat the same version probe,
533
+ # and an older check still running must not answer for it.
534
+ self.setting_validated_git_filename = ""
535
+ if self.launch_validation_identity is not None:
536
+ self.validator.cancel(self.launch_validation_identity.session_id)
537
+ self.launch_validation_identity = None
538
+ else:
539
+ self._validate_selected_git(self.repository.git_filename)
540
+ if "recent_dirs" in changed_value_by_key:
541
+ for window in self.session_registry_controller.welcome_by_session.values():
542
+ window.show_recent(changed_value_by_key["recent_dirs"])
543
+ for repository_window in self.session_registry_controller.window_by_session.values():
544
+ repository_window.show_recent_repositories(changed_value_by_key["recent_dirs"])
545
+ for session_id, window in self.session_registry_controller.window_by_session.items():
546
+ window.apply_reading_preference(changed_value_by_key)
547
+ window.apply_fetch_preference(changed_value_by_key)
548
+ if "diff_show_entire_file" in changed_value_by_key:
549
+ self.history_read_controller.change_diff_context(session_id, changed_value_by_key["diff_show_entire_file"])
550
+ for staging in self.staging_session_registry_controller.staging_by_key.values():
551
+ if "git_filename" in changed_value_by_key:
552
+ staging.set_git_filename(self.repository.git_filename)
553
+ if staging.window is not None and isValid(staging.window):
554
+ staging.window.apply_preference(changed_value_by_key)
555
+ # These two settings decide where Git Bash is searched; a terminal the user already chose is never replaced.
556
+ if "git_filename" in changed_value_by_key or "git_utility_dir" in changed_value_by_key:
557
+ self.terminal_registry.redetect()
558
+
559
+ def _resume_waiting_openings(self, accepted_git_filename: str) -> None:
560
+ """Release the openings asked during the startup check, but only those asked with the Git it accepted.
561
+
562
+ An opening whose window was closed or cancelled, or that was asked with another Git, is dropped. The others go
563
+ to ``waiting_opening_released``, whose receiver asks about their folder or starts their read.
564
+ """
565
+ waiting_openings = tuple(self.waiting_opening_by_session.items())
566
+ self.waiting_opening_by_session.clear()
567
+ for session_id, waiting in waiting_openings:
568
+ if self.session_registry_controller.identity_by_session.get(session_id) != waiting.identity or session_id not in self.session_registry_controller.welcome_by_session:
569
+ continue
570
+ if os.path.normcase(os.path.abspath(waiting.git_filename)) != os.path.normcase(os.path.abspath(accepted_git_filename)):
571
+ self._stop_waiting_opening(session_id, waiting, "Git changed while the repository was opening. Open it again.")
572
+ continue
573
+ self.waiting_opening_released.emit(waiting)
574
+
575
+ def _abandon_waiting_openings(self) -> None:
576
+ """Stop the openings waiting for a startup check that failed or was replaced, leaving their chooser usable."""
577
+ waiting_openings = tuple(self.waiting_opening_by_session.items())
578
+ self.waiting_opening_by_session.clear()
579
+ for session_id, waiting in waiting_openings:
580
+ self._stop_waiting_opening(session_id, waiting, "")
581
+
582
+ def _stop_waiting_opening(self, session_id: str, waiting: WaitingOpening, message: str) -> None:
583
+ """Give the chooser back to the user for one opening that will not start."""
584
+ if self.session_registry_controller.identity_by_session.get(session_id) != waiting.identity:
585
+ return
586
+ self.session_registry_controller.identity_by_session.pop(session_id, None)
587
+ welcome = self.session_registry_controller.welcome_by_session.get(session_id)
588
+ if welcome is None:
589
+ return
590
+ welcome.set_loading(False)
591
+ if message:
592
+ welcome.show_status(message)