gitpane 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. gitpane/__init__.py +0 -0
  2. gitpane/account/__init__.py +0 -0
  3. gitpane/account/account_choice.py +297 -0
  4. gitpane/account/address_owner.py +67 -0
  5. gitpane/account/authentication.py +1811 -0
  6. gitpane/account/authentication_worker.py +836 -0
  7. gitpane/account/credential_manager.py +480 -0
  8. gitpane/account/credential_manager_install.py +171 -0
  9. gitpane/account/keyring_reader.py +135 -0
  10. gitpane/account/keyring_state.py +158 -0
  11. gitpane/account/ssh.py +312 -0
  12. gitpane/account/ssh_askpass.py +216 -0
  13. gitpane/account/ssh_prompt.py +222 -0
  14. gitpane/application.py +684 -0
  15. gitpane/commit/__init__.py +0 -0
  16. gitpane/commit/commit_metadata.py +88 -0
  17. gitpane/commit/commit_push.py +276 -0
  18. gitpane/commit/commit_write.py +830 -0
  19. gitpane/config.py +1066 -0
  20. gitpane/controller/__init__.py +0 -0
  21. gitpane/controller/account_controller.py +677 -0
  22. gitpane/controller/clone_controller.py +776 -0
  23. gitpane/controller/close_readiness.py +212 -0
  24. gitpane/controller/commit_timing_controller.py +566 -0
  25. gitpane/controller/conflict_controller.py +885 -0
  26. gitpane/controller/console_registry.py +38 -0
  27. gitpane/controller/emoji_window_controller.py +95 -0
  28. gitpane/controller/file_action_controller.py +479 -0
  29. gitpane/controller/history_read_controller.py +671 -0
  30. gitpane/controller/history_rewrite_controller.py +739 -0
  31. gitpane/controller/navigation_controller.py +658 -0
  32. gitpane/controller/opening_controller.py +375 -0
  33. gitpane/controller/operation_policy.py +76 -0
  34. gitpane/controller/rebase_controller.py +797 -0
  35. gitpane/controller/rebase_editor_controller.py +309 -0
  36. gitpane/controller/reference_form_controller.py +1677 -0
  37. gitpane/controller/reference_read_controller.py +242 -0
  38. gitpane/controller/refresh_controller.py +477 -0
  39. gitpane/controller/replay_controller.py +805 -0
  40. gitpane/controller/session_registry.py +337 -0
  41. gitpane/controller/setting_controller.py +592 -0
  42. gitpane/controller/shutdown_controller.py +345 -0
  43. gitpane/controller/staging_controller.py +199 -0
  44. gitpane/controller/staging_session_registry.py +47 -0
  45. gitpane/controller/stash_controller.py +367 -0
  46. gitpane/controller/transport_controller.py +813 -0
  47. gitpane/controller/welcome_controller.py +102 -0
  48. gitpane/controller/window_activation.py +116 -0
  49. gitpane/controller/worktree_state_controller.py +169 -0
  50. gitpane/emoji/__init__.py +0 -0
  51. gitpane/emoji/emoji_catalogue.py +156 -0
  52. gitpane/git_config/__init__.py +0 -0
  53. gitpane/git_config/git_setting.py +441 -0
  54. gitpane/history/__init__.py +0 -0
  55. gitpane/history/avatar.py +241 -0
  56. gitpane/history/file_history.py +359 -0
  57. gitpane/history/graph.py +145 -0
  58. gitpane/history/history_filter.py +31 -0
  59. gitpane/history/history_parse.py +605 -0
  60. gitpane/history/repository.py +1600 -0
  61. gitpane/history/repository_value.py +341 -0
  62. gitpane/history/revision.py +180 -0
  63. gitpane/history/revision_file.py +182 -0
  64. gitpane/history_rewrite/__init__.py +0 -0
  65. gitpane/history_rewrite/graph.py +210 -0
  66. gitpane/history_rewrite/guard.py +288 -0
  67. gitpane/history_rewrite/index.py +125 -0
  68. gitpane/history_rewrite/prepare.py +378 -0
  69. gitpane/history_rewrite/protocol.py +320 -0
  70. gitpane/history_rewrite/publish.py +444 -0
  71. gitpane/history_rewrite/recovery.py +481 -0
  72. gitpane/history_rewrite/value.py +229 -0
  73. gitpane/operation/__init__.py +0 -0
  74. gitpane/operation/console_line.py +313 -0
  75. gitpane/operation/console_tone.py +76 -0
  76. gitpane/operation/diagnostic.py +954 -0
  77. gitpane/operation/launch_signature.py +254 -0
  78. gitpane/operation/operation_status.py +87 -0
  79. gitpane/operation/operation_trace.py +766 -0
  80. gitpane/operation/repository_operation.py +781 -0
  81. gitpane/preference/__init__.py +0 -0
  82. gitpane/preference/preference_location.py +40 -0
  83. gitpane/preference/preference_store.py +590 -0
  84. gitpane/process/__init__.py +0 -0
  85. gitpane/process/exact_arguments.py +77 -0
  86. gitpane/process/git_output_text.py +20 -0
  87. gitpane/process/process_group.py +851 -0
  88. gitpane/process/process_log.py +114 -0
  89. gitpane/process/process_runner.py +1033 -0
  90. gitpane/process/released_child_watch.py +138 -0
  91. gitpane/recent/__init__.py +0 -0
  92. gitpane/recent/mount_table.py +422 -0
  93. gitpane/recent/recent_availability.py +414 -0
  94. gitpane/recent/recent_name.py +11 -0
  95. gitpane/recent/recent_search.py +114 -0
  96. gitpane/reference/__init__.py +0 -0
  97. gitpane/reference/reference_edit.py +2030 -0
  98. gitpane/reference/reference_snapshot.py +196 -0
  99. gitpane/reference/stash.py +730 -0
  100. gitpane/reference/stash_list.py +164 -0
  101. gitpane/sequencer/__init__.py +0 -0
  102. gitpane/sequencer/commit_message_rule.py +154 -0
  103. gitpane/sequencer/conflict.py +506 -0
  104. gitpane/sequencer/merge_recovery.py +163 -0
  105. gitpane/sequencer/merge_state.py +166 -0
  106. gitpane/sequencer/mergetool.py +856 -0
  107. gitpane/sequencer/rebase.py +750 -0
  108. gitpane/sequencer/rebase_edit_file.py +224 -0
  109. gitpane/sequencer/rebase_state.py +523 -0
  110. gitpane/sequencer/replay.py +1435 -0
  111. gitpane/sequencer/replay_legacy.py +253 -0
  112. gitpane/sequencer/replay_state.py +747 -0
  113. gitpane/submodule/__init__.py +0 -0
  114. gitpane/submodule/checkout_refusal.py +102 -0
  115. gitpane/submodule/checkout_refusal_notice.py +111 -0
  116. gitpane/submodule/cloned_without_checkout.py +65 -0
  117. gitpane/submodule/submodule_discovery.py +1180 -0
  118. gitpane/submodule/submodule_loss.py +218 -0
  119. gitpane/system/__init__.py +0 -0
  120. gitpane/system/appearance_portal.py +125 -0
  121. gitpane/system/application_identity.py +48 -0
  122. gitpane/system/command_line.py +36 -0
  123. gitpane/system/desktop_icon_fact.py +128 -0
  124. gitpane/system/desktop_icon_support.py +270 -0
  125. gitpane/system/desktop_shortcut.py +536 -0
  126. gitpane/system/icns_image.py +35 -0
  127. gitpane/system/installed_launcher.py +136 -0
  128. gitpane/system/linux_distribution.py +151 -0
  129. gitpane/system/network_folder.py +46 -0
  130. gitpane/system/path_identity.py +95 -0
  131. gitpane/system/private_tree_removal.py +47 -0
  132. gitpane/system/process_program.py +63 -0
  133. gitpane/system/qt_folder.py +38 -0
  134. gitpane/system/ready_channel_watch.py +67 -0
  135. gitpane/system/startup_activation.py +65 -0
  136. gitpane/system/terminal_detach.py +384 -0
  137. gitpane/system/xdg_session.py +85 -0
  138. gitpane/text/__init__.py +0 -0
  139. gitpane/text/text_query.py +97 -0
  140. gitpane/text/typed_path.py +75 -0
  141. gitpane/text/typed_text.py +17 -0
  142. gitpane/thread/__init__.py +0 -0
  143. gitpane/thread/weak_signal.py +93 -0
  144. gitpane/tool/__init__.py +0 -0
  145. gitpane/tool/app_bundle.py +164 -0
  146. gitpane/tool/desktop_text_editor.py +156 -0
  147. gitpane/tool/difftool.py +429 -0
  148. gitpane/tool/editor.py +445 -0
  149. gitpane/tool/editor_bridge.py +193 -0
  150. gitpane/tool/editor_recipe.py +481 -0
  151. gitpane/tool/file_reveal.py +94 -0
  152. gitpane/tool/git_executable.py +264 -0
  153. gitpane/tool/git_version.py +62 -0
  154. gitpane/tool/kdiff3.py +41 -0
  155. gitpane/tool/kdiff3_install.py +162 -0
  156. gitpane/tool/launch_services_text_editor.py +104 -0
  157. gitpane/tool/mergetool_recipe.py +84 -0
  158. gitpane/tool/system_editor_default.py +80 -0
  159. gitpane/tool/system_text_editor.py +44 -0
  160. gitpane/tool/system_tool.py +816 -0
  161. gitpane/tool/terminal_recipe.py +390 -0
  162. gitpane/transport/__init__.py +0 -0
  163. gitpane/transport/branch_exchange.py +1690 -0
  164. gitpane/transport/clone.py +1273 -0
  165. gitpane/transport/credential_read_retry.py +35 -0
  166. gitpane/transport/git_refusal.py +207 -0
  167. gitpane/transport/push_tracking.py +121 -0
  168. gitpane/transport/refusal_notice.py +225 -0
  169. gitpane/transport/remote.py +747 -0
  170. gitpane/transport/remote_config.py +544 -0
  171. gitpane/ui/__init__.py +0 -0
  172. gitpane/ui/console/__init__.py +0 -0
  173. gitpane/ui/console/error_dialog.py +332 -0
  174. gitpane/ui/console/operation_dialog.py +408 -0
  175. gitpane/ui/console/operation_output.py +91 -0
  176. gitpane/ui/console/operation_output_view.py +373 -0
  177. gitpane/ui/emoji/__init__.py +0 -0
  178. gitpane/ui/emoji/emoji_font.py +257 -0
  179. gitpane/ui/emoji/emoji_grid.py +125 -0
  180. gitpane/ui/emoji/emoji_window.py +154 -0
  181. gitpane/ui/emoji/file_action_icon.py +131 -0
  182. gitpane/ui/emoji/opentype_family_name.py +182 -0
  183. gitpane/ui/history_rewrite/__init__.py +0 -0
  184. gitpane/ui/history_rewrite/details.py +78 -0
  185. gitpane/ui/history_rewrite/preview.py +565 -0
  186. gitpane/ui/history_rewrite/preview_model.py +291 -0
  187. gitpane/ui/history_rewrite/question.py +87 -0
  188. gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
  189. gitpane/ui/look/__init__.py +0 -0
  190. gitpane/ui/look/action_icon.py +504 -0
  191. gitpane/ui/look/application_icon.py +78 -0
  192. gitpane/ui/look/application_style.py +317 -0
  193. gitpane/ui/look/avatar_label.py +53 -0
  194. gitpane/ui/look/color_contrast.py +43 -0
  195. gitpane/ui/look/desktop_appearance_follower.py +118 -0
  196. gitpane/ui/look/fixed_font.py +21 -0
  197. gitpane/ui/look/gtk_settings.py +116 -0
  198. gitpane/ui/look/icon_density.py +16 -0
  199. gitpane/ui/look/reference_font.py +19 -0
  200. gitpane/ui/look/theme_color.py +63 -0
  201. gitpane/ui/look/theme_look.py +95 -0
  202. gitpane/ui/look/theme_palette.py +173 -0
  203. gitpane/ui/look/warning_icon.py +86 -0
  204. gitpane/ui/look/windows_control_look.py +250 -0
  205. gitpane/ui/reader/__init__.py +0 -0
  206. gitpane/ui/reader/commit_message.py +129 -0
  207. gitpane/ui/reader/commit_panel.py +378 -0
  208. gitpane/ui/reader/diff_panel.py +626 -0
  209. gitpane/ui/reader/file_action.py +138 -0
  210. gitpane/ui/reader/file_tree_panel.py +235 -0
  211. gitpane/ui/reader/reader_toolbar.py +56 -0
  212. gitpane/ui/reader/revision_tree.py +80 -0
  213. gitpane/ui/reader/search_result.py +165 -0
  214. gitpane/ui/reader/source_text.py +134 -0
  215. gitpane/ui/reader/source_view.py +151 -0
  216. gitpane/ui/reader/syntax_span.py +67 -0
  217. gitpane/ui/reference/__init__.py +0 -0
  218. gitpane/ui/reference/branch_selector.py +106 -0
  219. gitpane/ui/reference/checkout_dialog.py +419 -0
  220. gitpane/ui/reference/checkout_revision_dialog.py +182 -0
  221. gitpane/ui/reference/commit_summary.py +339 -0
  222. gitpane/ui/reference/create_branch_dialog.py +346 -0
  223. gitpane/ui/reference/create_tag_dialog.py +283 -0
  224. gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
  225. gitpane/ui/reference/delete_tag_dialog.py +394 -0
  226. gitpane/ui/reference/form_opening.py +53 -0
  227. gitpane/ui/reference/reference_dialog.py +417 -0
  228. gitpane/ui/reference/reference_feedback.py +62 -0
  229. gitpane/ui/reference/reference_form.py +139 -0
  230. gitpane/ui/reference/rename_branch_dialog.py +179 -0
  231. gitpane/ui/reference/reset_branch_dialog.py +430 -0
  232. gitpane/ui/reference/reset_dialog.py +263 -0
  233. gitpane/ui/reference/revision_dialog.py +121 -0
  234. gitpane/ui/reference/revision_picker.py +227 -0
  235. gitpane/ui/reference/select_branches_dialog.py +199 -0
  236. gitpane/ui/reference/stash_dialog.py +171 -0
  237. gitpane/ui/repository/__init__.py +0 -0
  238. gitpane/ui/repository/graph_delegate.py +298 -0
  239. gitpane/ui/repository/history_avatar.py +130 -0
  240. gitpane/ui/repository/history_model.py +179 -0
  241. gitpane/ui/repository/repository_list_popup.py +319 -0
  242. gitpane/ui/repository/repository_window.py +2580 -0
  243. gitpane/ui/resource/ArrowDown.png +0 -0
  244. gitpane/ui/resource/ArrowUp.png +0 -0
  245. gitpane/ui/resource/BranchCheckout.png +0 -0
  246. gitpane/ui/resource/BranchCreate.png +0 -0
  247. gitpane/ui/resource/BranchDelete.png +0 -0
  248. gitpane/ui/resource/CopyToClipboard.png +0 -0
  249. gitpane/ui/resource/GitPane-16.png +0 -0
  250. gitpane/ui/resource/GitPane-24.png +0 -0
  251. gitpane/ui/resource/GitPane-256.png +0 -0
  252. gitpane/ui/resource/GitPane-32.png +0 -0
  253. gitpane/ui/resource/GitPane-48.png +0 -0
  254. gitpane/ui/resource/GitPane-64.png +0 -0
  255. gitpane/ui/resource/GitPane.ico +0 -0
  256. gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  257. gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
  258. gitpane/ui/resource/Renamed.png +0 -0
  259. gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
  260. gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
  261. gitpane/ui/resource/Stash.png +0 -0
  262. gitpane/ui/resource/Tag.png +0 -0
  263. gitpane/ui/resource/TagCreate.png +0 -0
  264. gitpane/ui/resource/TagDelete.png +0 -0
  265. gitpane/ui/resource/Unmerged.png +0 -0
  266. gitpane/ui/resource/checkout.png +0 -0
  267. gitpane/ui/sequencer/__init__.py +0 -0
  268. gitpane/ui/sequencer/conflict_dialog.py +532 -0
  269. gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
  270. gitpane/ui/sequencer/rebase_dialog.py +461 -0
  271. gitpane/ui/sequencer/rebase_editor_window.py +220 -0
  272. gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
  273. gitpane/ui/sequencer/replay_dialog.py +501 -0
  274. gitpane/ui/setting/__init__.py +0 -0
  275. gitpane/ui/setting/account_page.py +724 -0
  276. gitpane/ui/setting/add_account_dialog.py +1005 -0
  277. gitpane/ui/setting/credential_manager_dialog.py +151 -0
  278. gitpane/ui/setting/git_setting_widget.py +388 -0
  279. gitpane/ui/setting/setting_block.py +19 -0
  280. gitpane/ui/setting/setting_dialog.py +750 -0
  281. gitpane/ui/setting/ssh_widget.py +232 -0
  282. gitpane/ui/setting/utility_page.py +240 -0
  283. gitpane/ui/staging/__init__.py +0 -0
  284. gitpane/ui/staging/staging_action.py +356 -0
  285. gitpane/ui/staging/staging_window.py +745 -0
  286. gitpane/ui/transport/__init__.py +0 -0
  287. gitpane/ui/transport/clone_dialog.py +879 -0
  288. gitpane/ui/transport/push_dialog.py +580 -0
  289. gitpane/ui/transport/push_rejection_prompt.py +100 -0
  290. gitpane/ui/transport/push_tracking_question.py +88 -0
  291. gitpane/ui/transport/remote_dialog.py +943 -0
  292. gitpane/ui/welcome/__init__.py +0 -0
  293. gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
  294. gitpane/ui/welcome/welcome_window.py +1137 -0
  295. gitpane/ui/widget/__init__.py +0 -0
  296. gitpane/ui/widget/busy_indicator.py +106 -0
  297. gitpane/ui/widget/commit_date_text.py +55 -0
  298. gitpane/ui/widget/content_growth.py +262 -0
  299. gitpane/ui/widget/content_measure.py +99 -0
  300. gitpane/ui/widget/dialog_layout.py +668 -0
  301. gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
  302. gitpane/ui/widget/flow_layout.py +110 -0
  303. gitpane/ui/widget/long_text.py +542 -0
  304. gitpane/ui/widget/mnemonic.py +13 -0
  305. gitpane/ui/widget/numbered_text_edit.py +127 -0
  306. gitpane/ui/widget/plain_label.py +15 -0
  307. gitpane/ui/widget/plain_tooltip.py +19 -0
  308. gitpane/ui/widget/refresh_advice.py +25 -0
  309. gitpane/ui/widget/result_label.py +227 -0
  310. gitpane/ui/widget/result_status_bar.py +53 -0
  311. gitpane/ui/widget/scrolled_text.py +69 -0
  312. gitpane/ui/widget/shortcut_text.py +31 -0
  313. gitpane/ui/widget/standard_icon_label.py +55 -0
  314. gitpane/ui/widget/system_shortcut.py +75 -0
  315. gitpane/ui/widget/unavailable_reason.py +29 -0
  316. gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
  317. gitpane/ui/window_control/__init__.py +0 -0
  318. gitpane/ui/window_control/button_focus.py +42 -0
  319. gitpane/ui/window_control/compositor_answer.py +152 -0
  320. gitpane/ui/window_control/compositor_growth_cap.py +73 -0
  321. gitpane/ui/window_control/desktop_activation.py +203 -0
  322. gitpane/ui/window_control/dialog_modality.py +122 -0
  323. gitpane/ui/window_control/item_activation.py +42 -0
  324. gitpane/ui/window_control/modal_focus_relay.py +124 -0
  325. gitpane/ui/window_control/wayland_activation.py +695 -0
  326. gitpane/ui/window_control/window_placement.py +305 -0
  327. gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  328. gitpane/vendor/w32.exe +0 -0
  329. gitpane/vendor/w64-arm.exe +0 -0
  330. gitpane/vendor/w64.exe +0 -0
  331. gitpane/worktree/__init__.py +0 -0
  332. gitpane/worktree/command_sequence.py +110 -0
  333. gitpane/worktree/file_action_target.py +61 -0
  334. gitpane/worktree/file_menu_action.py +52 -0
  335. gitpane/worktree/historical_restore.py +361 -0
  336. gitpane/worktree/ignore_rule.py +266 -0
  337. gitpane/worktree/mutation.py +1539 -0
  338. gitpane/worktree/partial_patch.py +435 -0
  339. gitpane/worktree/staged_reset.py +447 -0
  340. gitpane/worktree/staging.py +1418 -0
  341. gitpane/worktree/trash.py +302 -0
  342. gitpane/worktree/worktree_file.py +406 -0
  343. gitpane/worktree/worktree_service.py +584 -0
  344. gitpane/worktree/worktree_snapshot.py +630 -0
  345. gitpane-1.0.0.dist-info/METADATA +141 -0
  346. gitpane-1.0.0.dist-info/RECORD +353 -0
  347. gitpane-1.0.0.dist-info/WHEEL +5 -0
  348. gitpane-1.0.0.dist-info/entry_points.txt +2 -0
  349. gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
  350. gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
  351. gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
  352. gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
  353. gitpane-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,218 @@
1
+ """Check that nothing would be lost before a cherry-pick or revert replaces the folder of a submodule.
2
+
3
+ Git, launched without descending into submodules, removes the whole folder of a submodule whose entry the replayed
4
+ commit turns into a file, and writes the files of a folder or a link that takes its place. That is only safe when
5
+ every file in the folder, nested submodules included, is already recorded in the submodule's own history, and when
6
+ that history lives in the parent's Git folder rather than inside the folder about to go. Every check here only reads.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from enum import Enum
13
+ from typing import TYPE_CHECKING
14
+
15
+ from gitpane.config import GIT_PATH_SEPARATOR, SUBMODULE_LOSS_PATH_DISPLAY_LIMIT
16
+ from gitpane.reference.reference_edit import GITLINK_MODE
17
+ from gitpane.system.path_identity import canonical_path
18
+ from gitpane.worktree.command_sequence import FileTask, GitCommand, ReadBatch
19
+ from gitpane.worktree.worktree_snapshot import parse_index_listing
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Generator
23
+
24
+ from gitpane.history.repository_value import RepositoryContext
25
+
26
+ GIT_ENTRY_BASENAME = ".git"
27
+ # The folder, inside a repository's Git folder, where Git keeps the history of each of its absorbed submodules.
28
+ SUBMODULE_GIT_DIRS_BASENAME = "modules"
29
+ INDEX_ENTRY_SEPARATOR = b"\0"
30
+ INDEX_PATH_SEPARATOR = b"\t"
31
+ INDEX_FIELD_SEPARATOR = b" "
32
+ INDEX_MODE_POSITION = 0
33
+ # A porcelain v1 status record is two status letters, a space, then the path.
34
+ STATUS_SEPARATOR_POSITION = 2
35
+ STATUS_SEPARATOR = b" "
36
+ STATUS_PATH_OFFSET = 3
37
+ # The two lines rev-parse answers for the location of a submodule repository: its root, then its Git folder.
38
+ LOCATION_FIELD_COUNT = 2
39
+ LOCATION_ROOT_POSITION = 0
40
+ LOCATION_GIT_DIR_POSITION = 1
41
+ # Every file that would be lost is looked for until one more than the display limit is found, which is enough to say
42
+ # that the list goes on.
43
+ LOST_PATH_SEARCH_LIMIT = SUBMODULE_LOSS_PATH_DISPLAY_LIMIT + 1
44
+ SUBMODULE_UNREADABLE_CAUSE = "Git gave back an unexpected answer about a submodule. Refresh before applying a commit."
45
+
46
+
47
+ class SubmoduleFolder(Enum):
48
+ """What the folder recorded for a submodule holds on disk, read without following links."""
49
+
50
+ ABSENT = "absent"
51
+ EMPTY = "empty"
52
+ NOT_A_FOLDER = "not a folder"
53
+ UNRECORDED_FILES = "unrecorded files"
54
+ EMBEDDED_HISTORY = "embedded history"
55
+ REPOSITORY = "repository"
56
+
57
+
58
+ def gitlink_paths(index_entries: bytes) -> list[bytes]:
59
+ """Name the submodule entries of NUL-framed `ls-files --stage` records, in the order Git lists them."""
60
+ paths = []
61
+ for record in index_entries.split(INDEX_ENTRY_SEPARATOR):
62
+ if not record:
63
+ continue
64
+ metadata, separator, path = record.partition(INDEX_PATH_SEPARATOR)
65
+ if not separator or not path:
66
+ raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
67
+ if metadata.split(INDEX_FIELD_SEPARATOR)[INDEX_MODE_POSITION] == GITLINK_MODE:
68
+ paths.append(path)
69
+ return paths
70
+
71
+
72
+ def submodule_git_dirs(context: RepositoryContext) -> tuple[str, str]:
73
+ """Name the folders where Git keeps the history of the absorbed submodules of a worktree: its own Git folder's, then the common one's."""
74
+ return os.path.join(context.git_dir, SUBMODULE_GIT_DIRS_BASENAME), os.path.join(context.common_git_dir, SUBMODULE_GIT_DIRS_BASENAME)
75
+
76
+
77
+ def join_git_path(parent_path: str, child_path: str) -> str:
78
+ """Join two repository paths with Git's own separator, whatever the system's."""
79
+ return parent_path + GIT_PATH_SEPARATOR + child_path
80
+
81
+
82
+ def inspect_submodule_folder(directory: str) -> tuple[SubmoduleFolder, list[str]]:
83
+ """Say what the folder of a submodule holds, without following links, and name the first files of a folder that holds no repository.
84
+
85
+ A folder with no ".git" entry holds nothing any history records, so each of its files (up to one more than the
86
+ display limit, as paths relative to the folder with Git's separator) would be lost with it. Empty sub-folders
87
+ lose nothing.
88
+ """
89
+ if not os.path.lexists(directory):
90
+ return SubmoduleFolder.ABSENT, []
91
+ if os.path.islink(directory) or os.path.isjunction(directory) or not os.path.isdir(directory):
92
+ return SubmoduleFolder.NOT_A_FOLDER, []
93
+ git_entry_filename = os.path.join(directory, GIT_ENTRY_BASENAME)
94
+ if os.path.lexists(git_entry_filename):
95
+ if os.path.isfile(git_entry_filename) and not os.path.islink(git_entry_filename):
96
+ return SubmoduleFolder.REPOSITORY, []
97
+ return SubmoduleFolder.EMBEDDED_HISTORY, []
98
+ found_paths: list[str] = []
99
+ pending_dirs = [directory]
100
+ while pending_dirs and len(found_paths) < LOST_PATH_SEARCH_LIMIT:
101
+ current_dir = pending_dirs.pop()
102
+ with os.scandir(current_dir) as entries:
103
+ for entry in entries:
104
+ if entry.is_dir(follow_symlinks=False) and not entry.is_junction():
105
+ pending_dirs.append(entry.path)
106
+ continue
107
+ found_paths.append(os.path.relpath(entry.path, directory).replace(os.sep, GIT_PATH_SEPARATOR))
108
+ if len(found_paths) >= LOST_PATH_SEARCH_LIMIT:
109
+ break
110
+ if not found_paths:
111
+ return SubmoduleFolder.EMPTY, []
112
+ return SubmoduleFolder.UNRECORDED_FILES, sorted(found_paths)
113
+
114
+
115
+ def is_repository_of(directory: str, root_dir: str, git_dir: str, modules_dirs: tuple[str, ...]) -> tuple[bool, bool]:
116
+ """Tell whether Git found the folder's own repository rather than an enclosing one, then whether its history lives in the parent's Git folder."""
117
+ own_root = canonical_path(root_dir) == canonical_path(directory)
118
+ canonical_git_dir = canonical_path(git_dir)
119
+ absorbed = False
120
+ for modules_dir in modules_dirs:
121
+ if canonical_git_dir.startswith(canonical_path(modules_dir) + os.sep):
122
+ absorbed = True
123
+ return own_root, absorbed
124
+
125
+
126
+ def status_paths(output: bytes) -> list[bytes]:
127
+ """Name every path of a NUL-framed porcelain v1 status listed without rename detection, in Git's order."""
128
+ paths = []
129
+ for record in output.split(INDEX_ENTRY_SEPARATOR):
130
+ if not record:
131
+ continue
132
+ if record[STATUS_SEPARATOR_POSITION:STATUS_PATH_OFFSET] != STATUS_SEPARATOR or len(record) <= STATUS_PATH_OFFSET:
133
+ raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
134
+ paths.append(record[STATUS_PATH_OFFSET:])
135
+ return paths
136
+
137
+
138
+ def existing_paths(directory: str, paths: list[bytes]) -> list[bytes]:
139
+ """Keep the paths, relative to a folder, that name something on disk."""
140
+ present_paths = []
141
+ for path in paths:
142
+ if os.path.lexists(os.path.join(directory, *os.fsdecode(path).split(GIT_PATH_SEPARATOR))):
143
+ present_paths.append(path)
144
+ return present_paths
145
+
146
+
147
+ def submodule_loss_text(submodule_path: str, lost_paths: list[str]) -> str:
148
+ """Describe a refused replay that would lose files of a submodule, naming the submodule and the first of those files."""
149
+ shown_paths = lost_paths[:SUBMODULE_LOSS_PATH_DISPLAY_LIMIT]
150
+ text = f'The selected commit replaces the submodule "{submodule_path}", and these files in it would be lost: ' + ", ".join(shown_paths)
151
+ if len(lost_paths) > SUBMODULE_LOSS_PATH_DISPLAY_LIMIT:
152
+ text += ", and more"
153
+ return text + ". Commit, move or delete them first. It was not applied."
154
+
155
+
156
+ def submodule_history_loss_text(submodule_path: str, repository_path: str) -> str:
157
+ """Describe a refused replay that would delete a Git history kept inside the folder of a submodule."""
158
+ return (f'The selected commit replaces the submodule "{submodule_path}", but the Git history of "{repository_path}" is kept inside that folder and '
159
+ 'would be lost. Run "git submodule absorbgitdirs" in the parent repository first. It was not applied.')
160
+
161
+
162
+ def submodule_loss_refusal(context: RepositoryContext, submodule_path: bytes) -> Generator:
163
+ """Answer why replacing the folder of one submodule of this worktree would lose something, or "" when nothing would be lost.
164
+
165
+ The folder and every populated submodule nested in it are checked one repository at a time, without Git
166
+ descending on its own: an absent or empty folder loses nothing; a link, a file, or files without a repository
167
+ would be lost; a history kept inside the folder would be lost; otherwise every changed, staged, untracked or
168
+ ignored file is lost, and so is any file whose changes Git was told to hide. A malformed answer raises ValueError.
169
+ """
170
+ display_path = os.fsdecode(submodule_path)
171
+ modules_dirs = submodule_git_dirs(context)
172
+ lost_paths: list[str] = []
173
+ pending_paths = [display_path]
174
+ while pending_paths and len(lost_paths) < LOST_PATH_SEARCH_LIMIT:
175
+ repository_path = pending_paths.pop(0)
176
+ directory = os.path.join(context.worktree_dir, *repository_path.split(GIT_PATH_SEPARATOR))
177
+ folder, found_paths = yield FileTask(lambda directory=directory: inspect_submodule_folder(directory))
178
+ if folder in (SubmoduleFolder.ABSENT, SubmoduleFolder.EMPTY):
179
+ continue
180
+ if folder == SubmoduleFolder.NOT_A_FOLDER:
181
+ lost_paths.append(repository_path)
182
+ continue
183
+ if folder == SubmoduleFolder.UNRECORDED_FILES:
184
+ for found_path in found_paths:
185
+ lost_paths.append(join_git_path(repository_path, found_path))
186
+ continue
187
+ if folder == SubmoduleFolder.EMBEDDED_HISTORY:
188
+ return submodule_history_loss_text(display_path, repository_path)
189
+ location = yield GitCommand(["-C", directory, "rev-parse", "--show-toplevel", "--absolute-git-dir"])
190
+ fields = os.fsdecode(location.removesuffix(b"\n")).split("\n")
191
+ if len(fields) != LOCATION_FIELD_COUNT:
192
+ raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
193
+ own_root, absorbed = yield FileTask(lambda directory=directory, fields=fields: is_repository_of(directory, fields[LOCATION_ROOT_POSITION],
194
+ fields[LOCATION_GIT_DIR_POSITION], modules_dirs))
195
+ if not own_root:
196
+ raise ValueError(SUBMODULE_UNREADABLE_CAUSE)
197
+ if not absorbed:
198
+ return submodule_history_loss_text(display_path, repository_path)
199
+ # Git's own nested submodule check is turned on whatever the configuration says, so a nested submodule with
200
+ # local work shows up here too; its files are then listed by its own check.
201
+ status_output, index_output = yield ReadBatch((
202
+ GitCommand(["-C", directory, "status", "--porcelain=v1", "-z", "--no-renames", "--ignored", "--untracked-files=all", "--ignore-submodules=none"]),
203
+ GitCommand(["-C", directory, "ls-files", "--stage", "-v", "-z"]),
204
+ ))
205
+ listing = parse_index_listing(index_output)
206
+ nested_paths = gitlink_paths(listing.index_entries)
207
+ nested_path_set = set(nested_paths)
208
+ for path in status_paths(status_output):
209
+ if path not in nested_path_set:
210
+ lost_paths.append(join_git_path(repository_path, os.fsdecode(path)))
211
+ hidden_paths = yield FileTask(lambda directory=directory, listing=listing: existing_paths(directory, list(listing.hidden_index_paths)))
212
+ for path in hidden_paths:
213
+ lost_paths.append(join_git_path(repository_path, os.fsdecode(path)))
214
+ for nested_path in nested_paths:
215
+ pending_paths.append(join_git_path(repository_path, os.fsdecode(nested_path)))
216
+ if not lost_paths:
217
+ return ""
218
+ return submodule_loss_text(display_path, lost_paths)
File without changes
@@ -0,0 +1,125 @@
1
+ """Read the light or dark preference the desktop's Settings portal declares, at start or when it changes.
2
+
3
+ The freedesktop Settings portal publishes the preference the user picked in the desktop's settings as the
4
+ ``color-scheme`` key of the ``org.freedesktop.appearance`` namespace: 0 for no preference, 1 for dark, 2 for light,
5
+ and any other value counts as no preference. Qt reads that portal too, but asynchronously, so GitPane reads it itself
6
+ once, blocking and bounded, before its first window. Every failure of that read (no bus, no portal, an error, no answer
7
+ in time, a value of another type) gives no preference, which keeps what Qt shows without it.
8
+
9
+ The connection is received from the caller, so the tests read a private bus and never reach the user's session bus.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from enum import Enum
15
+ from typing import TYPE_CHECKING
16
+
17
+ from PySide6.QtCore import SLOT, QObject, Signal, Slot
18
+ from PySide6.QtDBus import QDBus, QDBusConnection, QDBusMessage, QDBusVariant
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
23
+ PORTAL_SERVICE_NAME = "org.freedesktop.portal.Desktop"
24
+ PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop"
25
+ PORTAL_SETTINGS_INTERFACE = "org.freedesktop.portal.Settings"
26
+ APPEARANCE_NAMESPACE = "org.freedesktop.appearance"
27
+ COLOR_SCHEME_KEY = "color-scheme"
28
+ READ_ONE_METHOD = "ReadOne"
29
+ SETTING_CHANGED_SIGNAL = "SettingChanged"
30
+ # A ReadOne reply carries exactly one value.
31
+ READ_ONE_REPLY_ARGUMENT_COUNT = 1
32
+ READ_ONE_REPLY_VALUE_INDEX = 0
33
+ # ReadOne wraps the value in one variant; the older Read method, which a portal without ReadOne still answers, wraps it in two.
34
+ MAXIMUM_VARIANT_LAYER_COUNT = 2
35
+ # SettingChanged carries the namespace, the key, then the value in a variant.
36
+ SETTING_CHANGED_ARGUMENT_COUNT = 3
37
+ SETTING_CHANGED_NAMESPACE_INDEX = 0
38
+ SETTING_CHANGED_KEY_INDEX = 1
39
+ SETTING_CHANGED_VALUE_INDEX = 2
40
+
41
+
42
+ class PortalColorScheme(Enum):
43
+ """The light or dark preference the Settings portal declares, with the value the portal sends for each."""
44
+
45
+ NO_PREFERENCE = 0
46
+ PREFER_DARK = 1
47
+ PREFER_LIGHT = 2
48
+
49
+ @property
50
+ def prefers_dark(self) -> bool:
51
+ """Tell whether the desktop asks applications for their dark appearance."""
52
+ return self == PortalColorScheme.PREFER_DARK
53
+
54
+
55
+ def portal_color_scheme(value: object) -> PortalColorScheme:
56
+ """Turn a value read from the portal into a preference, unwrapping its variants first.
57
+
58
+ Only a whole number names a preference: a boolean or a text is not one, even when it would compare equal to one.
59
+ The specification says that an unknown value means no preference, and so does anything that is not a number.
60
+ """
61
+ for _layer in range(MAXIMUM_VARIANT_LAYER_COUNT):
62
+ if type(value) is not QDBusVariant:
63
+ break
64
+ value = value.variant()
65
+ if type(value) is not int:
66
+ return PortalColorScheme.NO_PREFERENCE
67
+ try:
68
+ return PortalColorScheme(value)
69
+ except ValueError:
70
+ return PortalColorScheme.NO_PREFERENCE
71
+
72
+
73
+ def read_portal_color_scheme(connection: QDBusConnection, timeout_ms: int) -> PortalColorScheme:
74
+ """Ask the portal on ``connection`` for the colour scheme and wait at most ``timeout_ms`` for its answer.
75
+
76
+ The call blocks, so the preference is known before the first window is shown, when Qt's own asynchronous read has
77
+ not answered yet. A closed connection, an error reply (no portal, a portal without that key, no answer in time) or a
78
+ reply of another shape gives no preference.
79
+ """
80
+ if not connection.isConnected():
81
+ return PortalColorScheme.NO_PREFERENCE
82
+ message = QDBusMessage.createMethodCall(PORTAL_SERVICE_NAME, PORTAL_OBJECT_PATH, PORTAL_SETTINGS_INTERFACE, READ_ONE_METHOD)
83
+ message.setArguments([APPEARANCE_NAMESPACE, COLOR_SCHEME_KEY])
84
+ # D-Bus activation is left allowed, unlike the keyring read: Qt's own theme already starts the portal when the
85
+ # application starts, and the portal holds no secret, so starting it changes nothing the user would not expect.
86
+ reply = connection.call(message, QDBus.CallMode.Block, timeout_ms)
87
+ if reply.type() != QDBusMessage.MessageType.ReplyMessage:
88
+ return PortalColorScheme.NO_PREFERENCE
89
+ values = reply.arguments()
90
+ if len(values) != READ_ONE_REPLY_ARGUMENT_COUNT:
91
+ return PortalColorScheme.NO_PREFERENCE
92
+ return portal_color_scheme(values[READ_ONE_REPLY_VALUE_INDEX])
93
+
94
+
95
+ class PortalColorSchemeWatcher(QObject):
96
+ """Report each change of the colour scheme the portal signals, as a ``PortalColorScheme``.
97
+
98
+ The portal signals every setting it publishes through the one ``SettingChanged`` signal; only the colour scheme of
99
+ the appearance namespace is reported. The change is reported inside the D-Bus delivery itself, with no timer, so a
100
+ receiver acts on it before Qt's own listeners of the same signal, which were subscribed earlier, rebuild anything.
101
+ ``connected`` says whether the subscription was made: when it is false, no change will ever be reported.
102
+ """
103
+
104
+ changed = Signal(object)
105
+
106
+ def __init__(self, connect: Callable[[], QDBusConnection], parent: QObject | None = None) -> None:
107
+ super().__init__(parent)
108
+ self.connection = connect()
109
+ self.connected = False
110
+ if not self.connection.isConnected():
111
+ return
112
+ # PySide6 6.11 drops the first character of a slot signature given as a bare text ("etting_changed(QDBusMessage)"),
113
+ # and the subscription fails; written through SLOT(), the signature reaches QtDBus whole.
114
+ self.connected = self.connection.connect(PORTAL_SERVICE_NAME, PORTAL_OBJECT_PATH, PORTAL_SETTINGS_INTERFACE, SETTING_CHANGED_SIGNAL, self,
115
+ SLOT("setting_changed(QDBusMessage)"))
116
+
117
+ @Slot(QDBusMessage)
118
+ def setting_changed(self, message: QDBusMessage) -> None:
119
+ """Report the new colour scheme when the signalled setting is the appearance namespace's colour scheme, and ignore any other."""
120
+ arguments = message.arguments()
121
+ if len(arguments) != SETTING_CHANGED_ARGUMENT_COUNT:
122
+ return
123
+ if arguments[SETTING_CHANGED_NAMESPACE_INDEX] != APPEARANCE_NAMESPACE or arguments[SETTING_CHANGED_KEY_INDEX] != COLOR_SCHEME_KEY:
124
+ return
125
+ self.changed.emit(portal_color_scheme(arguments[SETTING_CHANGED_VALUE_INDEX]))
@@ -0,0 +1,48 @@
1
+ """Give GitPane its own identity in the Windows taskbar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import logging
7
+ import sys
8
+ from typing import TYPE_CHECKING
9
+
10
+ from gitpane.config import APPLICATION_USER_MODEL_ID, WINDOWS_PLATFORM
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ # S_OK: the shell accepted the identifier; any other result is a refusal.
16
+ SUCCESS_RESULT = 0
17
+ # Shows a negative result as the unsigned hexadecimal code Microsoft documents, such as 0x80070057.
18
+ RESULT_CODE_MASK = 0xFFFFFFFF
19
+
20
+
21
+ def _shell_identity_setter() -> Callable[[str], int]:
22
+ """Load the Windows shell call that names the running process, typed so the identifier is passed as a wide string."""
23
+ shell = ctypes.WinDLL("shell32")
24
+ setter = shell.SetCurrentProcessExplicitAppUserModelID
25
+ setter.argtypes = [ctypes.c_wchar_p]
26
+ setter.restype = ctypes.c_int32
27
+ return setter
28
+
29
+
30
+ def declare_application_identity(set_identity: Callable[[str], int] | None = None, platform: str = sys.platform) -> bool:
31
+ """Tell Windows that this process is GitPane, and return whether the shell accepted it.
32
+
33
+ Without it the taskbar groups GitPane under the Python interpreter and shows the Python icon. Windows only takes the
34
+ identifier into account when it is set before the process shows any window, so this runs before the Qt application
35
+ exists. A refusal only costs the taskbar icon: it is logged and start-up goes on. Other systems have no such call.
36
+ """
37
+ if platform != WINDOWS_PLATFORM:
38
+ return False
39
+ try:
40
+ setter = set_identity if set_identity is not None else _shell_identity_setter()
41
+ result = setter(APPLICATION_USER_MODEL_ID)
42
+ except (AttributeError, OSError) as error:
43
+ logging.getLogger(__name__).warning("Cannot declare the Windows application identity; the taskbar may show the Python icon: %s", error)
44
+ return False
45
+ if result != SUCCESS_RESULT:
46
+ logging.getLogger(__name__).warning("Windows refused the application identity (result %#x); the taskbar may show the Python icon", result & RESULT_CODE_MASK)
47
+ return False
48
+ return True
@@ -0,0 +1,36 @@
1
+ """Describe the command-line options of the gitpane command.
2
+
3
+ The parser answers --version and --help, and refuses an unknown option, before any Qt object exists, so these answers
4
+ reach the terminal even when GitPane would later leave it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ from enum import Enum
11
+
12
+ from gitpane.config import APPLICATION_VERSION_LINE
13
+
14
+ ATTACH_OPTION = "--attach"
15
+ LOG_OPTION = "--log"
16
+ VERSION_OPTION = "--version"
17
+
18
+
19
+ class CommandLineText(Enum):
20
+ """Texts the gitpane command shows in its help, one member per text so a misspelt name is an error."""
21
+
22
+ DESCRIPTION = "GitPane — browse a Git repository"
23
+ """The line under the usage of --help."""
24
+ LOG_HELP = "Write a diagnostic log with one line per Git process"
25
+ """The help of the --log option."""
26
+ ATTACH_HELP = "Stay attached to the terminal: GitPane's output and errors are shown there, and Ctrl+C stops GitPane"
27
+ """The help of the --attach option."""
28
+
29
+
30
+ def command_line_parser() -> argparse.ArgumentParser:
31
+ """Build the parser of the gitpane command, which answers --version with the application's name and version and exits."""
32
+ parser = argparse.ArgumentParser(description=CommandLineText.DESCRIPTION.value)
33
+ parser.add_argument(LOG_OPTION, action="store_true", help=CommandLineText.LOG_HELP.value)
34
+ parser.add_argument(ATTACH_OPTION, action="store_true", help=CommandLineText.ATTACH_HELP.value)
35
+ parser.add_argument(VERSION_OPTION, action="version", version=APPLICATION_VERSION_LINE)
36
+ return parser
@@ -0,0 +1,128 @@
1
+ """Read from the running session the facts the desktop icon decision needs, off the window's thread.
2
+
3
+ The state of a GNOME Shell extension is read through ``gnome-extensions list``, which names the extensions the Shell
4
+ knows, and ``gnome-extensions list --active``, which names those it really runs, each bounded by a delay of
5
+ ``config.py``; an extension the Shell does not know yet but whose ``metadata.json`` lies in an XDG data folder was
6
+ installed during the session. A read with no answer in time, a missing tool or a refusal leave the state unknown,
7
+ which gives no note rather than a wrong one. The program, the files and os-release are all received as parameters,
8
+ so a test never reaches the real session.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from platform import freedesktop_os_release
17
+ from typing import TYPE_CHECKING
18
+
19
+ from PySide6.QtCore import QStandardPaths
20
+
21
+ from gitpane.config import LINUX_PLATFORM, MILLISECONDS_PER_SECOND, SHELL_EXTENSION_READ_TIMEOUT_MS
22
+ from gitpane.system.desktop_icon_support import GNOME_EXTENSIONS_PROGRAM, DesktopIconFacts, ExtensionState, desktop_icon_rule
23
+ from gitpane.system.desktop_shortcut import named_desktop_dir
24
+ from gitpane.system.linux_distribution import read_os_release_or_nothing
25
+ from gitpane.system.qt_folder import native_folder_path
26
+ from gitpane.system.xdg_session import desktop_names, xdg_data_dirs, xdg_data_home
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable, Mapping, Sequence
30
+
31
+ ExtensionRun = Callable[..., subprocess.CompletedProcess[bytes]]
32
+
33
+ GNOME_EXTENSIONS_LIST_ARGUMENT = "list"
34
+ GNOME_EXTENSIONS_ACTIVE_OPTION = "--active"
35
+ SHELL_EXTENSION_DIR_PARTS = ("gnome-shell", "extensions")
36
+ EXTENSION_METADATA_BASENAME = "metadata.json"
37
+ GNOME_EXTENSIONS_OUTPUT_ENCODING = "utf-8"
38
+ MOST_SPECIFIC_DESKTOP_NAME_INDEX = 0
39
+
40
+
41
+ def shell_extension_read_timeout_seconds() -> float:
42
+ """Return the bound of one gnome-extensions read, in the seconds ``subprocess`` counts in."""
43
+ return SHELL_EXTENSION_READ_TIMEOUT_MS / MILLISECONDS_PER_SECOND
44
+
45
+
46
+ def listed_extension_uuid_set(run: ExtensionRun, options: Sequence[str], timeout_seconds: float) -> frozenset[str] | None:
47
+ """Return the extension identifiers ``gnome-extensions list`` names with ``options``, one per line, or None when it cannot answer in time.
48
+
49
+ The tool runs without a shell and reads no input; a missing tool, a delay passed or a non-zero exit code all give
50
+ None. Its output is only compared with identifiers of GitPane's own table, never shown or run.
51
+ """
52
+ try:
53
+ completed = run([GNOME_EXTENSIONS_PROGRAM, GNOME_EXTENSIONS_LIST_ARGUMENT, *options], shell=False, stdin=subprocess.DEVNULL, capture_output=True, check=False,
54
+ timeout=timeout_seconds)
55
+ except (OSError, subprocess.SubprocessError):
56
+ return None
57
+ if completed.returncode != 0:
58
+ return None
59
+ uuid_set = set()
60
+ for line in completed.stdout.decode(GNOME_EXTENSIONS_OUTPUT_ENCODING, errors="replace").splitlines():
61
+ uuid = line.strip()
62
+ if uuid:
63
+ uuid_set.add(uuid)
64
+ return frozenset(uuid_set)
65
+
66
+
67
+ def extension_on_disk(extension_uuid: str, environment_by_name: Mapping[str, str], home_dir: str, is_file: Callable[[str], bool]) -> bool:
68
+ """Tell whether the extension's ``metadata.json`` lies in the user's XDG data folder or in one of the system's."""
69
+ for data_dir in (xdg_data_home(environment_by_name, home_dir), *xdg_data_dirs(environment_by_name)):
70
+ if is_file(os.path.join(data_dir, *SHELL_EXTENSION_DIR_PARTS, extension_uuid, EXTENSION_METADATA_BASENAME)):
71
+ return True
72
+ return False
73
+
74
+
75
+ def read_extension_state(extension_uuid: str, environment_by_name: Mapping[str, str], home_dir: str, run: ExtensionRun = subprocess.run,
76
+ is_file: Callable[[str], bool] = os.path.isfile) -> ExtensionState:
77
+ """Read what the running GNOME Shell knows of an extension: active, known but not running, on the disk only, or missing.
78
+
79
+ Runs gnome-extensions twice, so it belongs on a worker thread only. Either read without an answer gives UNKNOWN.
80
+ """
81
+ timeout_seconds = shell_extension_read_timeout_seconds()
82
+ known_uuid_set = listed_extension_uuid_set(run, (), timeout_seconds)
83
+ if known_uuid_set is None:
84
+ return ExtensionState.UNKNOWN
85
+ active_uuid_set = listed_extension_uuid_set(run, (GNOME_EXTENSIONS_ACTIVE_OPTION,), timeout_seconds)
86
+ if active_uuid_set is None:
87
+ return ExtensionState.UNKNOWN
88
+ if extension_uuid in active_uuid_set:
89
+ return ExtensionState.ACTIVE
90
+ if extension_uuid in known_uuid_set:
91
+ return ExtensionState.INACTIVE
92
+ if extension_on_disk(extension_uuid, environment_by_name, home_dir, is_file):
93
+ return ExtensionState.INSTALLED_NOT_LOADED
94
+ return ExtensionState.NOT_INSTALLED
95
+
96
+
97
+ def read_desktop_icon_facts(platform: str, environment_by_name: Mapping[str, str], home_dir: str, desktop_dir: str, run: ExtensionRun = subprocess.run,
98
+ is_file: Callable[[str], bool] = os.path.isfile, is_dir: Callable[[str], bool] = os.path.isdir,
99
+ read_os_release: Callable[[], Mapping[str, str]] = freedesktop_os_release) -> DesktopIconFacts:
100
+ """Read the facts the desktop icon decision judges for ``platform``, the session ``environment_by_name`` and ``desktop_dir``.
101
+
102
+ The desktop and os-release are only read on Linux, and the extension state only for a desktop that has a row in
103
+ the desktop table: any other desktop or system starts no program and keeps the state unknown. A desktop folder
104
+ that is ``home_dir`` itself reads as no desktop folder, by the same rule the shortcut writer follows, so the note
105
+ says the system names none.
106
+ """
107
+ desktop_dir = named_desktop_dir(desktop_dir, home_dir)
108
+ desktop_name = ""
109
+ os_release_by_key: dict[str, str] = {}
110
+ extension_state = ExtensionState.UNKNOWN
111
+ if platform == LINUX_PLATFORM:
112
+ names = desktop_names(environment_by_name)
113
+ desktop_name = names[MOST_SPECIFIC_DESKTOP_NAME_INDEX] if names else ""
114
+ os_release_by_key = read_os_release_or_nothing(read_os_release)
115
+ rule = desktop_icon_rule(platform, desktop_name)
116
+ if rule is not None:
117
+ extension_state = read_extension_state(rule.extension_uuid, environment_by_name, home_dir, run, is_file)
118
+ return DesktopIconFacts(platform, desktop_name, os_release_by_key, extension_state, desktop_dir, bool(desktop_dir) and is_dir(desktop_dir))
119
+
120
+
121
+ def read_session_desktop_icon_facts() -> DesktopIconFacts:
122
+ """Read the facts of the running session, with the desktop folder Qt names for the system, written with the system's own separator.
123
+
124
+ Starts gnome-extensions on GNOME, so it runs only on a worker thread; Qt only reads the system's folder settings
125
+ there.
126
+ """
127
+ desktop_dir = native_folder_path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), sys.platform)
128
+ return read_desktop_icon_facts(sys.platform, dict(os.environ), os.path.expanduser("~"), desktop_dir)