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,390 @@
1
+ """Detect the terminals this machine offers, from known recipes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import sys
9
+ import urllib.parse
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCore import QObject, Signal
15
+
16
+ from gitpane.config import LINUX_PLATFORM, MACOS_PLATFORM, SYSTEM_ROOT_ENVIRONMENT_NAME, WINDOWS_PLATFORM
17
+ from gitpane.tool.app_bundle import bundled_program, find_application, read_bundle
18
+ from gitpane.tool.git_executable import GIT_BINARY_DIR_BASENAME, GIT_SHELL_DIR_BASENAME, git_root_dirs
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Mapping
22
+
23
+ from gitpane.preference.preference_store import PreferenceStore
24
+ from gitpane.tool.app_bundle import AppBundle
25
+
26
+
27
+ class TerminalLaunch(Enum):
28
+ """How a terminal recipe is started once its program or application has been found."""
29
+
30
+ # The recipe's own executable, found on the PATH or in known folders, started detached.
31
+ PROGRAM = "program"
32
+ # A program inside the macOS application found (a known tool, or the declared program), started detached.
33
+ BUNDLE_PROGRAM = "bundle-program"
34
+ # macOS's open command with the recipe arguments: it hands the request to LaunchServices and answers at once.
35
+ OPEN = "open"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TerminalRecipe:
40
+ """Describe only documented arguments, preserving registry order for initial choice.
41
+
42
+ On macOS ``executable`` is the application folder name searched in the application folders, and ``bundle_program``
43
+ the path of the program to start inside it (empty: the program the application declares). Arguments may hold the
44
+ markers ``{directory}``, ``{directory_url}`` (the folder encoded for a URL) and ``{bundle}`` (the application folder).
45
+ """
46
+
47
+ id: str
48
+ label: str
49
+ platform: str
50
+ executable: str
51
+ arguments: tuple[str, ...]
52
+ new_console: bool = False
53
+ launch: TerminalLaunch = TerminalLaunch.PROGRAM
54
+ bundle_program: tuple[str, ...] = ()
55
+
56
+
57
+ TERMINAL_RECIPES = (
58
+ # Terminal.app ships with macOS, so it always comes first and is the default choice whatever else is installed.
59
+ # open -a gives it the folder, in which it opens a new window; no AppleScript, so no permission prompt.
60
+ TerminalRecipe("terminal-macos", "Terminal", MACOS_PLATFORM, "Terminal.app", ("-a", "{bundle}", "{directory}"), launch=TerminalLaunch.OPEN),
61
+ # Third-party macOS terminals, offered only when installed, in this fixed order. Their ids are saved in the
62
+ # preferences and must never be renamed.
63
+ # iTerm2 opens a window in the folder it is given through open -a, like Terminal.
64
+ TerminalRecipe("iterm2", "iTerm2", MACOS_PLATFORM, "iTerm.app", ("-a", "{bundle}", "{directory}"), launch=TerminalLaunch.OPEN),
65
+ # Ghostty documents --working-directory. Started by open it defaults to the home folder, and an already running
66
+ # Ghostty would apply its own inherited folder, so -n starts a new instance that reads the option.
67
+ TerminalRecipe("ghostty", "Ghostty", MACOS_PLATFORM, "Ghostty.app", ("-na", "{bundle}", "--args", "--working-directory={directory}"), launch=TerminalLaunch.OPEN),
68
+ # kitty's documentation runs the program inside its application for command line options, which open would not
69
+ # pass; --directory changes to the folder at launch.
70
+ TerminalRecipe("kitty", "kitty", MACOS_PLATFORM, "kitty.app", ("--directory", "{directory}"), launch=TerminalLaunch.BUNDLE_PROGRAM,
71
+ bundle_program=("Contents", "MacOS", "kitty")),
72
+ # Alacritty's declared program accepts --working-directory, documented as the folder the shell starts in.
73
+ TerminalRecipe("alacritty", "Alacritty", MACOS_PLATFORM, "Alacritty.app", ("--working-directory", "{directory}"), launch=TerminalLaunch.BUNDLE_PROGRAM),
74
+ # The declared program of WezTerm is its interface; its command line tool, wezterm, documents start --cwd.
75
+ TerminalRecipe("wezterm", "WezTerm", MACOS_PLATFORM, "WezTerm.app", ("start", "--cwd", "{directory}"), launch=TerminalLaunch.BUNDLE_PROGRAM,
76
+ bundle_program=("Contents", "MacOS", "wezterm")),
77
+ # Warp documents a link that opens a window in a folder; the folder is encoded so that &, # or ? in its name
78
+ # never become parameters of the link.
79
+ TerminalRecipe("warp", "Warp", MACOS_PLATFORM, "Warp.app", ("warp://action/new_window?path={directory_url}",), launch=TerminalLaunch.OPEN),
80
+ TerminalRecipe("wt", "Windows Terminal", WINDOWS_PLATFORM, "wt.exe", ("new-tab", "-d", "{directory}")),
81
+ TerminalRecipe("pwsh", "PowerShell", WINDOWS_PLATFORM, "pwsh.exe", ("-NoExit",), True),
82
+ TerminalRecipe("powershell", "Windows PowerShell", WINDOWS_PLATFORM, "powershell.exe", ("-NoExit",), True),
83
+ TerminalRecipe("cmd", "Command Prompt", WINDOWS_PLATFORM, "cmd.exe", ("/D", "/K"), True),
84
+ TerminalRecipe("ptyxis", "Ptyxis", LINUX_PLATFORM, "ptyxis", ("--new-window", "--working-directory", "{directory}")),
85
+ TerminalRecipe("gnome-terminal", "GNOME Terminal", LINUX_PLATFORM, "gnome-terminal", ("--working-directory", "{directory}")),
86
+ TerminalRecipe("konsole", "Konsole", LINUX_PLATFORM, "konsole", ("--workdir", "{directory}")),
87
+ TerminalRecipe("xfce4-terminal", "Xfce Terminal", LINUX_PLATFORM, "xfce4-terminal", ("--working-directory={directory}",)),
88
+ TerminalRecipe("xterm", "XTerm", LINUX_PLATFORM, "xterm", ()),
89
+ )
90
+ # Stable id saved in the preferences for the Git Bash entry, whichever of its three executables was found.
91
+ GIT_BASH_TERMINAL_ID = "git-bash"
92
+ GIT_BASH_LABEL = "Git Bash"
93
+ # Git for Windows ships a windowed launcher; bash and sh are the console programs Git Extensions falls back to.
94
+ GIT_BASH_EXECUTABLE_BASENAME = "git-bash.exe"
95
+ BASH_EXECUTABLE_BASENAME = "bash.exe"
96
+ SH_EXECUTABLE_BASENAME = "sh.exe"
97
+ # Folder name of a Git for Windows installation under Program Files.
98
+ GIT_INSTALLATION_DIR_BASENAME = "Git"
99
+ NATIVE_PROGRAM_FILES_ENVIRONMENT_NAME = "ProgramW6432"
100
+ X86_PROGRAM_FILES_ENVIRONMENT_NAME = "ProgramFiles(x86)"
101
+ # The launcher is a windowed program and opens its own window; bash.exe and sh.exe need a new console. Started bare,
102
+ # usr\bin\bash.exe skips the Git for Windows profile and has neither ls nor git on its PATH; a login interactive
103
+ # shell reads that profile, as git-bash.exe does, and keeps the folder it was started in.
104
+ GIT_BASH_SHELL_ARGUMENTS = ("--login", "-i")
105
+ GIT_BASH_RECIPES = (
106
+ TerminalRecipe(GIT_BASH_TERMINAL_ID, GIT_BASH_LABEL, WINDOWS_PLATFORM, GIT_BASH_EXECUTABLE_BASENAME, ()),
107
+ TerminalRecipe("git-bash-bash", GIT_BASH_LABEL, WINDOWS_PLATFORM, BASH_EXECUTABLE_BASENAME, GIT_BASH_SHELL_ARGUMENTS, True),
108
+ TerminalRecipe("git-bash-sh", GIT_BASH_LABEL, WINDOWS_PLATFORM, SH_EXECUTABLE_BASENAME, GIT_BASH_SHELL_ARGUMENTS, True),
109
+ )
110
+ RECIPE_BY_ID: dict[str, TerminalRecipe] = {}
111
+ # Git Bash recipes are launchable by id but stay out of TERMINAL_RECIPES: they have their own search,
112
+ # and the generic loop only knows the installation folders of the other Windows terminals.
113
+ for recipe in (*TERMINAL_RECIPES, *GIT_BASH_RECIPES):
114
+ RECIPE_BY_ID[recipe.id] = recipe
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class TerminalDescriptor:
119
+ """Keep detected and formerly selected terminals identifiable without substituting tools."""
120
+
121
+ id: str
122
+ label: str
123
+ program_filename: str
124
+ recipe_id: str
125
+ available: bool = True
126
+
127
+
128
+ class TerminalRegistry(QObject):
129
+ """Publish one persisted terminal choice to every repository window."""
130
+
131
+ changed = Signal()
132
+
133
+ def __init__(self, preference: PreferenceStore, detector: Callable[[], list[TerminalDescriptor]] | None = None, parent: QObject | None = None) -> None:
134
+ """Inject detection and retain useful names when a selected executable disappears."""
135
+ super().__init__(parent)
136
+ self.preference = preference
137
+ self.detector = detector if detector is not None else detect_terminal
138
+ self.detected_terminals: list[TerminalDescriptor] = []
139
+ self.terminals: list[TerminalDescriptor] = []
140
+ self.selected_id = ""
141
+ self.warning = ""
142
+ preference.changed.connect(self._preference_changed)
143
+ self.redetect()
144
+
145
+ def redetect(self) -> None:
146
+ """Reinventory known recipes without silently replacing a saved missing terminal."""
147
+ self.detected_terminals = self.detector()
148
+ self.preference.refresh()
149
+ self.warning = ""
150
+ if not self.preference.value_by_key["terminal_id"] and self.detected_terminals:
151
+ first_terminal_index = 0
152
+ try:
153
+ self.select(self.detected_terminals[first_terminal_index].id)
154
+ except OSError as error:
155
+ self.warning = str(error)
156
+ self._rebuild()
157
+
158
+ def select(self, terminal_id: str) -> None:
159
+ """Persist the complete selection atomically before announcing it to other windows."""
160
+ for terminal in self.detected_terminals:
161
+ if terminal.id == terminal_id and terminal.available:
162
+ self.preference.update({"terminal_id": terminal.id, "terminal_label": terminal.label, "terminal_filename": terminal.program_filename})
163
+ self.warning = ""
164
+ self._rebuild()
165
+ return
166
+ raise OSError("This terminal is unavailable. Detect terminals again.")
167
+
168
+ def selected(self) -> TerminalDescriptor | None:
169
+ """Return exactly the shared choice, including its unavailable representation."""
170
+ for terminal in self.terminals:
171
+ if terminal.id == self.selected_id:
172
+ return terminal
173
+ return None
174
+
175
+ def _preference_changed(self, changed_value_by_key: dict[str, object]) -> None:
176
+ if any(key.startswith("terminal_") for key in changed_value_by_key):
177
+ self._rebuild()
178
+
179
+ def _rebuild(self) -> None:
180
+ """Combine the inventory with saved identity without constructing executable commands."""
181
+ self.terminals = list(self.detected_terminals)
182
+ self.selected_id = str(self.preference.value_by_key["terminal_id"])
183
+ if self.selected_id and not any(terminal.id == self.selected_id for terminal in self.terminals):
184
+ recipe = RECIPE_BY_ID.get(self.selected_id)
185
+ label = str(self.preference.value_by_key["terminal_label"]) or (recipe.label if recipe else self.selected_id)
186
+ program_filename = str(self.preference.value_by_key["terminal_filename"])
187
+ self.terminals.append(TerminalDescriptor(self.selected_id, label, program_filename, self.selected_id, False))
188
+ self.changed.emit()
189
+
190
+
191
+ def executable_available(program_filename: str) -> bool:
192
+ """Check a concrete executable without evaluating a shell command.
193
+
194
+ A relative name is accepted and read from the current folder; the SSH and credential searches use
195
+ ``absolute_executable_available`` of ``gitpane.account.ssh`` instead, which refuses one.
196
+ """
197
+ return bool(program_filename) and os.path.isfile(program_filename) and os.access(program_filename, os.X_OK)
198
+
199
+
200
+ def _windows_candidate_filenames(recipe: TerminalRecipe, environment_by_name: Mapping[str, str]) -> list[str]:
201
+ """Use known installation directories only when the corresponding environment exists."""
202
+ location_by_id = {
203
+ "wt": ("LOCALAPPDATA", "Microsoft", "WindowsApps"),
204
+ "pwsh": ("ProgramFiles", "PowerShell", "7"),
205
+ "powershell": (SYSTEM_ROOT_ENVIRONMENT_NAME, "System32", "WindowsPowerShell", "v1.0"),
206
+ "cmd": (SYSTEM_ROOT_ENVIRONMENT_NAME, "System32"),
207
+ }
208
+ environment_name, *directory_parts = location_by_id[recipe.id]
209
+ root_dir = environment_by_name.get(environment_name, "")
210
+ if not root_dir:
211
+ return []
212
+ return [os.path.join(root_dir, *directory_parts, recipe.executable)]
213
+
214
+
215
+ def git_bash_candidate_filenames(executable: str, git_filename: str, git_utility_dir: str, which: Callable[[str], str | None],
216
+ environment_by_name: Mapping[str, str]) -> list[str]:
217
+ """List where one Git Bash executable may be, in search order, starting beside the Git GitPane runs.
218
+
219
+ After the Git in use comes the order of Git Extensions: both Program Files, the Git utilities folder, the Linux
220
+ tool folders of the installation, then the PATH. The bin folder of the installation, whose bash.exe sets up the
221
+ Git for Windows environment itself, is looked at before the Linux tool folders. An environment variable that is
222
+ absent or empty adds nothing.
223
+ """
224
+ root_dirs = git_root_dirs(git_filename)
225
+ installation_dirs = []
226
+ for environment_name in (NATIVE_PROGRAM_FILES_ENVIRONMENT_NAME, X86_PROGRAM_FILES_ENVIRONMENT_NAME):
227
+ program_files_dir = environment_by_name.get(environment_name, "")
228
+ if program_files_dir:
229
+ installation_dirs.append(os.path.join(program_files_dir, GIT_INSTALLATION_DIR_BASENAME))
230
+ candidate_filenames = []
231
+ for root_dir in root_dirs:
232
+ candidate_filenames.append(os.path.join(root_dir, executable))
233
+ for installation_dir in installation_dirs:
234
+ candidate_filenames.append(os.path.join(installation_dir, executable))
235
+ if git_utility_dir:
236
+ candidate_filenames.append(os.path.join(git_utility_dir, executable))
237
+ native_program_files_dir = environment_by_name.get(NATIVE_PROGRAM_FILES_ENVIRONMENT_NAME, "")
238
+ for tool_dir_parts in ((GIT_BINARY_DIR_BASENAME,), (GIT_SHELL_DIR_BASENAME, GIT_BINARY_DIR_BASENAME)):
239
+ for root_dir in root_dirs:
240
+ candidate_filenames.append(os.path.join(root_dir, *tool_dir_parts, executable))
241
+ if native_program_files_dir:
242
+ candidate_filenames.append(os.path.join(native_program_files_dir, GIT_INSTALLATION_DIR_BASENAME, *tool_dir_parts, executable))
243
+ path_filename = which(executable)
244
+ if path_filename:
245
+ candidate_filenames.append(path_filename)
246
+ return candidate_filenames
247
+
248
+
249
+ def inside_system_root(filename: str, environment_by_name: Mapping[str, str]) -> bool:
250
+ """Tell whether a file lies under the Windows folder, where only system programs live."""
251
+ system_root_dir = environment_by_name.get(SYSTEM_ROOT_ENVIRONMENT_NAME, "")
252
+ if not system_root_dir:
253
+ return False
254
+ normalized_filename = os.path.normcase(os.path.abspath(filename))
255
+ normalized_system_root_dir = os.path.normcase(os.path.abspath(system_root_dir))
256
+ try:
257
+ return os.path.commonpath((normalized_filename, normalized_system_root_dir)) == normalized_system_root_dir
258
+ except ValueError:
259
+ return False
260
+
261
+
262
+ def _detect_git_bash(git_filename: str, git_utility_dir: str, which: Callable[[str], str | None], available: Callable[[str], bool],
263
+ environment_by_name: Mapping[str, str]) -> TerminalDescriptor | None:
264
+ """Find Git Bash the way Git Extensions does: git-bash.exe first, then bash.exe, then sh.exe.
265
+
266
+ All three forms share one saved id so the user's choice survives a fallback; only the launch recipe differs.
267
+ """
268
+ for recipe in GIT_BASH_RECIPES:
269
+ for candidate_filename in git_bash_candidate_filenames(recipe.executable, git_filename, git_utility_dir, which, environment_by_name):
270
+ # The WSL launcher in System32 is also named bash.exe; taking it would open another machine, not Git Bash.
271
+ if inside_system_root(candidate_filename, environment_by_name):
272
+ continue
273
+ if available(candidate_filename):
274
+ return TerminalDescriptor(GIT_BASH_TERMINAL_ID, GIT_BASH_LABEL, os.path.abspath(candidate_filename), recipe.id)
275
+ return None
276
+
277
+
278
+ def bundle_recipe_program(recipe: TerminalRecipe, bundle_dir: str) -> str:
279
+ """Return the program a bundle recipe starts inside ``bundle_dir``, or ``""`` when the application does not hold it.
280
+
281
+ It is the known tool the recipe names, or else the program the application declares in its Info.plist; either way
282
+ only an executable file inside the application folder comes out.
283
+ """
284
+ if recipe.bundle_program:
285
+ return bundled_program(bundle_dir, recipe.bundle_program)
286
+ bundle = read_bundle(bundle_dir)
287
+ if bundle is None:
288
+ return ""
289
+ return bundle.executable_filename
290
+
291
+
292
+ def _detect_macos_terminals(find_bundle: Callable[[tuple[str, ...]], AppBundle | None]) -> list[TerminalDescriptor]:
293
+ """List the installed macOS terminals in the order of the recipe table, each named by its application folder.
294
+
295
+ An application is offered once, from the first application folder that holds it; a recipe that starts a program
296
+ inside the application is only offered when that program is there.
297
+ """
298
+ terminals = []
299
+ bundle_identity_set: set[str] = set()
300
+ for recipe in TERMINAL_RECIPES:
301
+ if recipe.platform != MACOS_PLATFORM:
302
+ continue
303
+ bundle = find_bundle((recipe.executable,))
304
+ if bundle is None:
305
+ continue
306
+ if recipe.launch == TerminalLaunch.BUNDLE_PROGRAM and not bundle_recipe_program(recipe, bundle.bundle_dir):
307
+ continue
308
+ bundle_identity = os.path.normcase(bundle.bundle_dir)
309
+ if bundle_identity in bundle_identity_set:
310
+ continue
311
+ bundle_identity_set.add(bundle_identity)
312
+ terminals.append(TerminalDescriptor(recipe.id, recipe.label, bundle.bundle_dir, recipe.id))
313
+ return terminals
314
+
315
+
316
+ def detect_terminal(platform: str = sys.platform, which: Callable[[str], str | None] = shutil.which,
317
+ realpath: Callable[[str], str] = os.path.realpath, available: Callable[[str], bool] = executable_available,
318
+ environment_by_name: Mapping[str, str] | None = None, git_filename: str = "", git_utility_dir: str = "",
319
+ find_bundle: Callable[[tuple[str, ...]], AppBundle | None] = find_application) -> list[TerminalDescriptor]:
320
+ """Inventory recognized executables with a stable order and a recognized Linux alternative first.
321
+
322
+ On Windows, Git Bash comes first, searched from the Git GitPane runs (git_filename) and the Git utilities
323
+ directory before the usual installation folders and the PATH. On macOS terminals are applications, looked up by
324
+ their folder name in the application folders (find_bundle), with Terminal first.
325
+ """
326
+ if platform == MACOS_PLATFORM:
327
+ return _detect_macos_terminals(find_bundle)
328
+ environment_by_name = os.environ if environment_by_name is None else environment_by_name
329
+ terminals = []
330
+ program_identity_set: set[str] = set()
331
+ recipe_id_set: set[str] = set()
332
+ if platform == WINDOWS_PLATFORM:
333
+ git_bash = _detect_git_bash(git_filename, git_utility_dir, which, available, environment_by_name)
334
+ if git_bash is not None:
335
+ terminals.append(git_bash)
336
+ program_identity_set.add(os.path.normcase(git_bash.program_filename))
337
+ if platform == LINUX_PLATFORM:
338
+ alternative_filename = which("x-terminal-emulator")
339
+ if alternative_filename:
340
+ target_filename = realpath(alternative_filename)
341
+ for recipe in TERMINAL_RECIPES:
342
+ if recipe.platform == platform and os.path.basename(target_filename) == recipe.executable and available(target_filename):
343
+ terminals.append(TerminalDescriptor(recipe.id, recipe.label, target_filename, recipe.id))
344
+ program_identity_set.add(os.path.normcase(target_filename))
345
+ recipe_id_set.add(recipe.id)
346
+ break
347
+ for recipe in TERMINAL_RECIPES:
348
+ if recipe.platform != platform or recipe.id in recipe_id_set:
349
+ continue
350
+ program_filename = which(recipe.executable)
351
+ candidate_filenames = [program_filename] if program_filename else []
352
+ if platform == WINDOWS_PLATFORM:
353
+ candidate_filenames.extend(_windows_candidate_filenames(recipe, environment_by_name))
354
+ for candidate_filename in candidate_filenames:
355
+ if not available(candidate_filename):
356
+ continue
357
+ canonical_filename = os.path.abspath(candidate_filename) if platform == WINDOWS_PLATFORM else realpath(candidate_filename)
358
+ program_identity = os.path.normcase(canonical_filename)
359
+ if program_identity not in program_identity_set:
360
+ terminals.append(TerminalDescriptor(recipe.id, recipe.label, canonical_filename, recipe.id))
361
+ program_identity_set.add(program_identity)
362
+ break
363
+ return terminals
364
+
365
+
366
+ # Markers a recipe argument may hold, each replaced by one value in a single pass, so a folder name that happens to
367
+ # contain a marker's text is never expanded a second time.
368
+ TERMINAL_ARGUMENT_MARKER_PATTERN = re.compile(r"\{(directory|directory_url|bundle)\}")
369
+ # Characters a folder path keeps unencoded inside a URL: only the separator, so &, #, ? and spaces never become syntax.
370
+ URL_PATH_SAFE_CHARACTERS = "/"
371
+
372
+
373
+ def terminal_arguments(descriptor: TerminalDescriptor, repository_dir: str) -> list[str]:
374
+ """Expand the directory as a single argument, never as executable shell text.
375
+
376
+ The application folder (a macOS terminal's descriptor names it) and the folder encoded for a URL are expanded the
377
+ same way, one element per argument.
378
+ """
379
+ recipe = RECIPE_BY_ID.get(descriptor.recipe_id)
380
+ if recipe is None:
381
+ raise ValueError(f"Unknown terminal recipe: {descriptor.recipe_id}")
382
+ value_by_marker = {
383
+ "directory": repository_dir,
384
+ "directory_url": urllib.parse.quote(repository_dir, safe=URL_PATH_SAFE_CHARACTERS),
385
+ "bundle": descriptor.program_filename,
386
+ }
387
+ arguments = []
388
+ for argument in recipe.arguments:
389
+ arguments.append(TERMINAL_ARGUMENT_MARKER_PATTERN.sub(lambda match: value_by_marker[match.group(1)], argument))
390
+ return arguments
File without changes