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
File without changes
@@ -0,0 +1,40 @@
1
+ """Name the per-user INI file that holds GitPane's preferences.
2
+
3
+ It is the only location, so a pip or pipx install, whose package folder does not belong to the user, keeps the same preferences.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from typing import TYPE_CHECKING
10
+
11
+ from gitpane.config import APPLICATION_NAME, WINDOWS_PLATFORM
12
+ from gitpane.system.xdg_session import xdg_config_home
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+ PREFERENCE_BASENAME = "gitpane.ini"
18
+ APPDATA_ENVIRONMENT_NAME = "APPDATA"
19
+ # Where APPDATA points by default under the user's home folder, used when the variable is missing.
20
+ WINDOWS_ROAMING_DIR_PARTS = ("AppData", "Roaming")
21
+ PREFERENCE_FILE_LOG_TEXT = "Preferences file: {filename}"
22
+
23
+
24
+ def user_preference_dir(platform: str, environment_by_name: Mapping[str, str], home_dir: str) -> str:
25
+ """Name the per-user folder that holds the preferences file.
26
+
27
+ A relative XDG_CONFIG_HOME is ignored, as the XDG specification asks, so the file never lands in the current folder.
28
+ """
29
+ if platform == WINDOWS_PLATFORM:
30
+ roaming_dir = environment_by_name.get(APPDATA_ENVIRONMENT_NAME, "") or os.path.join(home_dir, *WINDOWS_ROAMING_DIR_PARTS)
31
+ return os.path.join(roaming_dir, APPLICATION_NAME)
32
+ return os.path.join(xdg_config_home(environment_by_name, home_dir), APPLICATION_NAME)
33
+
34
+
35
+ def user_preference_filename(platform: str, environment_by_name: Mapping[str, str], home_dir: str) -> str:
36
+ """Name the only preferences file GitPane uses when no file is given to it.
37
+
38
+ The system, the environment and the home folder are parameters, so every system's rule is testable from any machine.
39
+ """
40
+ return os.path.join(user_preference_dir(platform, environment_by_name, home_dir), PREFERENCE_BASENAME)
@@ -0,0 +1,590 @@
1
+ """Persist only changed preferences so separate launches can share storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ntpath
6
+ import os
7
+ import posixpath
8
+ import sys
9
+ from enum import Enum
10
+ from typing import TYPE_CHECKING, Self
11
+
12
+ from PySide6.QtCore import QObject, QSettings, Signal
13
+
14
+ from gitpane.config import (
15
+ ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY,
16
+ AUTHENTICATION_PREFERENCE_KEY,
17
+ CLONE_DESTINATION_LIMIT,
18
+ EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY,
19
+ LINUX_PLATFORM,
20
+ MACOS_PLATFORM,
21
+ OPERATION_KEEP_OPEN_PREFERENCE_KEY,
22
+ PUSH_TRACKING_PREFERENCE_KEY,
23
+ RECENT_LIMIT,
24
+ STAGING_PROPORTION_BY_KEY,
25
+ WINDOWS_PLATFORM,
26
+ )
27
+ from gitpane.recent.mount_table import case_insensitive_path_key
28
+ from gitpane.reference.stash import STASH_DEFAULT_BY_KEY
29
+ from gitpane.system.path_identity import on_disk_path
30
+ from gitpane.tool.editor_recipe import (
31
+ EDITOR_ARGUMENT_PREFERENCE_KEY,
32
+ EDITOR_DEFAULT_BY_KEY,
33
+ EDITOR_FILENAME_PREFERENCE_KEY,
34
+ EDITOR_INITIAL_PATH,
35
+ EDITOR_PLATFORM,
36
+ EDITOR_PRESET_PREFERENCE_KEY,
37
+ EditorRecipe,
38
+ build_editor_arguments,
39
+ detect_editor_recipe,
40
+ )
41
+ from gitpane.tool.kdiff3 import detect_kdiff3_filename
42
+ from gitpane.tool.mergetool_recipe import MERGETOOL_COMMAND_PREFERENCE_KEY, MERGETOOL_DEFAULT_BY_KEY, MERGETOOL_FILENAME_PREFERENCE_KEY, MergetoolRecipe, parse_mergetool_command
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Mapping
46
+ from types import ModuleType
47
+
48
+
49
+ FETCH_DEFAULT_PREFERENCE_KEY = "fetch_default"
50
+
51
+
52
+ class FetchMode(Enum):
53
+ """What a Fetch does: fetch the one remote of the current branch, or fetch every remote and prune what they deleted.
54
+
55
+ The value is the word saved in the preferences file, which every system and older GitPane versions read; the label
56
+ is the text of the Fetch menus, which list the modes in this order.
57
+ """
58
+
59
+ FETCH = ("fetch", "Fetch")
60
+ PRUNE_ALL = ("prune-all", "Fetch and prune all")
61
+
62
+ def __new__(cls, value: str, label: str) -> Self:
63
+ """Keep the saved word as the value and the menu text beside it."""
64
+ member = object.__new__(cls)
65
+ member._value_ = value
66
+ member.label = label
67
+ return member
68
+
69
+
70
+ FETCH_INITIAL_MODE = FetchMode.PRUNE_ALL
71
+
72
+
73
+ def fetch_mode_preference(value: object) -> FetchMode:
74
+ """Read a saved default Fetch, falling back to the initial mode for a word this version does not know, an empty value or another type.
75
+
76
+ The file may have been edited by hand or written by a later GitPane: the Fetch button must still have a mode.
77
+ """
78
+ try:
79
+ return FetchMode(value)
80
+ except ValueError:
81
+ return FETCH_INITIAL_MODE
82
+
83
+
84
+ # Parent folders of the last clones that really started, the most recent first, offered by the next clone form.
85
+ CLONE_DESTINATION_DIRS_PREFERENCE_KEY = "clone_destination_dirs"
86
+ CONFLICT_PROMPT_PREFERENCE_KEY = "conflict_prompt_suppressed"
87
+ REBASE_PROMPT_PREFERENCE_KEY = "rebase_prompt_suppressed"
88
+ # Shown by Open repository and Settings, and raised by every refused save: they name the file and say what to do, nothing else.
89
+ PREFERENCE_UNREADABLE_TEXT = "The preferences file cannot be read: {filename}. Fix or delete it, then restart GitPane. Nothing is saved until then."
90
+ PREFERENCE_UNWRITABLE_TEXT = "The preferences file cannot be saved: {filename}. Check that the file is not read-only and that its folder accepts new files."
91
+
92
+ PREFERENCE_DEFAULT_BY_KEY: dict[str, object] = {
93
+ "git_filename": "", "terminal_id": "", "terminal_label": "", "terminal_filename": "", "recent_dirs": [],
94
+ "difftool_filename": "", "difftool_command": '"$LOCAL" "$REMOTE"',
95
+ "ssh_filename": "", "git_utility_dir": "",
96
+ OPERATION_KEEP_OPEN_PREFERENCE_KEY: True,
97
+ FETCH_DEFAULT_PREFERENCE_KEY: FETCH_INITIAL_MODE.value,
98
+ CLONE_DESTINATION_DIRS_PREFERENCE_KEY: [],
99
+ CONFLICT_PROMPT_PREFERENCE_KEY: False,
100
+ REBASE_PROMPT_PREFERENCE_KEY: False,
101
+ "diff_splitter_state": "", "tree_splitter_state": "", "file_history_splitter_state": "", "diff_show_entire_file": False,
102
+ EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY: "",
103
+ AUTHENTICATION_PREFERENCE_KEY: '{"accounts": [], "global": {}, "repository": {}}',
104
+ ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY: "{}",
105
+ PUSH_TRACKING_PREFERENCE_KEY: "{}",
106
+ **STAGING_PROPORTION_BY_KEY,
107
+ **STASH_DEFAULT_BY_KEY,
108
+ **MERGETOOL_DEFAULT_BY_KEY,
109
+ **EDITOR_DEFAULT_BY_KEY,
110
+ }
111
+ # Folder lists and the number of folders each keeps, the most recent first.
112
+ DIR_LIST_LIMIT_BY_KEY = {"recent_dirs": RECENT_LIMIT, CLONE_DESTINATION_DIRS_PREFERENCE_KEY: CLONE_DESTINATION_LIMIT}
113
+ # How each system writes, folds and recognizes absolute paths: Windows from a drive letter or a network share with
114
+ # backslashes, Linux and macOS from the root with forward slashes. A system missing here is refused, never guessed.
115
+ PATH_MODULE_BY_PLATFORM = {WINDOWS_PLATFORM: ntpath, LINUX_PLATFORM: posixpath, MACOS_PLATFORM: posixpath}
116
+ # Diagnostic log line for a folder of a saved list that another system wrote: the preferences file may be shared between
117
+ # systems, so the folder stays in the file for that system and is only left out of the lists shown here.
118
+ FOREIGN_DIR_LOG_TEXT = "Folder written by another system kept in {key} and not used here: {folder}"
119
+
120
+
121
+ def _path_module(platform: str) -> ModuleType:
122
+ """Return the path rules of ``platform``, raising OSError for a system GitPane does not know rather than guessing its rules."""
123
+ path_module = PATH_MODULE_BY_PLATFORM.get(platform)
124
+ if path_module is None:
125
+ raise OSError("No path rules are known for " + platform)
126
+ return path_module
127
+
128
+
129
+ def saved_recent_identity(recent_dir: str, platform: str = sys.platform) -> str:
130
+ """Return the key that tells whether two entries of the recent history name the same repository, without touching the disk.
131
+
132
+ The history is saved with its paths already resolved, so its entries compare as normalized text: a path written
133
+ differently (trailing separator, ``.`` segment, other case on Windows) still matches. On macOS another letter case
134
+ or Unicode form (composed or decomposed accents) is the same entry too, as on its disks by default; Linux compares
135
+ the paths as written. The key stays text: resolving each entry again would open its folder, and a disconnected
136
+ network drive in the history would freeze the window while the system waits. A system without known path rules
137
+ raises OSError.
138
+ """
139
+ path_module = _path_module(platform)
140
+ absolute_dir = path_module.abspath(recent_dir)
141
+ if platform == MACOS_PLATFORM:
142
+ return case_insensitive_path_key(absolute_dir)
143
+ return path_module.normcase(absolute_dir)
144
+
145
+
146
+ def split_dir_entries(value: list[str], platform: str = sys.platform) -> tuple[list[str], list[str]]:
147
+ """Split saved folders into those of ``platform``, absolute by its own path rules, and those another system wrote.
148
+
149
+ A preferences file can be shared between Windows, Linux and macOS: a Windows folder such as ``E:\\work`` is not an
150
+ absolute path on macOS and must never be read as a folder below the current one, and a POSIX folder is not absolute
151
+ on Windows. A relative entry is absolute on no system and goes with the other system's entries. Both parts keep the
152
+ saved order; nothing is read on disk. A system without known path rules raises OSError.
153
+ """
154
+ path_module = _path_module(platform)
155
+ own_dirs = []
156
+ foreign_dirs = []
157
+ for folder in value:
158
+ if path_module.isabs(folder):
159
+ own_dirs.append(folder)
160
+ else:
161
+ foreign_dirs.append(folder)
162
+ return own_dirs, foreign_dirs
163
+
164
+
165
+ def saved_dir_entries(value: object) -> list[str]:
166
+ """Return a saved folder list as a list of folder texts, raising TypeError or ValueError when it cannot be one.
167
+
168
+ The INI file gives an empty list back as None and a one-folder list as plain text, both taken back as lists here.
169
+ """
170
+ if value is None:
171
+ return []
172
+ if isinstance(value, str):
173
+ return [value] if value else []
174
+ if not isinstance(value, list):
175
+ raise TypeError("Unreadable folder list")
176
+ for folder in value:
177
+ if not isinstance(folder, str) or not folder:
178
+ raise ValueError("Unreadable folder path")
179
+ return list(value)
180
+
181
+
182
+ def foreign_saved_dirs(value: object, platform: str = sys.platform) -> list[str]:
183
+ """Return the folders of a saved folder list that another system wrote, in saved order, or none when the list is unreadable.
184
+
185
+ An unreadable list has its own warning, so it gives no foreign folder; a system without known path rules raises OSError.
186
+ """
187
+ try:
188
+ entries = saved_dir_entries(value)
189
+ except (TypeError, ValueError):
190
+ return []
191
+ return split_dir_entries(entries, platform)[1]
192
+
193
+
194
+ def normalized_dir_list(value: object, limit: int, platform: str = sys.platform) -> list[str]:
195
+ """Check a saved folder list and return the folders of this system, normalized, without repeats, cut to ``limit``, without touching the disk.
196
+
197
+ Folders another system wrote are left out, never normalized: making them absolute would join them to the current
198
+ folder. Folders compare by the key of saved_recent_identity, and the first spelling of a folder is kept with its
199
+ place: the list is saved most recent first, so two spellings of one folder merge into the most recent one.
200
+ """
201
+ own_dirs, _foreign_dirs = split_dir_entries(saved_dir_entries(value), platform)
202
+ path_module = _path_module(platform)
203
+ normalized_dirs = []
204
+ identity_set: set[str] = set()
205
+ for folder in own_dirs:
206
+ normalized_dir = path_module.abspath(folder)
207
+ identity = saved_recent_identity(normalized_dir, platform)
208
+ if identity not in identity_set:
209
+ normalized_dirs.append(normalized_dir)
210
+ identity_set.add(identity)
211
+ return normalized_dirs[:limit]
212
+
213
+
214
+ def saved_recent_dir(repository_dir: str) -> str:
215
+ """Return the form in which a repository is saved in the recent history: absolute, spelled as the disk spells it.
216
+
217
+ On macOS that is the letter case and Unicode form the disk keeps, whatever was typed, so the history and the
218
+ window title name the folder the same way; elsewhere it is the path with its links resolved, as before. Paired with
219
+ saved_recent_identity, it gives a repository that is not read from the history, such as the one a window shows,
220
+ the same key as its saved entry, so the switch menu lists it once. It touches the disk, so it is only for a path
221
+ known to be reachable, never for each entry of the history.
222
+ """
223
+ return on_disk_path(os.path.abspath(repository_dir))
224
+
225
+
226
+ class PreferenceStore(QObject):
227
+ """Read fresh settings and synchronize individual changes to local storage."""
228
+
229
+ changed = Signal(object)
230
+ warning_raised = Signal(str)
231
+
232
+ def __init__(self, setting: QSettings) -> None:
233
+ """Use injected storage to keep application preferences independently testable."""
234
+ super().__init__()
235
+ self.setting = setting
236
+ self.value_by_key = dict(PREFERENCE_DEFAULT_BY_KEY)
237
+ self.warning = ""
238
+ # One diagnostic line per saved folder another system wrote, refreshed with the values: the launch writes them
239
+ # to the --log file, never to a window, since the folder is only kept for that other system.
240
+ self.foreign_dir_lines: list[str] = []
241
+ # Whether the file was found read-only, checked only at launch and after a refused save, never on each read: Qt
242
+ # tests it by opening the file for writing outside its own lock, which can make another GitPane's save fail.
243
+ self.unwritable = False
244
+ self.refresh()
245
+ self._check_writable()
246
+
247
+ def refresh(self) -> None:
248
+ """Merge external changes without discarding valid keys when another is unreadable.
249
+
250
+ Folders another system wrote in the folder lists are left out of the values and listed in ``foreign_dir_lines``;
251
+ reading never rewrites the file, so they stay in it.
252
+ """
253
+ self.setting.sync()
254
+ try:
255
+ self._check_status()
256
+ except OSError as error:
257
+ self._warn(str(error))
258
+ return
259
+ changed_value_by_key = {}
260
+ self.warning = ""
261
+ if self.unwritable:
262
+ self._warn(self._unwritable_text())
263
+ foreign_dir_lines = []
264
+ for key, default in PREFERENCE_DEFAULT_BY_KEY.items():
265
+ value = self.setting.value(key, default)
266
+ if key in DIR_LIST_LIMIT_BY_KEY:
267
+ for foreign_dir in foreign_saved_dirs(value):
268
+ foreign_dir_lines.append(FOREIGN_DIR_LOG_TEXT.format(key=key, folder=foreign_dir))
269
+ try:
270
+ value = self._validate(key, value)
271
+ except (TypeError, ValueError):
272
+ self._warn(f"Unreadable preference ignored : {key}.")
273
+ continue
274
+ if self.value_by_key[key] != value:
275
+ self.value_by_key[key] = value
276
+ changed_value_by_key[key] = value
277
+ self.foreign_dir_lines = foreign_dir_lines
278
+ if changed_value_by_key:
279
+ self.changed.emit(changed_value_by_key)
280
+
281
+ def read(self, key: str, default: object = "") -> object:
282
+ """Import changes made by other processes before returning a preference."""
283
+ self.refresh()
284
+ value = self.value_by_key.get(key, default)
285
+ return list(value) if isinstance(value, list) else value
286
+
287
+ def read_saved(self, key: str) -> object:
288
+ """Return the value stored right now for this key, read and checked again, never this store's copy in memory.
289
+
290
+ ``read`` keeps showing the last good copy when the stored value cannot be read (written by another GitPane version,
291
+ say) or the storage cannot be synchronized. That is fine to show, but a value rewritten from that copy would undo
292
+ what another window saved meanwhile, so this raises OSError or ValueError instead.
293
+ """
294
+ self.setting.sync()
295
+ self._check_status()
296
+ value = self.setting.value(key, PREFERENCE_DEFAULT_BY_KEY.get(key))
297
+ try:
298
+ value = self._validate(key, value)
299
+ except TypeError as error:
300
+ raise ValueError(str(error)) from error
301
+ return list(value) if isinstance(value, list) else value
302
+
303
+ def contains_saved(self, key: str) -> bool:
304
+ """Distinguish absent keys from saved empty values without hiding synchronization errors."""
305
+ self.setting.sync()
306
+ try:
307
+ self._check_status()
308
+ saved = self.setting.contains(key)
309
+ self._check_status()
310
+ except OSError as error:
311
+ self._warn(str(error))
312
+ raise
313
+ return saved
314
+
315
+ def initialize_difftool(self, *, save: bool = True) -> str:
316
+ """Return the diff tool executable a comparison starts: the saved one, or else the KDiff3 detected on this system.
317
+
318
+ Settings shows that same detected KDiff3 while no diff tool is saved, so what it shows is what a comparison uses.
319
+ The detected executable is saved, with the arguments in effect, only when ``save`` is set, which the comparison
320
+ itself does: opening Settings writes nothing. A refused write is reported and the detected KDiff3 is still used.
321
+ """
322
+ self.refresh()
323
+ saved_filename = str(self.value_by_key["difftool_filename"])
324
+ if saved_filename:
325
+ return saved_filename
326
+ detected_filename = detect_kdiff3_filename(sys.platform)
327
+ if not detected_filename or not save:
328
+ return detected_filename
329
+ try:
330
+ self.update({"difftool_filename": detected_filename, "difftool_command": str(self.value_by_key["difftool_command"])})
331
+ except OSError as error:
332
+ self._warn(f"Cannot save the detected external diff tool: {error}")
333
+ return detected_filename
334
+
335
+ def initialize_mergetool(self, *, save_detected: bool = True) -> str:
336
+ """Return the merge tool executable conflict resolution uses, choosing a KDiff3 on first use.
337
+
338
+ A saved merge path, even an emptied one, or saved arguments alone are kept as they are. Otherwise a KDiff3 saved as
339
+ the diff tool is saved as the merge tool too, and failing that the KDiff3 detected on this system is used. That
340
+ detected one is saved, with the default arguments, only when ``save_detected`` is set, which the conflict action
341
+ does: starting GitPane or opening Settings writes nothing for it.
342
+ """
343
+ self.refresh()
344
+ filename = str(self.value_by_key[MERGETOOL_FILENAME_PREFERENCE_KEY])
345
+ try:
346
+ if self.contains_saved(MERGETOOL_FILENAME_PREFERENCE_KEY) or self.contains_saved(MERGETOOL_COMMAND_PREFERENCE_KEY):
347
+ return filename
348
+ candidate_filename = str(self.value_by_key["difftool_filename"])
349
+ if os.path.basename(candidate_filename).casefold() not in ("kdiff3", "kdiff3.exe"):
350
+ detected_filename = detect_kdiff3_filename(sys.platform)
351
+ if not detected_filename:
352
+ return filename
353
+ if not save_detected:
354
+ return detected_filename
355
+ candidate_filename = detected_filename
356
+ filename = candidate_filename
357
+ recipe = MergetoolRecipe(filename)
358
+ try:
359
+ recipe.validate()
360
+ except ValueError:
361
+ return filename
362
+ self.update({MERGETOOL_FILENAME_PREFERENCE_KEY: filename, MERGETOOL_COMMAND_PREFERENCE_KEY: recipe.command})
363
+ except OSError as error:
364
+ self._warn(f"Cannot initialize the external merge tool: {error}")
365
+ return filename
366
+
367
+ def editor_recipe(self) -> EditorRecipe:
368
+ """Read the common editor choice as one recipe after refreshing persisted values."""
369
+ self.refresh()
370
+ return EditorRecipe(str(self.value_by_key[EDITOR_PRESET_PREFERENCE_KEY]), str(self.value_by_key[EDITOR_FILENAME_PREFERENCE_KEY]), str(self.value_by_key[EDITOR_ARGUMENT_PREFERENCE_KEY]))
371
+
372
+ def editor_choice_saved(self) -> bool:
373
+ """Tell whether Settings holds an editor choice, which then comes before any detected editor.
374
+
375
+ One saved editor key is enough, even an empty one: the user chose it. A settings file that cannot be read counts
376
+ as saved, so what it holds is kept rather than replaced by a detection; the problem is already reported.
377
+ """
378
+ try:
379
+ for key in EDITOR_DEFAULT_BY_KEY:
380
+ if self.contains_saved(key):
381
+ return True
382
+ except OSError:
383
+ return True
384
+ return False
385
+
386
+ def initialize_editor(self, *, platform: str = EDITOR_PLATFORM, search_path: str = EDITOR_INITIAL_PATH, repository_dirs: tuple[str, ...] = (),
387
+ flatpak_dirs: tuple[str, ...] | None = None, system_editor_id: str = "") -> EditorRecipe:
388
+ """Answer the saved editor choice, or else the editor detected on this system, which is used as it is and never saved.
389
+
390
+ Settings shows the detected editor and Show in text editor opens it without a Save; only the user's Save writes
391
+ an editor choice. ``system_editor_id`` is the editor the system names for plain text, read elsewhere and off the
392
+ window's thread; it only steers the detection and is never saved here either. Without ``flatpak_dirs``, the
393
+ detection looks in the session's Flatpak installations.
394
+ """
395
+ previous = self.editor_recipe()
396
+ try:
397
+ if self.editor_choice_saved():
398
+ return previous
399
+ return detect_editor_recipe(platform=platform, search_path=search_path, repository_dirs=repository_dirs, flatpak_dirs=flatpak_dirs, system_editor_id=system_editor_id)
400
+ except OSError as error:
401
+ self._warn(f"Cannot initialize the external editor: {error}")
402
+ return previous
403
+
404
+ def update(self, changed_value_by_key: Mapping[str, object]) -> None:
405
+ """Write only the supplied keys and report storage failures immediately.
406
+
407
+ A folder list is written as this system's folders followed by the folders another system wrote, read again from
408
+ the file right before the write and each part cut to the list limit: the file may be shared with that system, so
409
+ none of its folders is ever dropped here. The values kept in memory and emitted hold this system's folders only.
410
+ """
411
+ self.refresh()
412
+ self._check_status()
413
+ accepted_value_by_key = {}
414
+ written_value_by_key = {}
415
+ previous_value_by_key = {}
416
+ for key, value in changed_value_by_key.items():
417
+ accepted_value = self._validate(key, value)
418
+ previous_value = self.setting.value(key)
419
+ accepted_value_by_key[key] = accepted_value
420
+ previous_value_by_key[key] = previous_value
421
+ written_value_by_key[key] = accepted_value
422
+ if key in DIR_LIST_LIMIT_BY_KEY:
423
+ written_value_by_key[key] = [*accepted_value, *foreign_saved_dirs(previous_value)[:DIR_LIST_LIMIT_BY_KEY[key]]]
424
+ for key, value in written_value_by_key.items():
425
+ self.setting.setValue(key, value)
426
+ self.setting.sync()
427
+ try:
428
+ self._check_status()
429
+ except OSError:
430
+ # QSettings buffers failed writes; restore them so a later sync cannot publish a rejected draft.
431
+ for key, previous_value in previous_value_by_key.items():
432
+ if previous_value is None:
433
+ self.setting.remove(key)
434
+ else:
435
+ self.setting.setValue(key, previous_value)
436
+ self.setting.sync()
437
+ self._check_writable()
438
+ raise
439
+ # A save that went through proves the file is writable again, so the read-only warning goes.
440
+ if self.unwritable:
441
+ self.unwritable = False
442
+ if self.warning == self._unwritable_text():
443
+ self.warning = ""
444
+ actual_value_by_key = {}
445
+ for key, value in accepted_value_by_key.items():
446
+ if self.value_by_key[key] != value:
447
+ self.value_by_key[key] = value
448
+ actual_value_by_key[key] = value
449
+ if actual_value_by_key:
450
+ self.changed.emit(actual_value_by_key)
451
+
452
+ def read_recent(self) -> list[str]:
453
+ """Return successful repository paths in persisted recency order."""
454
+ return self.read("recent_dirs", [])
455
+
456
+ def remember(self, repository_dir: str) -> None:
457
+ """Move a successful repository to the front without overwriting other keys; repositories another system saved stay in the file."""
458
+ recent_dirs = [saved_recent_dir(repository_dir), *self.read_recent()]
459
+ self.update({"recent_dirs": recent_dirs})
460
+
461
+ def read_clone_destinations(self) -> list[str]:
462
+ """Return the parent folders of the last started clones, the most recent first, as saved: none is looked at on disk."""
463
+ return self.read(CLONE_DESTINATION_DIRS_PREFERENCE_KEY, [])
464
+
465
+ def remember_clone_destination(self, parent_dir: str) -> None:
466
+ """Put the parent folder of a clone that really started at the front of the destination list, without other keys changing.
467
+
468
+ The same folder written differently moves to the front instead of being listed twice, and the oldest folder
469
+ beyond the limit is dropped. The folder is saved as typed, made absolute, never resolved on disk. Folders another
470
+ system saved stay in the file.
471
+ """
472
+ destination_dirs = [os.path.abspath(parent_dir), *self.read_clone_destinations()]
473
+ self.update({CLONE_DESTINATION_DIRS_PREFERENCE_KEY: destination_dirs})
474
+
475
+ def forget(self, repository_dirs: list[str]) -> None:
476
+ """Remove these repositories from the recent history, for example folders that no longer exist.
477
+
478
+ Only the saved list changes, in one write: the folders themselves are never touched. The paths are compared as
479
+ saved, never resolved on disk, since they usually come from the history itself and name folders that may be
480
+ unreachable. Nothing is emitted when none of them is in the history, and a storage refusal raises OSError with
481
+ the previous list kept. Repositories another system saved are never removed from the file.
482
+ """
483
+ forgotten_identity_set: set[str] = set()
484
+ for repository_dir in repository_dirs:
485
+ forgotten_identity_set.add(saved_recent_identity(repository_dir))
486
+ kept_dirs = []
487
+ for recent_dir in self.read_recent():
488
+ if saved_recent_identity(recent_dir) not in forgotten_identity_set:
489
+ kept_dirs.append(recent_dir)
490
+ self.update({"recent_dirs": kept_dirs})
491
+
492
+ def _validate(self, key: str, value: object) -> object:
493
+ """Reject malformed values per key and normalize only folder lists.
494
+
495
+ The recent history and the clone destinations are normalized as text, never resolved on disk: every read of any
496
+ preference validates them, and an unreachable network folder in them would freeze the window each time.
497
+ ``remember`` resolves the one path it saves, so the saved history entries are already resolved.
498
+ """
499
+ if key not in PREFERENCE_DEFAULT_BY_KEY:
500
+ raise ValueError(f"Unknown preference : {key}")
501
+ if key == AUTHENTICATION_PREFERENCE_KEY:
502
+ from gitpane.account.authentication import validate_selection_json
503
+ return validate_selection_json(value)
504
+ if key == ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY:
505
+ from gitpane.account.authentication import validate_sign_in_method_json
506
+ return validate_sign_in_method_json(value)
507
+ if key == PUSH_TRACKING_PREFERENCE_KEY:
508
+ from gitpane.transport.push_tracking import validate_push_tracking_json
509
+ return validate_push_tracking_json(value)
510
+ if key == FETCH_DEFAULT_PREFERENCE_KEY:
511
+ # Only the saved word is accepted, never a member: Qt would write an object to the file in a form no other
512
+ # system or GitPane version can read.
513
+ if isinstance(value, str):
514
+ try:
515
+ FetchMode(value)
516
+ return value
517
+ except ValueError:
518
+ pass
519
+ raise ValueError("Choose Fetch or Fetch and prune all as the default.")
520
+ if key in STAGING_PROPORTION_BY_KEY:
521
+ if not isinstance(value, (list, tuple)) or len(value) != len(STAGING_PROPORTION_BY_KEY[key]):
522
+ raise TypeError(f"Unreadable preference : {key}")
523
+ sizes: list[int] = []
524
+ for size in value:
525
+ # INI storage reads each list item back as text after a fresh process opens the file.
526
+ if isinstance(size, str) and size.isdigit():
527
+ size = int(size)
528
+ if type(size) is not int or size <= 0:
529
+ raise ValueError(f"Unreadable preference : {key}")
530
+ sizes.append(size)
531
+ return sizes
532
+ if key in {"diff_show_entire_file", OPERATION_KEEP_OPEN_PREFERENCE_KEY, CONFLICT_PROMPT_PREFERENCE_KEY, REBASE_PROMPT_PREFERENCE_KEY} or key in STASH_DEFAULT_BY_KEY:
533
+ if type(value) is bool:
534
+ return value
535
+ # INI storage reads booleans as strings after a fresh process opens the file.
536
+ if value in ("true", "false"):
537
+ return value == "true"
538
+ raise TypeError(f"Unreadable preference : {key}")
539
+ if key in DIR_LIST_LIMIT_BY_KEY:
540
+ try:
541
+ return normalized_dir_list(value, DIR_LIST_LIMIT_BY_KEY[key])
542
+ except TypeError as error:
543
+ raise TypeError(f"Unreadable preference : {key}") from error
544
+ if not isinstance(value, str):
545
+ raise TypeError(f"Unreadable preference : {key}")
546
+ if key == MERGETOOL_COMMAND_PREFERENCE_KEY:
547
+ parse_mergetool_command(value, "base", "local", "remote", "merged")
548
+ if key in EDITOR_DEFAULT_BY_KEY and "\0" in value:
549
+ raise ValueError("External editor settings cannot contain a null character.")
550
+ if key == EDITOR_ARGUMENT_PREFERENCE_KEY:
551
+ build_editor_arguments(value, os.path.abspath("editor-file"))
552
+ return value
553
+
554
+ def _check_writable(self) -> None:
555
+ """Look once whether the settings file can be saved, and warn when it cannot, so a read-only file is named before a save fails.
556
+
557
+ Only called at launch and after a refused save: a readable status is required first, since a file Qt could not
558
+ read already has its own warning.
559
+ """
560
+ if self.setting.status() != QSettings.Status.NoError:
561
+ return
562
+ self.unwritable = not self.setting.isWritable()
563
+ if self.unwritable:
564
+ self._warn(self._unwritable_text())
565
+
566
+ def _unwritable_text(self) -> str:
567
+ """Say that the settings file cannot be saved, naming it."""
568
+ return PREFERENCE_UNWRITABLE_TEXT.format(filename=self._display_filename())
569
+
570
+ def _warn(self, warning: str) -> None:
571
+ """Keep the latest problem for the windows that show it when they open, and announce it to live listeners."""
572
+ self.warning = warning
573
+ self.warning_raised.emit(warning)
574
+
575
+ def _check_status(self) -> None:
576
+ """Raise OSError naming the settings file when Qt could not parse it or could not save it.
577
+
578
+ A file Qt could not parse stays in that state for the whole session, so every later save is refused: writing
579
+ would replace the file with the few keys Qt understood and lose what the user edited by hand.
580
+ """
581
+ status = self.setting.status()
582
+ if status == QSettings.Status.NoError:
583
+ return
584
+ if status == QSettings.Status.FormatError:
585
+ raise OSError(PREFERENCE_UNREADABLE_TEXT.format(filename=self._display_filename()))
586
+ raise OSError(self._unwritable_text())
587
+
588
+ def _display_filename(self) -> str:
589
+ """Return the settings file path the way the system writes it, since Qt reports it with forward slashes."""
590
+ return os.path.abspath(self.setting.fileName())
File without changes