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
gitpane/config.py ADDED
@@ -0,0 +1,1066 @@
1
+ """Definitions that several GitPane modules must agree on, from shared values to the sizing rules of controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ from dataclasses import dataclass
7
+ from enum import Enum, IntEnum
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from PySide6.QtCore import QMargins, QPoint, QRect
12
+ from PySide6.QtGui import QScreen
13
+ from PySide6.QtWidgets import QWidget
14
+
15
+ # Start of the names of the trace variables of Git and of the Git Credential Manager. Every place that removes traces
16
+ # from the environment of a program it starts reads this one value.
17
+ TRACE_ENVIRONMENT_PREFIXES = ("GIT_TRACE", "GCM_TRACE")
18
+ # Variables of GitPane itself and of Git or credential manager traces: a text editor GitPane starts to open a working
19
+ # copy file must not inherit them.
20
+ EDITOR_HIDDEN_ENVIRONMENT_PREFIXES = ("GITPANE_", *TRACE_ENVIRONMENT_PREFIXES)
21
+ ERROR_LIGHT_COLOR = "#b42318"
22
+ ERROR_DARK_COLOR = "#ff8a80"
23
+ # Green of a successful result, such as a Settings save, readable on a light or on a dark background.
24
+ SUCCESS_LIGHT_COLOR = "#1a7f37"
25
+ SUCCESS_DARK_COLOR = "#7ee2a8"
26
+ # Orange of a result that is not an error but asks to switch to another method (a server that refuses passwords, for
27
+ # example). Each keeps a contrast of at least 4.5:1 with its theme's background and stays clearly apart from the red.
28
+ WARNING_LIGHT_COLOR = "#b45309"
29
+ WARNING_DARK_COLOR = "#fdba74"
30
+ # Yellow of a recent folder whose check got no answer (Unknown): apart from the grey path, so it is noticed, and from the
31
+ # orange of Unavailable, so it is not read as gone. A dark gold on light backgrounds and a bright yellow on dark ones,
32
+ # each with a contrast of at least 4.5:1.
33
+ UNKNOWN_LIGHT_COLOR = "#8a6d00"
34
+ UNKNOWN_DARK_COLOR = "#fde047"
35
+ DARK_BACKGROUND_LIGHTNESS_LIMIT = 0.5
36
+ EDITOR_CONNECT_TIMEOUT_MS = 5000
37
+ EDITOR_LIVENESS_TIMEOUT_MS = 5000
38
+ EDITOR_HEARTBEAT_INTERVAL_MS = 250
39
+ # How long GitPane keeps a connection it answered open, for the bridge to read the answer and close it itself; past
40
+ # this delay, GitPane closes it.
41
+ EDITOR_ANSWERED_CLOSE_TIMEOUT_MS = 5000
42
+ EDITOR_IPC_LIMIT_BYTES = 65536
43
+ EDITOR_PENDING_LIMIT = 4
44
+ EDITOR_REQUEST_LIMIT = 1024
45
+ EDITOR_TOKEN_BYTES = 32
46
+
47
+ # The longest a size change asked just after a window is shown waits for the compositor to answer that show (the
48
+ # activation delivered to the window, and the configure that comes with it applied by Qt). Mutter answers in 8 to 15 ms,
49
+ # and Qt applies that configure at the end of the frame in progress; a window it never activates (opened while the user
50
+ # is in another application) still gets its size change once this bound is reached.
51
+ COMPOSITOR_ANSWER_TIMEOUT_MS = 1_000
52
+ # The longest one click waits, on the window's thread, for the Wayland compositor while GitPane asks for an activation
53
+ # token: binding the protocol the first time, the window that has the keyboard, then the token, all within this one
54
+ # bound. Mutter answers within a millisecond; a compositor that does not answer leaves the program to start without a
55
+ # token instead of freezing GitPane.
56
+ WAYLAND_ACTIVATION_TOKEN_TIMEOUT_MS = 500
57
+ # Longest activation token GitPane hands to a program; a longer one is refused. Mutter's tokens are about 45
58
+ # characters long (a UUID, "_TIME" and a time).
59
+ ACTIVATION_TOKEN_MAX_LENGTH = 256
60
+ # The longest each read of gnome-extensions may take when Settings checks whether GNOME can show desktop icons. The
61
+ # tool answers in a few milliseconds; the bound only matters for a loaded session or a Shell that does not answer, and
62
+ # a read with no answer in time gives no note rather than a wrong one.
63
+ SHELL_EXTENSION_READ_TIMEOUT_MS = 3_000
64
+ # The longest each of the three reads of the Secret Service may take when GitPane checks the default keyring collection.
65
+ # Each read answers in about 0.5 ms; a read with no answer in time counts as an unknown state, which gives no sentence.
66
+ KEYRING_READ_TIMEOUT_MS = 500
67
+ # The longest the read of the desktop's light or dark preference may take, through the Settings portal, when GitPane
68
+ # starts. The portal answers in under a millisecond; a read with no answer in time counts as no preference, which keeps
69
+ # the appearance Qt shows without it.
70
+ PORTAL_SETTING_READ_TIMEOUT_MS = 500
71
+ # The longest the gitpane command typed in a terminal waits for GitPane, started again in a session of its own, to
72
+ # show its first window before it gives the terminal back. A cold start on a slow disk takes a few seconds; past this
73
+ # delay the command gives the terminal back and says so, and GitPane goes on starting without that terminal.
74
+ DETACHED_READY_TIMEOUT_MS = 30_000
75
+ # How long the gitpane command still reads a GitPane that closed its ready channel without the ready signal, then waits
76
+ # for its end, counted from that close whatever is left of the wait above. A program GitPane started can keep its
77
+ # output open after GitPane ended, and a process ends a little after its channel closes: past this delay the command
78
+ # reports what it has and gives the terminal back.
79
+ DETACHED_STOP_GRACE_MS = 5_000
80
+ GIT_OPEN_TIMEOUT_MS = 5_000
81
+ # Characters refused in any name or revision placed on a Git command line: none can belong to a reference name or
82
+ # a revision, and each could split what Git or the logs receive.
83
+ COMMAND_LINE_CONTROL_CHARACTER_SET = frozenset({"\0", "\n", "\r"})
84
+ GIT_READ_TIMEOUT_MS = 30_000
85
+ # The longest a reference form waits, unseen, for its opening check. That check runs at most two batches of Git
86
+ # reads, each bounded by GIT_READ_TIMEOUT_MS, plus reads of reference files and its wait in the process queue: a slow
87
+ # Git is still explained by its own error, and this limit only ends a check that never answers (an error the read
88
+ # service does not catch, or a file read that blocks).
89
+ FORM_OPENING_CHECK_TIMEOUT_MS = 3 * GIT_READ_TIMEOUT_MS
90
+ # The whole history is read by one streamed process: a history of several hundred thousand commits can take a few
91
+ # minutes to read and show. A Git that stops writing is still stopped much sooner by the ordinary inactivity limit.
92
+ HISTORY_STREAM_TIMEOUT_MS = 600_000
93
+ # While the history is read, its new rows are shown at most this often: the first rows appear at once, and the window
94
+ # redraws a few times per second instead of once per chunk of Git output.
95
+ HISTORY_PUBLISH_INTERVAL_MS = 200
96
+ GIT_EXECUTABLE_PROBE_LIMIT_BYTES = 4096
97
+ GIT_MUTATION_TIMEOUT_MS = 60_000
98
+ TRASH_TIMEOUT_MS = 30_000
99
+ GIT_COMMIT_TIMEOUT_MS = 120_000
100
+ COMMIT_IDLE_TIMEOUT_MS = 120_000
101
+ # The longest wait, from the click, before the --log timing of a commit stops with an "abandoned" line: a window whose
102
+ # history never shows the commit must not be followed for the rest of the session. Well above the commit's own process
103
+ # limit (GIT_COMMIT_TIMEOUT_MS), the refresh of a large repository and an ordinary push.
104
+ COMMIT_TIMING_LIMIT_MS = 600_000
105
+ MUTATION_IDLE_TIMEOUT_MS = 60_000
106
+ # Placed in front of a native write command so it never descends into a submodule, even when the user set
107
+ # submodule.recurse: a write of the parent repository must leave every file of every submodule untouched.
108
+ SUBMODULE_RECURSION_OFF_ARGUMENTS = ("-c", "submodule.recurse=false")
109
+ # Placed in front of the Git commands that write or verify a rewritten history. Under Windows, and under macOS on a
110
+ # case-insensitive disk, git init writes core.ignorecase=true even in a bare repository, and fast-import then folds the
111
+ # case of the paths it deletes: removing "Secret" also removes "SECRET". History removal compares recorded paths byte
112
+ # for byte and never touches files on disk through these commands, so the case of a path must always count.
113
+ EXACT_PATH_CASE_ARGUMENTS = ("-c", "core.ignorecase=false")
114
+ # Where Git keeps every full reference name, and the namespaces under it of local branches, remote branches and tags.
115
+ # They build the names and patterns given to Git (for-each-ref, refspecs) and read back what it lists.
116
+ REFERENCE_ROOT_PREFIX = "refs/"
117
+ LOCAL_PREFIX = REFERENCE_ROOT_PREFIX + "heads/"
118
+ REMOTE_PREFIX = REFERENCE_ROOT_PREFIX + "remotes/"
119
+ TAG_PREFIX = REFERENCE_ROOT_PREFIX + "tags/"
120
+ # What rev-parse --symbolic-full-name answers for a detached HEAD; the grouped HEAD read of a commit ends with the same word.
121
+ DETACHED_HEAD_NAME = "HEAD"
122
+ # Number of characters of a commit id that the forms and the notices of the repository window show.
123
+ SHORT_OID_LENGTH = 12
124
+ # Separator of the folders in the paths Git writes and reads, whatever the system: Git always uses "/".
125
+ GIT_PATH_SEPARATOR = "/"
126
+ # Unicode category of control characters (C0, DEL and C1): never part of a path a user copied, and escaped when a path Git cited is shown.
127
+ CONTROL_CHARACTER_CATEGORY = "Cc"
128
+ # End of a line in what Git prints and in the text of the operation console.
129
+ LINE_SEPARATOR = "\n"
130
+ # Qt keeps the lines of a plain text document apart with this character in its raw text. The numbered editor turns it
131
+ # into a line break, so a file that holds one stays read-only.
132
+ QT_PARAGRAPH_SEPARATOR = "\u2029"
133
+ WORKTREE_REFRESH_INTERVAL_MS = 5_000
134
+ REBASE_REFRESH_INTERVAL_MS = 1_000
135
+ REBASE_CAPTURE_ATTEMPTS = 3
136
+ REBASE_FILE_LIMIT_BYTES = 256 * 1024
137
+ REBASE_TOTAL_LIMIT_BYTES = 1024 * 1024
138
+ REBASE_STEP_LIMIT = 5_000
139
+ REBASE_CAPTURE_TIMEOUT_MS = GIT_READ_TIMEOUT_MS
140
+ REBASE_CLOSE_REREAD_ATTEMPTS = 5
141
+ # The folder of the common Git directory where the reftable format keeps references and their logs; the files format
142
+ # has none, and keeps them as files under refs/ and logs/ instead.
143
+ GIT_REFTABLE_DIR_BASENAME = "reftable"
144
+ # The file Git keeps in a worktree's own Git directory while a merge waits for its commit: one commit ID per line, the
145
+ # commits that become the parents after HEAD.
146
+ MERGE_HEAD_BASENAME = "MERGE_HEAD"
147
+ # The file next to it that tells how the merge was started; Git writes exactly this text into it when the merge must
148
+ # keep every parent (git merge --no-ff), and otherwise drops from the commit any parent another parent already contains.
149
+ MERGE_MODE_BASENAME = "MERGE_MODE"
150
+ MERGE_MODE_NO_FAST_FORWARD = b"no-ff"
151
+ # Largest MERGE_HEAD or MERGE_MSG read when a merge is concluded; a real one holds a few lines.
152
+ MERGE_STATE_FILE_LIMIT_BYTES = 1024 * 1024
153
+ REPLAY_FILE_LIMIT_BYTES = 1024 * 1024
154
+ REPLAY_TOTAL_LIMIT_BYTES = 4 * REPLAY_FILE_LIMIT_BYTES
155
+ REPLAY_INSTRUCTION_LIMIT = 10000
156
+ REPLAY_CAPTURE_TIMEOUT_MS = 5000
157
+ REPLAY_REFRESH_INTERVAL_MS = 2000
158
+ # Bounds on reading the Cherry-pick and Revert journals earlier versions left: the size of one record and how many are read.
159
+ REPLAY_RECORD_LIMIT_BYTES = 4 * 1024 * 1024
160
+ REPLAY_RECORD_COUNT_LIMIT = 1000
161
+ # How many files a refused Cherry-pick or Revert names when it would remove a submodule folder that still holds files
162
+ # its own history does not record.
163
+ SUBMODULE_LOSS_PATH_DISPLAY_LIMIT = 10
164
+ WORKTREE_OUTPUT_LIMIT_BYTES = 32 * 1024 * 1024
165
+ WORKTREE_INDEX_LIMIT_BYTES = 128 * 1024 * 1024
166
+ WORKTREE_FILE_WORKER_COUNT = 2
167
+ MUTATION_RECORD_LIMIT_BYTES = 64 * 1024
168
+ IGNORE_RULE_LIMIT_BYTES = 8 * 1024 * 1024
169
+ GIT_MUTATION_ENVIRONMENT_NAME_SET = frozenset({"GIT_INDEX_FILE"})
170
+ GIT_ALTERNATE_OBJECT_ENVIRONMENT_NAME = "GIT_ALTERNATE_OBJECT_DIRECTORIES"
171
+ GIT_INERT_EDITOR = ":"
172
+ STAGING_WIDTH, STAGING_HEIGHT = 1200, 800
173
+ STAGING_MIN_WIDTH, STAGING_MIN_HEIGHT = 800, 560
174
+ FILE_HISTORY_ACTIVE_COMPARISON_LIMIT = 1
175
+ FILE_HISTORY_BATCH_SIZE = 256
176
+ FILE_HISTORY_CACHE_ENTRY_LIMIT = 1024
177
+ FILE_HISTORY_COMPARISON_LIMIT = 10_000
178
+ FILE_HISTORY_TURN_BUDGET_MS = 5
179
+ # A Git-launched shell may describe its own repository; each read must discover the requested directory instead.
180
+ GIT_LOCAL_ENVIRONMENT_NAMES = (
181
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
182
+ "GIT_NAMESPACE", "GIT_PREFIX", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE", "GIT_REPLACE_REF_BASE",
183
+ "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM",
184
+ )
185
+ GIT_LOCAL_ENVIRONMENT_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
186
+ # Command-scope setting given to every Git command GitPane starts, as a (key, value) pair. Under macOS and Windows, a
187
+ # repository configured for fsmonitor makes Git start a background daemon that stays in the process group of the command
188
+ # that started it, and a supervised command only ends once its whole group is gone. Passed through GIT_CONFIG_COUNT in the
189
+ # environment rather than with -c, so no command line changes.
190
+ GIT_FSMONITOR_SETTING = ("core.fsmonitor", "false")
191
+ # Turns the millisecond time limits of this file into the seconds that Python's own waits take.
192
+ MILLISECONDS_PER_SECOND = 1000
193
+ # Turns a reading of the monotonic clock, in nanoseconds, into the milliseconds that timings are written in.
194
+ NANOSECONDS_PER_MILLISECOND = 1_000_000
195
+ # A shell reports a process ended by signal n with this base plus n, and so do GitPane's own exit codes.
196
+ SIGNAL_EXIT_CODE_BASE = 128
197
+ # Interpreter option that keeps the current folder (python -m) and the folder of a script started by its path (python
198
+ # script.py) out of sys.path, so a gitpane folder in the folder a program starts from is never imported instead of the
199
+ # installed GitPane.
200
+ SAFE_PATH_OPTION = "-P"
201
+ # Interpreter option that runs a module given by its dotted name.
202
+ MODULE_OPTION = "-m"
203
+ # Interpreter option that runs the Python text given as the next argument.
204
+ CODE_OPTION = "-c"
205
+ # Interpreter option of isolated mode: the user's Python environment variables, user site directory and current folder stay out of the child.
206
+ ISOLATED_MODE_OPTION = "-I"
207
+ # Interpreter option that loads no site directory at all, so only the standard library can be imported.
208
+ NO_SITE_OPTION = "-S"
209
+ # Interpreter options of a standard-library script GitPane starts: isolated mode, without any site directory.
210
+ ISOLATED_INTERPRETER_OPTIONS = (ISOLATED_MODE_OPTION, NO_SITE_OPTION)
211
+ # Interpreter option that ignores the PYTHON* variables the interpreter reads itself (PYTHONPATH, PYTHONHOME,
212
+ # PYTHONSTARTUP, PYTHONNOUSERSITE...). The site module still reads PYTHONUSERBASE, which moves the user's site-packages
213
+ # folder.
214
+ IGNORE_ENVIRONMENT_OPTION = "-E"
215
+ # Interpreter option that leaves the user's site-packages folder off the import path.
216
+ NO_USER_SITE_OPTION = "-s"
217
+ # Options of a GitPane module started as a program of its own (account worker, rebase editor bridge, SSH passphrase
218
+ # prompt) by a GitPane that has the user's site-packages folder on its path. Unlike isolated mode, that folder stays on
219
+ # the path: pip installs GitPane there when the Python's own folder is read-only.
220
+ APPLICATION_MODULE_INTERPRETER_OPTIONS = (IGNORE_ENVIRONMENT_OPTION, SAFE_PATH_OPTION)
221
+
222
+
223
+ def application_module_interpreter_options(no_user_site: bool) -> tuple[str, ...]:
224
+ """Answer the options of a GitPane module started as a program of its own, with the user's site-packages folder only when the GitPane that starts it has it.
225
+
226
+ ``no_user_site`` tells whether that GitPane runs without the folder: started with -s or -I, or with
227
+ PYTHONNOUSERSITE set (``sys.flags.no_user_site``). Under -E the program ignores PYTHONNOUSERSITE, so the folder is
228
+ left out by its own option; otherwise a gitpane installed there with pip --user could be imported by the program
229
+ while the GitPane that starts it runs another version, and both sides would no longer speak the same protocol.
230
+ """
231
+ if no_user_site:
232
+ return (*APPLICATION_MODULE_INTERPRETER_OPTIONS, NO_USER_SITE_OPTION)
233
+ return APPLICATION_MODULE_INTERPRETER_OPTIONS
234
+
235
+
236
+ PROCESS_IDLE_TIMEOUT_MS = 10_000
237
+ GIT_TRANSPORT_TIMEOUT_MS = 300_000
238
+ GIT_CLONE_TIMEOUT_MS = 900_000
239
+ AUTHENTICATION_TIMEOUT_MS = 600_000
240
+ # Longest wait for the `git ls-remote` that tests a username + password account on its server before it is saved.
241
+ ACCOUNT_CHECK_TIMEOUT_MS = 60_000
242
+ # Longest wait for the `git ls-remote` that reads a remote repository's branches while a URL is typed in the clone form.
243
+ GIT_BRANCH_LIST_TIMEOUT_MS = 60_000
244
+ # How many times a Git network step is started again after Git Credential Manager could not read back a secret it keeps,
245
+ # a failure that passes by itself: at most this many retries after the first attempt, so one more start in all.
246
+ CREDENTIAL_READ_RETRY_LIMIT = 3
247
+ # Wait between two starts of such a step, which gives the credential store time to answer again.
248
+ CREDENTIAL_READ_RETRY_DELAY_MS = MILLISECONDS_PER_SECOND
249
+ # How long a browser sign-in waits for the browser to answer, as chosen by the user: past it, the exchange with Git
250
+ # Credential Manager is stopped and nothing is saved.
251
+ BROWSER_SIGN_IN_TIMEOUT_MS = 120_000
252
+ # Extra time the process supervisor gives a browser sign-in beyond that wait. The worker stops the browser wait itself;
253
+ # this margin covers the identity check and the secure store that follow a late answer, and stops a worker that no
254
+ # longer answers at all.
255
+ BROWSER_SIGN_IN_STOP_GRACE_MS = 90_000
256
+ # Longest time one check of the system credential store may take, for every account it covers together: it only asks
257
+ # the credential helper whether each account's secret is still stored, never interactively and without any network
258
+ # request, so a few seconds per account are plenty. Past it, the accounts not answered yet stay unknown.
259
+ SECRET_LOOKUP_TIMEOUT_MS = 10_000
260
+ # Extra time the process supervisor gives that check beyond its own limit, to start its worker and stop a late helper.
261
+ SECRET_LOOKUP_STOP_GRACE_MS = 10_000
262
+ # Longest time the erase of a secret an exchange left behind may take: it runs without any prompt, and nobody waits for
263
+ # it. What it does not erase in time is erased by the next test of the same account or by the account's removal.
264
+ SECRET_ERASE_TIMEOUT_MS = 10_000
265
+ # Longest wait for the one request that recognises a server (GitHub or GitLab) after it refused a password; the same
266
+ # default as the server detection of Git Credential Manager.
267
+ SERVER_PROBE_TIMEOUT_MS = 2_000
268
+ # Longest wait for the system to stop a process and every process it started, once its own time limit is over.
269
+ PROCESS_TREE_STOP_TIMEOUT_MS = 5_000
270
+ AUTHENTICATION_PREFERENCE_KEY = "authentication_selection"
271
+ # How each connected account signs in, by account namespace. Kept apart from the account list so an older GitPane still
272
+ # open at the same time, which does not know this key, keeps reading the account list.
273
+ ACCOUNT_SIGN_IN_METHOD_PREFERENCE_KEY = "account_sign_in_method"
274
+ # The answer kept for the question that links a pushed branch to its remote branch, by shared Git folder of the
275
+ # repository. Kept apart from the account data, whose JSON refuses any key it does not know.
276
+ PUSH_TRACKING_PREFERENCE_KEY = "push_tracking_by_repository"
277
+ AUTHENTICATION_CONFIG_FILE_LIMIT = 128
278
+ AUTHENTICATION_CONFIG_LIMIT_BYTES = 8 * 1024 * 1024
279
+ GIT_DEFAULT_TEMPLATE_DIR = "%(prefix)/share/git-core/templates"
280
+ SSH_PROMPT_TIMEOUT_MS = 120_000
281
+ SSH_IPC_CONNECT_TIMEOUT_MS = 5_000
282
+ SSH_PROMPT_LIMIT_BYTES = 16 * 1024
283
+ SSH_RESPONSE_LIMIT_BYTES = 16 * 1024
284
+ SSH_IPC_LIMIT_BYTES = 64 * 1024
285
+ SSH_PENDING_CONNECTION_LIMIT = 4
286
+ SSH_PROMPT_COUNT_LIMIT = 16
287
+ SSH_NONCE_BYTES = 32
288
+ SSH_ENVIRONMENT_NAMES = ("GITPANE_SSH_PIPE", "GITPANE_SSH_NONCE", "GITPANE_SSH_OPERATION", "GITPANE_SSH_OWNER_PID")
289
+ TRANSPORT_IDLE_TIMEOUT_MS = 120_000
290
+ AUTHENTICATION_IDLE_TIMEOUT_MS = 600_000
291
+ TOOL_DOWNLOAD_TIMEOUT_SECONDS = 120
292
+ # Longest wait for the mount command of macOS, which asks every mount point and so may wait on a dead network share;
293
+ # past it, the recent folders are checked in a single group, as when no mount table can be read.
294
+ MOUNT_TABLE_TIMEOUT_SECONDS = 2
295
+ TOOL_BUILD_TIMEOUT_SECONDS = 600
296
+ # Longest wait of a test or of a qualification tool for one step of its scenario, such as a window state, an
297
+ # operation's result or one Git command it runs; the application itself never waits on it.
298
+ SCENARIO_STEP_TIMEOUT_MS = 30_000
299
+ QUALIFICATION_SCENARIO_TIMEOUT_SECONDS = 60
300
+ # Bounds the single pytest child process the measuring tool starts for a reading. Not a test timeout. One hour was
301
+ # sized for the process ceiling tests a reading used to run; since they were removed, a reading only collects the suite.
302
+ PROCESS_MEASURE_TIMEOUT_SECONDS = 3_600
303
+ TRACE_PAGE_SIZE = 200
304
+ TRACE_UNIT_LIMIT_BYTES = 16 * 1024 * 1024
305
+ TRACE_STORAGE_LIMIT_BYTES = 128 * 1024 * 1024
306
+ TRACE_VIEW_LIMIT_BYTES = 64 * 1024
307
+ TRACE_RENDER_INTERVAL_MS = 30
308
+ # The byte counter under the console is a word-wrapped label: every rewrite asks the whole window to lay itself out
309
+ # again, and twice a second is all the eye can follow while output streams in.
310
+ TRACE_NAVIGATION_INTERVAL_MS = 500
311
+ OPERATION_WIDTH, OPERATION_HEIGHT = 900, 560
312
+ OPERATION_KEEP_OPEN_PREFERENCE_KEY = "operation_keep_open"
313
+
314
+
315
+ class ConsoleOperation(Enum):
316
+ """Kind of work shown by the shared operation console; the kind alone decides whether the console offers Abort."""
317
+
318
+ FETCH = "fetch"
319
+ """Fetch and pull."""
320
+ PUSH = "push"
321
+ """Push, commit and push, and the tag push offered after creating a tag."""
322
+ REMOTE_DELETE = "remote-delete"
323
+ """Deletion of a remote branch or tag."""
324
+ CLONE = "clone"
325
+ """Clone, including the retry of its submodules."""
326
+ SUBMODULE_INIT = "submodule-init"
327
+ STASH_POP = "stash-pop"
328
+ REBASE = "rebase"
329
+ """Rebase start, todo edit, Continue and Abort."""
330
+ REFERENCE = "reference"
331
+ """Checkout, branch, tag, reset, cherry-pick and revert, including their Continue and Abort."""
332
+ HISTORY_REWRITE = "history-rewrite"
333
+ """Confirmed history publication whose stop waits for safe reconciliation."""
334
+
335
+
336
+ # The end of every refusal of an outdated reference capture. The form that shows it blocks the repository window, so
337
+ # the user closes it first; {refresh} and {shortcut} are filled from the real action and its shortcut on this system.
338
+ STALE_CAPTURE_ADVICE = " Close this window, click {refresh} ({shortcut}), then try again."
339
+ # The end of a refusal after which GitPane reads the repository again by itself, as a refused checkout does: clicking
340
+ # Refresh would add nothing, the user only closes the form and tries again.
341
+ REREAD_CAPTURE_ADVICE = " GitPane has read the repository again: close this window, then try again."
342
+
343
+
344
+ class SharedText(Enum):
345
+ """Texts shown by several windows, or written by code outside the interface, one member per text so a misspelt name is an error."""
346
+
347
+ SETTING_BUTTON = "Settings…"
348
+ """The settings button of the welcome window."""
349
+ SETTING_BUTTON_MISSING_GIT = "Open settings"
350
+ """The same button while no usable Git has been found: it then only opens the settings to choose one."""
351
+ SETTING = "Settings"
352
+ """The settings action of the repository window and the title of the settings window."""
353
+ REFRESH = "Refresh"
354
+ """The action of the repository window that reads the repository again, which the refusals of an outdated reference capture name."""
355
+ RECENT_REPOSITORY = "Recent repositories"
356
+ HISTORY_REFRESH = "Refreshing…"
357
+ """The status bar text while the history is read again."""
358
+ TEXT_SEARCH_HELP = "Case-insensitive search; * matches characters within a word, including hyphens, but not whitespace. Other characters are literal."
359
+ """The tooltip of the find field of the commit message and of the diff."""
360
+ DETAIL_UNAVAILABLE = "Details unavailable"
361
+ """The start of the error a commit read gives when Git did not answer with its details."""
362
+ CHILD_LOADING = "Finding children…"
363
+ TERMINAL_CHOOSE = "Choose terminal"
364
+ RESULT_DETAILS = "Show details"
365
+ """The button next to a result in Prepare changes; the commit warning that tells the user to click it quotes it."""
366
+ COPY_DETAILS = "Copy details"
367
+ """The button that copies the shown details to the clipboard: the error window and the sign-in form read it here, while the operation console, the merge recovery window and the history rewrite windows still write the same text themselves."""
368
+ FAILED = "Failed"
369
+ """The status line of a console that failed, and a failed or refused step of Delete branch; the console lines written outside the interface use the same word."""
370
+ NOT_STARTED = "Not started"
371
+ """A step of Delete branch that never began; the console lines written outside the interface use the same word."""
372
+ OUTCOME_UNKNOWN = "Outcome unknown"
373
+ """An end GitPane could not observe: the status line of a console, and a step of Delete branch or Delete tag."""
374
+ REBASE_EDITOR_CLOSE_WAIT = "Waiting for the rebase editor: continue or abort the edit in GitPane."
375
+ """The start of the banner of a repository window that waits, before it closes or changes repository, for an edit Git asked for."""
376
+ REBASE_APPLYING_COMMITS = "Applying commits…"
377
+ """The progress line of a rebase while Git applies the commits: after the start, after an edit answered or lost, and after Continue rebase."""
378
+ SUBMODULE_CHECKOUT_REFUSED_START = "Submodule {submodule} could not be checked out: "
379
+ """The start of the sentence that explains a submodule whose checkout Git refused, in the console, the diagnostic and the clone form."""
380
+ CHECKOUT_REFUSED_WINDOWS_NAME = "Windows does not allow the file name '{path}'."
381
+ """Why Git refused a path under Windows; it also comes before the link reason when Windows refuses a part of the path. A path with ? is followed by CHECKOUT_REFUSED_QUESTION_MARK."""
382
+ CHECKOUT_REFUSED_OTHER_NAME = "Git did not accept the file name '{path}' on this system."
383
+ """Why Git refused a path under Linux or macOS, where the rule may be Git's own rather than the file system's."""
384
+ CHECKOUT_REFUSED_PROTECTED_PATH = "Git protects the path '{path}': it never writes a .git folder, or a name Windows reads as one, inside a repository."
385
+ """Why Git refused a path with a .git component, or one Windows reads as .git (git~1, .git with dots or spaces at the end), on every system: Git protects it, not the file system."""
386
+ CHECKOUT_REFUSED_PROTECTED_LINK = "Git protects the file name '{path}': it never writes it as a symbolic link."
387
+ """Why Git refused a path whose last part is, without case, exactly .gitmodules, .gitattributes, .gitignore or .mailmap, on every system: Git refuses these names only as a symbolic link."""
388
+ CHECKOUT_REFUSED_QUESTION_MARK = "Git shows a control character in a file name as ?."
389
+ """Added after the reason of a cited path that holds ?: Git writes ? for any control character, so the text never names one."""
390
+ SUBMODULE_CHECKOUT_REFUSED_END = "Other files of this submodule may have the same problem; Retry submodules cannot write them while these names exist in the repository. The rest of the clone is kept and can be opened."
391
+ """The end of that sentence: Git cites only the first name it refuses, and Retry submodules stays offered."""
392
+ CLONED_SUBMODULE_CHANGED = "The folder of submodule {submodule} changed after Git cloned it, so GitPane did not check it out. Existing files were preserved."
393
+ """Why GitPane stopped before checking out a submodule Git had cloned without its files: something appeared in its folder meanwhile."""
394
+ CLONED_SUBMODULE_CHECKOUT_NOT_STARTED = ("GitPane did not check out the submodules Git cloned without their files: the clone no longer holds its repository."
395
+ " Click Retry submodules to check them out.")
396
+ """Why the clone's step that checks out the submodules Git cloned without files did not run: the clone had already released its repository."""
397
+ CLONED_SUBMODULE_CHECKOUT_STEP = "Check out cloned submodules"
398
+ """The step of a clone that checks out the submodules Git cloned but left without files after refusing another submodule's checkout."""
399
+ UPSTREAM_LINK_STEP = "Set upstream branch"
400
+ """The step of a Push that links the pushed branch to its remote branch, shown in the console and in the details of the Push window."""
401
+ UPSTREAM_LINKED = "{branch} now tracks {tracking}."
402
+ """The console line once the pushed branch follows the remote-tracking branch it was pushed to."""
403
+ UPSTREAM_NOT_LINKED = "{branch} was pushed, but it was not linked to {tracking}: {cause}"
404
+ """The red console line when the link asked for after a successful Push was not set; the Push itself stays a success."""
405
+ UPSTREAM_NO_TRACKING_BRANCH = "no remote-tracking branch of {remote} receives {destination}"
406
+ """The cause of that line when no Fetch refspec of the remote maps the pushed destination to a remote-tracking branch."""
407
+ UPSTREAM_KEPT = "{branch} already has an upstream branch; it was left unchanged."
408
+ """The neutral console line when the pushed branch already has an upstream once the repository is read again after the Push: GitPane leaves that upstream as it is."""
409
+ UPSTREAM_OTHER_UPSTREAM = "Git reports {upstream} as its upstream branch"
410
+ """The cause of the not-linked line when Git accepted the link but resolves another upstream, as with several branch.<name>.remote values."""
411
+ UPSTREAM_NO_UPSTREAM = "Git reports no upstream branch"
412
+ """The cause of the not-linked line when Git accepted the link but the repository, read again, shows the branch without any upstream."""
413
+ UPSTREAM_UNREAD = "the upstream branch could not be read back: {cause}"
414
+ """The cause of the not-linked line when the repository could not be read again after the link, so the link is not known to be set."""
415
+ PUSH_TRACKING_UNREADABLE = "the " + PUSH_TRACKING_PREFERENCE_KEY + " entry of the preferences file is not a JSON object: remove it to go back to Ask for every repository"
416
+ """Why a kept tracking choice cannot be saved: the stored value of every repository's choices is not an object, so only removing it from gitpane.ini lets GitPane save again."""
417
+ SAVE_TRACKING_CHOICE = "Save tracking choice"
418
+ """The operation named in the diagnostic and the log when the answer kept by Don't ask again cannot be written to the preferences file."""
419
+ PUSH_TRACKING_NOT_SAVED = "Don't ask again was not saved: {cause}"
420
+ """The status bar line of the repository window when the tracking answer to keep cannot be written to the preferences file; the Push still goes with that answer."""
421
+ COMMIT_PUSH_CANCELLED = "Commit created. Push cancelled: the commit stays local."
422
+ """The status bar line of the repository window when the user answers Cancel to the tracking question of a Commit & push: the commit exists, nothing was sent."""
423
+ COMMIT_PUSH_NOT_SENT = "Commit created. Push not sent: this window may no longer push this repository. The commit stays local."
424
+ """The status bar line of the repository window when a Commit & push's created commit cannot be pushed because the window lost its authority to manage the repository: nothing was sent."""
425
+ CREDENTIAL_READ_RETRY = "The credential manager could not read the stored secret; trying again in {seconds} s ({attempt} of {limit})."
426
+ """The console and --log line of a Git network step started again because the credential manager could not read back a secret it keeps."""
427
+ CREDENTIAL_READ_RETRY_CANCELLED = "Cancelled while waiting to try again: Git was not started again."
428
+ """The cause of an operation cancelled, or of GitPane closing, while a network step waited to be started again."""
429
+ REFERENCE_CHANGED_BEFORE_OPENING = "The repository changed since GitPane last read it (branches, tags or Git settings)." + STALE_CAPTURE_ADVICE
430
+ """A reference form refused when it opens: the references or the Git settings differ from the capture its menu was built on."""
431
+ SELECTED_REFERENCE_OR_REPOSITORY_CHANGED = ("The repository changed since GitPane last read it (the selected branch or tag, other branches or tags, or Git settings)."
432
+ + STALE_CAPTURE_ADVICE)
433
+ """Delete branch or Delete tag refused when it opens: the clicked branch or tag, or the rest of the capture its menu was built on, changed."""
434
+ SELECTED_LOCAL_BRANCH_CHANGED = "The selected local branch changed or was deleted since GitPane last read it." + STALE_CAPTURE_ADVICE
435
+ """A branch form refused because the local branch it acts on moved, disappeared or is no longer a plain local branch."""
436
+ HEAD_CHANGED_BEFORE_OPENING = "The current branch or commit changed since GitPane last read the repository." + STALE_CAPTURE_ADVICE
437
+ """Checkout branch or Checkout revision refused when it opens: HEAD differs from the capture its menu was built on."""
438
+ SELECTED_REFERENCE_CHANGED = "The selected branch or tag changed or was deleted since GitPane last read it." + STALE_CAPTURE_ADVICE
439
+ """A checkout or a deletion refused because the branch or tag it acts on no longer points where the capture said, or is gone."""
440
+ FORM_CHECK_UNANSWERED = "GitPane got no answer while checking the repository for this form; try again."
441
+ """A reference form closed before it was ever shown because its opening check gave no answer within its limit; the next click opens a new form."""
442
+ NOT_A_LOCAL_BRANCH = "GitPane does not know '{branch}' as a local branch: the branches may have changed since it last read the repository." + STALE_CAPTURE_ADVICE
443
+ """A deletion of local branches refused before any process: a requested name is not a plain local branch of the capture."""
444
+ REFERENCE_CHANGED_DURING_READING = ("The branches, tags or current commit changed while GitPane was reading them."
445
+ " Close any open form, click {refresh} ({shortcut}), then try again.")
446
+ """A reference read that saw the files change while it ran, shown in the status bar of the repository window or in a form."""
447
+ SELECTED_REFERENCE_CHANGED_OR_SYMBOLIC = ("The selected branch or tag changed since GitPane last read it, or it is now another name for a branch (a symbolic reference)."
448
+ + REREAD_CAPTURE_ADVICE)
449
+ """A checkout refused when it starts: the chosen branch or tag moved, disappeared or became a symbolic reference."""
450
+ CHECKOUT_REFERENCES_OR_HEAD_CHANGED = "The branches, tags or current commit changed since GitPane last read the repository." + REREAD_CAPTURE_ADVICE
451
+ """A checkout refused when it starts: the references or the current commit are no longer those the form was opened on."""
452
+ CHECKOUT_LOCAL_BRANCH_GONE = "The local branch to reset no longer exists." + REREAD_CAPTURE_ADVICE
453
+ """A checkout that resets a local branch onto its remote branch, refused when it starts because that local branch was deleted."""
454
+ CHECKOUT_CHANGED_BEFORE_LAUNCH = ("The repository changed just before Git started the checkout (branches, tags, current commit or Git settings)."
455
+ + REREAD_CAPTURE_ADVICE)
456
+ """A checkout refused at the last moment: the references or Git settings changed, or the request stopped being current, while it waited to start."""
457
+
458
+
459
+ # Only a network transfer can be stopped midway from the console: it waits on a server and stopping it leaves the
460
+ # repository as it was. Every other console runs a local write that must finish, so it hides the Abort button, and
461
+ # Escape or the close button do not stop it either. A paused rebase, cherry-pick or revert is abandoned from the
462
+ # repository window instead.
463
+ CONSOLE_ABORTABLE_OPERATION_SET = frozenset((ConsoleOperation.FETCH, ConsoleOperation.PUSH, ConsoleOperation.REMOTE_DELETE, ConsoleOperation.CLONE, ConsoleOperation.SUBMODULE_INIT, ConsoleOperation.HISTORY_REWRITE))
464
+ DIAGNOSTIC_DECODE_LIMIT = 8
465
+ # The OAuth authorization code field. The same word also names exit, HTTP and Git error codes, so the secret filter lets
466
+ # a short number after it through; every other value stays hidden.
467
+ AUTHORIZATION_CODE_FIELD_NAME = "code"
468
+ SENSITIVE_FIELD_NAMES = (
469
+ "authorization", "proxy-authorization", "cookie", "set-cookie", "password", "passwd", "access_token", "refresh_token", "id_token", "token", AUTHORIZATION_CODE_FIELD_NAME,
470
+ "code_verifier", "verifier", "state", "nonce", "client_secret", "secret", "api_key", "apikey", "credential",
471
+ )
472
+ # A value shorter than this is never hidden in diagnostics and logs: hiding a one-character value, such as the "0" of
473
+ # a trace switch, rewrites every line it appears in (times, object names, counts) and protects nothing.
474
+ SENSITIVE_VALUE_MIN_LENGTH = 2
475
+ # The environment variable through which Git Credential Manager reads each of its settings that GitPane depends on, named
476
+ # as in Git configuration. Git Credential Manager reads such a variable before any Git configuration, even before a
477
+ # setting the user scoped to one address, and it only matches a URL-scoped setting against the host Git sends it, never
478
+ # against a repository path; a helper started with these variables therefore finds its account whatever URL Git chose
479
+ # that helper for, whatever the user's own configuration says. Every other setting of Git Credential Manager (its proxy,
480
+ # its prompts window…) stays the user's.
481
+ GCM_ENVIRONMENT_NAME_BY_SETTING = {"provider": "GCM_PROVIDER", "namespace": "GCM_NAMESPACE", "credentialStore": "GCM_CREDENTIAL_STORE",
482
+ "interactive": "GCM_INTERACTIVE", "traceSecrets": "GCM_TRACE_SECRETS", "allowWindowsAuth": "GCM_ALLOW_WINDOWSAUTH",
483
+ "gitHubAuthModes": "GCM_GITHUB_AUTHMODES", "gitLabAuthModes": "GCM_GITLAB_AUTHMODES", "bitbucketAuthModes": "GCM_BITBUCKET_AUTHMODES"}
484
+ GCM_TRACE_SECRETS_NAME = GCM_ENVIRONMENT_NAME_BY_SETTING["traceSecrets"]
485
+ # Environment names that look secret but hold no secret: the Git Credential Manager switch that keeps secrets out of
486
+ # its own trace has "SECRET" in its name, yet its value is only "0" or "1".
487
+ NON_SECRET_ENVIRONMENT_NAME_SET = frozenset({GCM_TRACE_SECRETS_NAME})
488
+ NETWORK_ENVIRONMENT_VALUE_SET_BY_NAME = {
489
+ "GIT_TRACE": frozenset({"1"}), "GIT_TRACE_CURL": frozenset({"1"}), "GIT_TRACE_CURL_NO_DATA": frozenset({"0", "1"}),
490
+ "GIT_TRACE_REDACT": frozenset({"1"}), "GCM_TRACE": frozenset({"1"}), GCM_TRACE_SECRETS_NAME: frozenset({"0"}),
491
+ "GIT_CONFIG_NOSYSTEM": frozenset({"1"}), "NO_PROXY": frozenset({"*"}),
492
+ }
493
+ PROCESS_TERMINATE_TIMEOUT_MS = 500
494
+ PROCESS_GROUP_POLL_MS = 25
495
+ COMMIT_BATCH_SIZE = 200
496
+ SEARCH_DEBOUNCE_MS = 250
497
+ # Typing pause after which a revision typed in a revision field is resolved: nothing is resolved while the user is
498
+ # still typing, so a revision that needs Git costs one process after the pause instead of one per key.
499
+ REVISION_INPUT_PAUSE_MS = 400
500
+ RELATIVE_DATE_REFRESH_MS = 60_000
501
+ AVATAR_SIDE = 64
502
+ AVATAR_INITIALS_FILL_RATIO = 0.85
503
+ AVATAR_TIMEOUT_MS = 5_000
504
+ AVATAR_ACTIVE_LIMIT = 4
505
+ AVATAR_PENDING_LIMIT = 128
506
+ AVATAR_RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024
507
+ AVATAR_DECODED_SIDE_LIMIT = 1024
508
+ AVATAR_REQUEST_SIDE_LIMIT = 512
509
+ AVATAR_CACHE_ENTRY_LIMIT = 128
510
+ AVATAR_CACHE_LIMIT_BYTES = 16 * 1024 * 1024
511
+ AVATAR_CACHE_TTL_SECONDS = 3600
512
+ TOOL_START_TIMEOUT_MS = 5_000
513
+ MERGETOOL_START_TIMEOUT_MS = TOOL_START_TIMEOUT_MS
514
+ MERGETOOL_TERMINATE_TIMEOUT_MS = PROCESS_TERMINATE_TIMEOUT_MS
515
+ WINDOWS_EXCEPTION_STATUS_START = 0x80000000
516
+ SHELL_SCRIPT_EXTENSION_SET = frozenset({".bat", ".cmd"})
517
+ TOOL_START_ACTIVE_LIMIT = 4
518
+ DIFFTOOL_START_TIMEOUT_MS = 5_000
519
+ DIFFTOOL_ACTIVE_LIMIT = 4
520
+ TOOL_RESULT_POLL_MS = 25
521
+ FILE_EXPORT_BUFFER_CHUNKS = 128
522
+ FILE_EXPORT_ACTIVE_LIMIT = 2
523
+ COPY_CONFIRMATION_MS = 3_000
524
+ PARSING_BUDGET_MS = 5
525
+ STDERR_LIMIT_BYTES = 256 * 1024
526
+ COMMIT_LIMIT_BYTES = 8 * 1024 * 1024
527
+ MESSAGE_CACHE_LIMIT_BYTES = 32 * 1024 * 1024
528
+ DIFF_CACHE_LIMIT_BYTES = 16 * 1024 * 1024
529
+ DIFF_CACHE_ENTRY_LIMIT = 128
530
+ # Keep text layout small even for generated files; stream exact bytes separately for export.
531
+ FILE_PREVIEW_LIMIT_BYTES = 256 * 1024
532
+ FILE_LINE_LIMIT_CHARS = 4096
533
+ FILE_PARSE_BUDGET_MS = 5
534
+ FILE_CACHE_LIMIT_BYTES = 8 * 1024 * 1024
535
+ FILE_TEXT_BATCH_CHARS = 8192
536
+ SESSION_PROCESS_LIMIT = 2
537
+ GLOBAL_PROCESS_LIMIT = 4
538
+
539
+
540
+ class ProcessPriority(IntEnum):
541
+ """Order in which waiting Git processes take a free slot; a smaller value goes first.
542
+
543
+ A user action goes before the rest of an operation already under way, and both go before background work such as
544
+ the periodic refresh. The level only orders the waiting queue: it grants no permission, never changes a launch
545
+ profile and never interrupts a process that already runs.
546
+ """
547
+
548
+ USER = 0
549
+ CONTINUATION = 1
550
+ BACKGROUND = 2
551
+
552
+
553
+ SESSION_QUEUE_LIMIT = 64
554
+ GLOBAL_QUEUE_LIMIT = 256
555
+ PROCESS_CHUNK_BYTES = 64 * 1024
556
+ # The one byte asked beyond a validated file size, so that a file which grew while it was read shows it.
557
+ READ_SIZE_SENTINEL_BYTES = 1
558
+ PARSING_BACKLOG_LIMIT_BYTES = 8 * 1024 * 1024
559
+ SNAPSHOT_RETRY_LIMIT = 3
560
+ CAT_FILE_HEADER_LIMIT_BYTES = 256
561
+ COMMIT_HEADER_LIMIT_BYTES = 64 * 1024
562
+ HISTORY_REWRITE_SPOOL_LIMIT_BYTES = 4 * 1024 * 1024 * 1024
563
+ HISTORY_REWRITE_DIAGNOSTIC_LIMIT_CHARS = 2048
564
+ COMMIT_SUBJECT_LIMIT_CHARS = 512
565
+ COMMIT_PREVIEW_LIMIT_CHARS = 512
566
+ # Repositories the recent history keeps, the most recent first. The Open repository window and the repository switch
567
+ # menu show as many, and scroll past what fits.
568
+ RECENT_LIMIT = 100
569
+ # How long a recent's folder check may go without an answer before its row shows "checking": a folder on a local disk
570
+ # answers within a few milliseconds and so never shows it, and the recent list does not jump when the window opens.
571
+ RECENT_CHECKING_DISPLAY_DELAY_MS = 300
572
+ # How long after its check was requested a recent without an answer is shown as Unknown, even one still waiting behind a slow disk.
573
+ # A question blocked on a dead network mount cannot be cancelled, so the window stops waiting for it; its answer is still taken if it ever comes.
574
+ RECENT_CHECK_UNKNOWN_DELAY_MS = 30_000
575
+ # Parent folders of the last started clones offered by the clone form's Destination list.
576
+ CLONE_DESTINATION_LIMIT = 10
577
+ FIRST_ROW = 0
578
+ # Qt measures arc angles in sixteenths of a degree, starting at three o'clock.
579
+ QT_ANGLE_UNITS_PER_DEGREE = 16
580
+ # Shown in place of the commit subject by the branch forms when the selected commit could not be read.
581
+ COMMIT_METADATA_UNAVAILABLE = "Commit metadata unavailable"
582
+ # Text of the submit button of the forms that delete a branch or a tag.
583
+ DELETE_BUTTON_TEXT = "Delete"
584
+ # Title of both Delete branch windows and of the questions they ask.
585
+ DELETE_BRANCH_TITLE = "Delete branch"
586
+ # Text of the button that confirms a form whose action has no more precise name.
587
+ OK_BUTTON_TEXT = "OK"
588
+ # Shortcuts shared by the action that reacts to them and by the tooltip that cites them, in Qt's portable format.
589
+ FIND_SHORTCUT = "Ctrl+F"
590
+ STAGE_SHORTCUT = "S"
591
+ WELCOME_WIDTH = 720
592
+ REPOSITORY_WIDTH = 1440
593
+ REPOSITORY_HEIGHT = 900
594
+ LAYOUT_SPACING = 8
595
+ APPLICATION_NAME = "GitPane"
596
+ # The build reads this value from the text of this file without running it, so it stays a literal written as is:
597
+ # a computed value would make the build import the module instead.
598
+ APPLICATION_VERSION = "1.0.0"
599
+ # The line `gitpane --version` prints and the diagnostic log writes first.
600
+ APPLICATION_VERSION_LINE = APPLICATION_NAME + " " + APPLICATION_VERSION
601
+ # Bounds the helper that marks or writes the desktop shortcut (gio on Linux, PowerShell on Windows); it runs off the
602
+ # window's thread, so a slow helper never freezes Settings.
603
+ DESKTOP_SHORTCUT_HELPER_TIMEOUT_MS = 30_000
604
+ # Windows groups taskbar buttons by this identifier. Company.Product format, without a version so an update keeps the same button.
605
+ APPLICATION_USER_MODEL_ID = "ThongvanAlexis.GitPane"
606
+ # macOS knows the desktop bundle of GitPane by this reverse-DNS name. The Windows identifier above is not reused: it is not in that form.
607
+ MACOS_BUNDLE_IDENTIFIER = "io.github.thongvanalexis.gitpane"
608
+ WINDOWS_PLATFORM = "win32"
609
+ LINUX_PLATFORM = "linux"
610
+ MACOS_PLATFORM = "darwin"
611
+ # Linux's process folder, read by the process supervisor (process states) and by the keyring read (the program a process runs).
612
+ PROC_DIR = "/proc"
613
+ # The Windows environment variable that names the Windows folder, read by the terminal search and the SSH client search.
614
+ SYSTEM_ROOT_ENVIRONMENT_NAME = "SystemRoot"
615
+ # The Unicode form macOS gives the names it creates and Qt for macOS gives every argument: accents decomposed into a letter and a combining mark.
616
+ DECOMPOSED_UNICODE_FORM = "NFD"
617
+ # The macOS command that hands a file, a folder or an application to LaunchServices (Finder, Terminal, default editor).
618
+ MACOS_OPEN_FILENAME = "/usr/bin/open"
619
+ # Start of every HTTPS URL and account origin; shared by the account service and the reader of Git's refusal lines.
620
+ HTTPS_PREFIX = "https://"
621
+ WINDOWS_KDIFF3_FILENAME = "C:/Program Files/KDiff3/kdiff3.exe"
622
+ # Where the distribution packages install KDiff3.
623
+ LINUX_KDIFF3_FILENAME = "/usr/bin/kdiff3"
624
+ # Standard KDiff3 installation of each system, keyed by sys.platform, offered in Settings when no comparison tool is saved.
625
+ # macOS has no fixed file: its KDiff3 is an application bundle, searched through KDIFF3_BUNDLE_BASENAMES_BY_PLATFORM below.
626
+ # A system missing from the table gets no suggestion rather than another system's location.
627
+ KDIFF3_SUGGESTED_FILENAMES_BY_PLATFORM: dict[str, tuple[str, ...]] = {
628
+ WINDOWS_PLATFORM: (WINDOWS_KDIFF3_FILENAME,),
629
+ LINUX_PLATFORM: (LINUX_KDIFF3_FILENAME,),
630
+ MACOS_PLATFORM: (),
631
+ }
632
+ # Homebrew's kdiff3 cask and KDE's disk image both install this application folder.
633
+ KDIFF3_BUNDLE_BASENAME = "kdiff3.app"
634
+ # KDiff3 application folders of each system, keyed by sys.platform, searched in the applications folders after the fixed
635
+ # files above. GitPane offers the program the bundle declares, the same one Browse… gives on that folder, never the script
636
+ # the cask adds to Homebrew's bin folder. A system missing from the table gets no suggestion.
637
+ KDIFF3_BUNDLE_BASENAMES_BY_PLATFORM: dict[str, tuple[str, ...]] = {
638
+ WINDOWS_PLATFORM: (),
639
+ LINUX_PLATFORM: (),
640
+ MACOS_PLATFORM: (KDIFF3_BUNDLE_BASENAME,),
641
+ }
642
+ # KDE's download folder of the stable KDiff3 releases, which holds the Windows installer (.exe).
643
+ KDIFF3_DOWNLOAD_URL = "https://download.kde.org/stable/kdiff3/"
644
+ # How to install KDiff3 on Windows and macOS, keyed by sys.platform: shown under the tool fields of Settings while no
645
+ # KDiff3 is found, and after the message of a comparison or a conflict resolution refused for a missing tool. Linux has
646
+ # one line per distribution, in gitpane/tool/kdiff3_install.py. Any other system gets no line rather than another
647
+ # system's.
648
+ KDIFF3_INSTALL_LINE_BY_PLATFORM: dict[str, str] = {
649
+ WINDOWS_PLATFORM: "To install KDiff3, download its Windows installer from " + KDIFF3_DOWNLOAD_URL,
650
+ MACOS_PLATFORM: "To install KDiff3, run: brew install --cask kdiff3",
651
+ }
652
+ # Oldest Git Credential Manager that accepts every setting GitPane passes to it (the "none" store included) and that
653
+ # carries the fix for CVE-2024-50338; an older one is refused with its version so the user knows what to update.
654
+ GCM_MINIMUM_VERSION = (2, 6, 1)
655
+ # Longest wait for `git-credential-manager --version`: the first start of .NET on Windows, antivirus scan included, can
656
+ # take several seconds.
657
+ GCM_VERSION_TIMEOUT_MS = 10_000
658
+ # Start of the names of the environment variables the Git Credential Manager reads as its own settings. Every place
659
+ # that drops the user's settings before starting it, or before starting Git that starts it, reads this one value.
660
+ GCM_ENVIRONMENT_PREFIX = "GCM_"
661
+ GCM_RELEASES_URL = "https://github.com/git-ecosystem/git-credential-manager/releases/latest"
662
+ # Version of the official .tar.gz archive the Linux install lines download: the latest stable release when these lines
663
+ # were written, at least GCM_MINIMUM_VERSION. Raised by hand when a newer release is worth giving.
664
+ GCM_ARCHIVE_VERSION = "3.0.2"
665
+ GIT_FOR_WINDOWS_DOWNLOAD_URL = "https://git-scm.com/downloads/win"
666
+ # Where a Mac without Homebrew gets it before installing the credential manager with it.
667
+ HOMEBREW_INSTALL_URL = "https://brew.sh"
668
+ # First line of the "credential manager missing" message, the same on every system; the install lines of the table follow it.
669
+ GCM_REQUIRED_TEXT = "A git credential manager is required to push/fetch"
670
+ GCM_WINDOWS_BASENAME = "git-credential-manager.exe"
671
+ GCM_BASENAME = "git-credential-manager"
672
+ # Where the .pkg installer of the Homebrew cask, the official .deb and the archive install lines all put the credential
673
+ # manager, on Apple silicon and Intel alike. An application started from the Finder or from the desktop menu does not
674
+ # always have /usr/local/bin in its PATH, and the archive lines put no link there, so this location is searched after
675
+ # the PATH. posixpath, because the path is POSIX on both systems that use it, whatever system runs the tests.
676
+ GCM_CORE_DIR = "/usr/local/share/gcm-core"
677
+ GCM_CORE_FILENAME = posixpath.join(GCM_CORE_DIR, GCM_BASENAME)
678
+ # Install line of the macOS entry, given both for a missing credential manager and for one that is installed but unusable.
679
+ MACOS_GCM_INSTALL_LINE = " - run : brew install --cask git-credential-manager"
680
+
681
+
682
+ @dataclass(frozen=True)
683
+ class CredentialManagerPlatform:
684
+ """Everything about the Git Credential Manager that differs from one system to another.
685
+
686
+ Its file name, whether the Git installation in use is searched for it, the fixed locations searched after the
687
+ PATH, the secure store that keeps GitPane's accounts, and the install lines shown when it is refused, which may
688
+ depend on why it is refused.
689
+ """
690
+
691
+ executable_basename: str
692
+ searches_git_installation: bool
693
+ fixed_filenames: tuple[str, ...]
694
+ credential_store: str
695
+ # Lines for a credential manager that is installed but unusable (unreadable version, does not start), and for any
696
+ # refusal that has no lines of its own below.
697
+ install_lines: tuple[str, ...]
698
+ # A missing credential manager and one that is too old are not installed the same way everywhere (install versus
699
+ # upgrade); empty means the same lines as install_lines.
700
+ missing_install_lines: tuple[str, ...] = ()
701
+ too_old_install_lines: tuple[str, ...] = ()
702
+ # True when the lines depend on the distribution and the processor, written by the module of the Linux install
703
+ # lines; install_lines then stays empty.
704
+ install_lines_by_distribution: bool = False
705
+
706
+
707
+ # Keyed by sys.platform. A system missing from the table is refused as unsupported, never served with another system's
708
+ # location, store or install lines.
709
+ CREDENTIAL_MANAGER_PLATFORM_BY_NAME: dict[str, CredentialManagerPlatform] = {
710
+ WINDOWS_PLATFORM: CredentialManagerPlatform(
711
+ executable_basename=GCM_WINDOWS_BASENAME, searches_git_installation=True, fixed_filenames=(), credential_store="wincredman",
712
+ install_lines=(' - reinstall Git for Windows with "Git Credential Manager" checked', " - You can get it there in case you forgot : " + GIT_FOR_WINDOWS_DOWNLOAD_URL),
713
+ ),
714
+ LINUX_PLATFORM: CredentialManagerPlatform(
715
+ executable_basename=GCM_BASENAME, searches_git_installation=False, fixed_filenames=(GCM_CORE_FILENAME,), credential_store="secretservice",
716
+ install_lines=(), install_lines_by_distribution=True,
717
+ ),
718
+ MACOS_PLATFORM: CredentialManagerPlatform(
719
+ executable_basename=GCM_BASENAME, searches_git_installation=False, fixed_filenames=(GCM_CORE_FILENAME,), credential_store="keychain",
720
+ install_lines=(MACOS_GCM_INSTALL_LINE,),
721
+ missing_install_lines=(MACOS_GCM_INSTALL_LINE, " - no Homebrew yet? install it first : " + HOMEBREW_INSTALL_URL),
722
+ too_old_install_lines=(" - run : brew upgrade --cask git-credential-manager",),
723
+ ),
724
+ }
725
+ SPACING_XS, SPACING_SM, SPACING_MD, SPACING_LG, SPACING_XL, SPACING_2XL, SPACING_3XL = 4, 8, 16, 24, 32, 48, 64
726
+ SCREEN_MARGIN = SPACING_MD
727
+ # Rows the repository switch list shows at once, like an ordinary dropdown list; it scrolls past them.
728
+ REPOSITORY_LIST_MAX_VISIBLE_ROW_COUNT = 20
729
+ # Gap added after the number and after the name in the repository switch list; Qt already counts a small style margin in
730
+ # each measured column.
731
+ REPOSITORY_LIST_COLUMN_GAP = SPACING_SM
732
+ # The Open repository buttons sit in the band under the form, so the form itself is short: the minimum height stays
733
+ # under the height the form wants with a few recent repositories, so the window still grows with them.
734
+ WELCOME_MIN_WIDTH, WELCOME_MIN_HEIGHT = 480, 240
735
+ # The Open repository form grows with its recent repositories up to this share of the usable screen height; past it, the form scrolls.
736
+ WELCOME_MAX_SCREEN_RATIO = 0.8
737
+ REPOSITORY_MIN_WIDTH, REPOSITORY_MIN_HEIGHT = 800, 560
738
+ SETTING_WIDTH, SETTING_HEIGHT = 800, 600
739
+ SETTING_MIN_WIDTH, SETTING_MIN_HEIGHT = 480, 320
740
+ ERROR_WIDTH, ERROR_HEIGHT = 640, 320
741
+ # Share of the usable screen height the diagnostic window never goes past, title bar included; past it, its details scroll.
742
+ ERROR_MAX_SCREEN_HEIGHT_RATIO = 0.8
743
+ # Share of the room left to the cause and the details that the cause may take when the diagnostic window is at its height cap.
744
+ ERROR_SUMMARY_MAX_SHARE = 0.5
745
+ # Client-area width of the Git Extensions Cherry pick commit and Revert commit windows (615 px with their borders); the
746
+ # height follows the content. The revision chooser keeps the same starting size.
747
+ REPLAY_DIALOG_WIDTH = 613
748
+ REPLAY_DIALOG_HEIGHT = 360
749
+ NAVIGATION_WIDTH, NAVIGATION_MIN_WIDTH = 240, 160
750
+ HISTORY_MIN_HEIGHT, DETAIL_MIN_HEIGHT = 192, 200
751
+ HISTORY_STRETCH, DETAIL_STRETCH = 1, 1
752
+ HISTORY_ROW_MIN_HEIGHT = 24
753
+ # The package and the folder where GitPane keeps the images and the font it ships, read through importlib.resources.
754
+ # Every reader takes them from here, so moving the resources means changing one place.
755
+ UI_RESOURCE_PACKAGE_NAME = "gitpane.ui"
756
+ UI_RESOURCE_DIR_NAME = "resource"
757
+ # Names of the packaged application pictures that the icon drawing tool writes into that folder, read by the desktop shortcut.
758
+ APPLICATION_ICON_FILE_NAME_PREFIX = "GitPane-"
759
+ APPLICATION_ICON_PNG_EXTENSION = ".png"
760
+ APPLICATION_ICON_ICO_BASENAME = "GitPane.ico"
761
+ ICON_SIDE, GRAPH_NODE_SIDE, GRAPH_LANE_WIDTH = 16, 8, 16
762
+ PARENT_MARKER_FILL = "#e87500"
763
+ PARENT_MARKER_BORDER = "#c73939"
764
+ PARENT_MARKER_BACKGROUND = "#ffffff"
765
+ PARENT_MARKER_BORDER_WIDTH = 1
766
+ PARENT_MARKER_INSET = 2
767
+ PARENT_MARKER_SHOULDER_RATIO = 0.55
768
+ PARENT_MARKER_SHAFT_RATIO = 0.3
769
+ GRAPH_MESSAGE_MIN_CHARACTERS = 12
770
+ TERMINAL_CONTROL_HEIGHT = 32
771
+ # Arrow of the toolbar menu buttons, measured at 100 % on a Git Extensions toolbar capture: a filled triangle whose
772
+ # rows are 7, 5, 3 and 1 pixels wide, 9 pixels after the end of the button text.
773
+ MENU_ARROW_WIDTH, MENU_ARROW_HEIGHT = 7, 4
774
+ MENU_ARROW_TEXT_GAP = 9
775
+ MENU_ARROW_AREA_WIDTH = 11
776
+ MENU_SEPARATOR_WIDTH = 1
777
+ # Right padding of the button content, tuned so the measured gap equals MENU_ARROW_TEXT_GAP with the fonts of Windows and
778
+ # Linux: Qt adds two spaces of the button font after the text and the triangle is centred in the menu area, so the gap is
779
+ # larger than this padding and grows with the font's space. The pixel test checks the gap this layout gives in the button's
780
+ # font on every system, and that it equals MENU_ARROW_TEXT_GAP on Windows and Linux (macOS buttons get a smaller font).
781
+ MENU_ARROW_TEXT_PADDING = 2
782
+ # Top bar of the repository window: the repository button gives way to the filter and commit hash fields, which
783
+ # keep at least these widths. The button shows the whole path up to its maximum width and elides it in the middle below
784
+ # that, down to its minimum text width. Starting values, to be judged on screen captures at 100 %.
785
+ REPOSITORY_BUTTON_MAX_WIDTH = 480
786
+ REPOSITORY_BUTTON_MIN_TEXT_WIDTH = 64
787
+ # Narrower than the hash field since the filter mode button, wider than the label it replaced, sits beside it: at the
788
+ # minimum window width the repository button still shows the repository name with its separator.
789
+ FILTER_FIELD_MIN_WIDTH = 104
790
+ HASH_FIELD_MIN_WIDTH = 120
791
+ TOP_BAR_FIELD_STRETCH = 1
792
+ HEADING_FONT_RATIO, DISPLAY_FONT_RATIO = 1.25, 1.75
793
+ MESSAGE_LINE_HEIGHT_PERCENT = 150
794
+ DIFF_FRAGMENT_LIMIT_CHARS = 4096
795
+ DIFF_SYNTAX_LIMIT_CHARS = 16384
796
+ DIFF_ICON_PADDING_RATIO = 0.2
797
+ DIFF_ICON_STROKE_RATIO = 0.14
798
+ DIFF_ICON_RENAME_BADGE_AREA_RATIO = 0.25
799
+ DIFF_NUMBER_COLUMN_COUNT = 2
800
+ DIFF_FILE_STRETCH, DIFF_TEXT_STRETCH = 1, 3
801
+ # The message splitter has a third pane for the emoji keyboard, but its preference keeps two sizes: upper (diff and keyboard) and message.
802
+ STAGING_MESSAGE_PROPORTION_KEY = "staging_message_proportions"
803
+ STAGING_PROPORTION_BY_KEY: dict[str, tuple[int, int]] = {
804
+ "staging_file_proportions": (DIFF_FILE_STRETCH, DIFF_TEXT_STRETCH),
805
+ "staging_list_proportions": (1, 1),
806
+ STAGING_MESSAGE_PROPORTION_KEY: (DIFF_TEXT_STRETCH, DIFF_FILE_STRETCH),
807
+ }
808
+ # Duration of the slide that opens or closes the staging emoji keyboard.
809
+ EMOJI_GRID_ANIMATION_MS = 150
810
+ # Colour emoji glyphs ignore the palette's disabled colour, so a disabled keyboard is dimmed to this opacity to look disabled.
811
+ EMOJI_GRID_DISABLED_OPACITY = 0.4
812
+ EMOJI_POINT_SIZE = 18
813
+ EMOJI_BUTTON_MIN_SIDE = SPACING_XL
814
+ # Combining mark buttons show the mark between two emoji, so they are this many symbol buttons wide.
815
+ EMOJI_MARK_BUTTON_WIDTH_RATIO = 2
816
+ # Shared label of the staging button, the Tools menu action and the title of the floating keyboard window.
817
+ EMOJI_KEYBOARD_TITLE = "Emoji keyboard"
818
+ # How long the floating keyboard's copy bubble stays up; copied hashes keep their own, longer status confirmation.
819
+ EMOJI_COPY_TOAST_MS = 1_200
820
+ COPIED_TO_CLIPBOARD_TEXT = "Copied to clipboard"
821
+ # The floating keyboard first opens at this share of the usable screen width, then as tall as its laid-out grid needs to
822
+ # show every symbol at that width; it only grows wider when that height does not fit on the screen.
823
+ EMOJI_WINDOW_SCREEN_WIDTH_SHARE = 0.4
824
+ EMOJI_WINDOW_MIN_WIDTH, EMOJI_WINDOW_MIN_HEIGHT = 240, 160
825
+ EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY = "emoji_window_geometry"
826
+
827
+
828
+ SUBMODULE_SUMMARY_OUTPUT_LIMIT_BYTES = 1024 * 1024
829
+ SUBMODULE_SUMMARY_COMMIT_LIMIT = 100
830
+ SUBMODULE_SUMMARY_DIFFERENCE_LIMIT = 500
831
+
832
+
833
+ def round_up_to_spacing(value: int) -> int:
834
+ """Round a size up to the next multiple of the smallest spacing, so that controls stay on the spacing grid."""
835
+ return ((value + SPACING_XS - 1) // SPACING_XS) * SPACING_XS
836
+
837
+
838
+ def application_icon_png_basename(side: int) -> str:
839
+ """Return the name of the packaged PNG picture of the application logo drawn ``side`` pixels wide."""
840
+ return APPLICATION_ICON_FILE_NAME_PREFIX + str(side) + APPLICATION_ICON_PNG_EXTENSION
841
+
842
+
843
+ def control_height(font_height: int) -> int:
844
+ """Round a readable control height upward without scaling logical pixels twice."""
845
+ return round_up_to_spacing(max(SPACING_XL, font_height + SPACING_SM))
846
+
847
+
848
+ def emoji_button_side(font_height: int) -> int:
849
+ """Return the side of a square emoji button: the symbol plus a small margin, never below the minimum, on the spacing grid."""
850
+ return round_up_to_spacing(max(EMOJI_BUTTON_MIN_SIDE, font_height + SPACING_XS))
851
+
852
+
853
+ def history_row_height(font_height: int) -> int:
854
+ """Keep the history compact while giving enlarged system fonts room to breathe."""
855
+ return max(HISTORY_ROW_MIN_HEIGHT, font_height + SPACING_SM)
856
+
857
+
858
+ def keep_selection_visible(widget: QWidget) -> bool:
859
+ """Retain the system highlight while another control in the window owns focus; answer whether the palette had to change.
860
+
861
+ Starts from the widget's own palette, which pins only the colors set here: a copy of the application palette would
862
+ also pin the window and field colors GitPane lays in a light theme, and the widget would keep them after a switch
863
+ to a dark theme. A palette that already has them is left alone, so no palette change is raised for nothing.
864
+ """
865
+ from PySide6.QtGui import QPalette
866
+
867
+ palette = widget.palette()
868
+ changed = False
869
+ for role in (QPalette.ColorRole.Highlight, QPalette.ColorRole.HighlightedText):
870
+ active_color = palette.color(QPalette.ColorGroup.Active, role)
871
+ if palette.color(QPalette.ColorGroup.Inactive, role) == active_color and palette.isBrushSet(QPalette.ColorGroup.Inactive, role):
872
+ continue
873
+ palette.setColor(QPalette.ColorGroup.Inactive, role, active_color)
874
+ changed = True
875
+ if changed:
876
+ widget.setPalette(palette)
877
+ return changed
878
+
879
+
880
+ def fit_window(widget: QWidget, minimum_width: int, minimum_height: int, available: QRect | None = None, *, platform_name: str = "") -> None:
881
+ """Keep the frame inside usable screen space, relaxing nominal minima on small screens.
882
+
883
+ The size is fitted on every platform. Where the compositor places windows itself, the frame is not moved back
884
+ inside the area: the compositor ignores the position. ``platform_name`` is Qt's platform name, read from Qt when not
885
+ given.
886
+ """
887
+ from PySide6.QtCore import QPoint
888
+
889
+ available = (available if available is not None else widget.screen().availableGeometry()).adjusted(SCREEN_MARGIN, SCREEN_MARGIN, -SCREEN_MARGIN, -SCREEN_MARGIN)
890
+ decoration = known_frame_margins(widget, platform_name)
891
+ width = max(1, available.width() - decoration.left() - decoration.right())
892
+ height = max(1, available.height() - decoration.top() - decoration.bottom())
893
+ widget.setMinimumSize(min(minimum_width, width), min(minimum_height, height))
894
+ widget.resize(min(widget.width(), width), min(widget.height(), height))
895
+ if not window_placement(platform_name).requests_position:
896
+ return
897
+ margins = known_frame_margins(widget, platform_name)
898
+ position = frame_position_inside(widget.geometry().marginsAdded(margins), available)
899
+ # While the widget's copy of its frame is still its client area, Qt places the client area at the position it is
900
+ # given and the window system adds the title bar above it (see center_window_at): move it by the margins only the
901
+ # native window knows, so the title bar stays inside the area.
902
+ if widget.frameGeometry() == widget.geometry():
903
+ position += QPoint(margins.left(), margins.top())
904
+ widget.move(position)
905
+
906
+
907
+ def known_frame_margins(window: QWidget, platform_name: str = "") -> QMargins:
908
+ """Return the title bar and borders the window system already gave a window, or no margin while it has given none.
909
+
910
+ The widget keeps its own copy of its frame, refreshed only when the window is moved or resized. On the platforms of
911
+ NATIVE_FRAME_MARGIN_PLATFORM_NAME_SET, a title bar created after that (drawn by Qt itself under Wayland once the
912
+ window is shown, or built late by AppKit on macOS) leaves the copy equal to the client area while the native window
913
+ already knows its margins, so those are read then. Elsewhere only the copy counts, and a window not decorated yet
914
+ reports no margin. ``platform_name`` is Qt's platform name, read from Qt when not given.
915
+ """
916
+ from PySide6.QtCore import QMargins
917
+ from PySide6.QtGui import QGuiApplication
918
+
919
+ frame = window.frameGeometry()
920
+ client = window.geometry()
921
+ if frame != client:
922
+ return QMargins(max(0, client.left() - frame.left()), max(0, client.top() - frame.top()), max(0, frame.right() - client.right()),
923
+ max(0, frame.bottom() - client.bottom()))
924
+ if (platform_name or QGuiApplication.platformName()) not in NATIVE_FRAME_MARGIN_PLATFORM_NAME_SET:
925
+ return QMargins()
926
+ native_window = window.windowHandle()
927
+ if native_window is None:
928
+ return QMargins()
929
+ return native_window.frameMargins()
930
+
931
+
932
+ def frame_position_inside(frame: QRect, available: QRect) -> QPoint:
933
+ """Return the top-left corner that keeps a window frame inside the usable area, moving it as little as possible."""
934
+ from PySide6.QtCore import QPoint
935
+
936
+ left = min(max(frame.left(), available.left()), available.right() - frame.width() + 1)
937
+ top = min(max(frame.top(), available.top()), available.bottom() - frame.height() + 1)
938
+ return QPoint(left, top)
939
+
940
+
941
+ # Names of Qt's platform plugins, as QGuiApplication.platformName() gives them; not the sys.platform values above.
942
+ # Qt's name for the macOS window system.
943
+ MACOS_PLATFORM_NAME = "cocoa"
944
+ WINDOWS_PLATFORM_NAME = "windows"
945
+ # Qt's name for the X11 window system.
946
+ X11_PLATFORM_NAME = "xcb"
947
+ # Qt's name for the Wayland window system.
948
+ WAYLAND_PLATFORM_NAME = "wayland"
949
+ # Platforms where the widget's copy of a window's frame can stay its client area while the native window already knows
950
+ # its title bar: AppKit can build the frame after Qt took the window's first geometry, and under Wayland Qt draws the
951
+ # title bar itself once the window is shown. There the native window's margins are read; elsewhere only the widget's
952
+ # copy counts, and while it is empty the title bar of a decorated window is borrowed.
953
+ NATIVE_FRAME_MARGIN_PLATFORM_NAME_SET = frozenset({MACOS_PLATFORM_NAME, WAYLAND_PLATFORM_NAME})
954
+
955
+
956
+ class WindowPlacement(Enum):
957
+ """Who places a new GitPane window on one Qt platform."""
958
+
959
+ CENTRED_ON_ANCHOR = "centred-on-anchor"
960
+ LEFT_TO_COMPOSITOR = "left-to-compositor"
961
+
962
+ @property
963
+ def requests_position(self) -> bool:
964
+ """Tell whether GitPane moves its windows itself; a compositor that places them ignores any position GitPane asks for."""
965
+ return self is WindowPlacement.CENTRED_ON_ANCHOR
966
+
967
+ @property
968
+ def size_waits_for_compositor_answer(self) -> bool:
969
+ """Tell whether a size asked just after a window is shown must wait for the compositor's answer to that show.
970
+
971
+ A compositor that places the windows answers the first show with a size of its own, the one it knew at that
972
+ moment, and the toolkit applies it: a size asked before that answer is overwritten for good.
973
+ """
974
+ return self is WindowPlacement.LEFT_TO_COMPOSITOR
975
+
976
+
977
+ DEFAULT_WINDOW_PLACEMENT = WindowPlacement.CENTRED_ON_ANCHOR
978
+ # Under Wayland the compositor places every top-level window and Qt drops the position a program asks for, so GitPane
979
+ # only sizes its windows there and asks for no position.
980
+ WINDOW_PLACEMENT_BY_PLATFORM_NAME: dict[str, WindowPlacement] = {
981
+ WAYLAND_PLATFORM_NAME: WindowPlacement.LEFT_TO_COMPOSITOR,
982
+ }
983
+
984
+
985
+ def window_placement(platform_name: str = "") -> WindowPlacement:
986
+ """Return who places GitPane's windows on a Qt platform, so the placement code only asks for positions where they are taken.
987
+
988
+ ``platform_name`` is Qt's platform name, read from Qt when not given; a platform missing from the table keeps the
989
+ default, where GitPane centres its windows itself.
990
+ """
991
+ from PySide6.QtGui import QGuiApplication
992
+
993
+ return WINDOW_PLACEMENT_BY_PLATFORM_NAME.get(platform_name or QGuiApplication.platformName(), DEFAULT_WINDOW_PLACEMENT)
994
+
995
+ # Style GitPane runs on every system, and the legacy Windows default style it replaces there.
996
+ FUSION_STYLE_NAME = "Fusion"
997
+ LEGACY_WINDOWS_STYLE_NAME = "windowsvista"
998
+
999
+
1000
+ def unknown_decoration_estimate(window: QWidget, decoration_reference: QWidget | None, platform_name: str = "") -> QMargins:
1001
+ """Estimate the title bar and borders that a window's frame does not count yet, so placing the frame leaves room for them.
1002
+
1003
+ Windows gives Qt a window's frame before the window is shown; an X11 window manager only adds it once the window is
1004
+ mapped, so until then the frame equals the client area and the title bar is borrowed from a decorated window. On
1005
+ macOS the native window knows its title bar from its creation, but Qt's copy of it in the widget can stay empty: a
1006
+ window that got its first geometry before AppKit had built its frame keeps a frame equal to its client area until
1007
+ the window system next moves or resizes it. Centring that frame puts the client area where the frame should be,
1008
+ and macOS then adds the title bar above it. Under Wayland Qt draws the title bar itself after the window is shown,
1009
+ with the same stale copy. So on macOS and under Wayland the native window's own margins are used; nothing is ever
1010
+ borrowed from another window on macOS, while under Wayland a window not decorated yet still borrows. The estimate
1011
+ is empty whenever the frame is already known or the window has no decoration. ``platform_name`` is Qt's platform
1012
+ name, read from Qt when not given.
1013
+ """
1014
+ from PySide6.QtCore import QMargins, Qt
1015
+ from PySide6.QtGui import QGuiApplication
1016
+ from shiboken6 import isValid
1017
+
1018
+ if window.frameGeometry() != window.geometry() or window.windowFlags() & Qt.WindowType.FramelessWindowHint:
1019
+ return QMargins()
1020
+ platform_name = platform_name or QGuiApplication.platformName()
1021
+ if platform_name in NATIVE_FRAME_MARGIN_PLATFORM_NAME_SET:
1022
+ native_margins = known_frame_margins(window, platform_name)
1023
+ # The macOS native window knows its title bar from its creation: empty margins there mean nothing to add yet,
1024
+ # and a title bar borrowed from another window would place the window too low.
1025
+ if not native_margins.isNull() or platform_name == MACOS_PLATFORM_NAME:
1026
+ return native_margins
1027
+ if decoration_reference is None or not isValid(decoration_reference):
1028
+ return QMargins()
1029
+ reference_frame = decoration_reference.frameGeometry()
1030
+ reference_client = decoration_reference.geometry()
1031
+ return QMargins(max(0, reference_client.left() - reference_frame.left()), max(0, reference_client.top() - reference_frame.top()),
1032
+ max(0, reference_frame.right() - reference_client.right()), max(0, reference_frame.bottom() - reference_client.bottom()))
1033
+
1034
+
1035
+ def uncounted_decoration_estimate(window: QWidget, decoration_reference: QWidget | None, platform_name: str = "") -> QMargins:
1036
+ """Estimate the title bar and borders that ``known_frame_margins`` does not count yet, to take off a usable area a size is fitted in.
1037
+
1038
+ The size fits (``fit_window``, the largest client size) already take off the margins the window system gave,
1039
+ including those only the native window knows. Taking the full estimate off the area as well would count such a
1040
+ title bar twice and leave the window shorter by its height, so the estimate only applies while no margin is known.
1041
+ """
1042
+ from PySide6.QtCore import QMargins
1043
+
1044
+ if not known_frame_margins(window, platform_name).isNull():
1045
+ return QMargins()
1046
+ return unknown_decoration_estimate(window, decoration_reference, platform_name)
1047
+
1048
+
1049
+ def center_window_at(window: QWidget, centre: QPoint, screen: QScreen, decoration_reference: QWidget | None = None, *, platform_name: str = "") -> None:
1050
+ """Put the window's frame centre on a point, then pull the frame back inside the screen's usable area if it crosses an edge.
1051
+
1052
+ Frames are compared, not client areas, so the title bar counts as part of the window; the current frame size is used.
1053
+ While the window system has not told the frame yet (X11 before the first map), ``decoration_reference`` lends its
1054
+ title bar and borders: Qt then places the client area at the position it is given and the window manager adds the
1055
+ title bar above it, so the client area is moved down by that estimate. Where the compositor places windows itself,
1056
+ nothing is moved: it would ignore the position. ``platform_name`` is Qt's platform name, read from Qt when not given.
1057
+ """
1058
+ from PySide6.QtCore import QPoint
1059
+
1060
+ if not window_placement(platform_name).requests_position:
1061
+ return
1062
+ available = screen.availableGeometry().adjusted(SCREEN_MARGIN, SCREEN_MARGIN, -SCREEN_MARGIN, -SCREEN_MARGIN)
1063
+ decoration = unknown_decoration_estimate(window, decoration_reference, platform_name)
1064
+ frame = window.frameGeometry().marginsAdded(decoration)
1065
+ frame.moveCenter(centre)
1066
+ window.move(frame_position_inside(frame, available) + QPoint(decoration.left(), decoration.top()))