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,816 @@
1
+ """Start independent tools (terminal, text editor, file manager) in explicit directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import queue
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Protocol
15
+
16
+ from PySide6.QtCore import QObject, QTimer, QUrl, Signal
17
+ from PySide6.QtGui import QDesktopServices
18
+
19
+ from gitpane.config import (
20
+ EDITOR_HIDDEN_ENVIRONMENT_PREFIXES,
21
+ FILE_EXPORT_ACTIVE_LIMIT,
22
+ FILE_EXPORT_BUFFER_CHUNKS,
23
+ GIT_READ_TIMEOUT_MS,
24
+ MACOS_OPEN_FILENAME,
25
+ MACOS_PLATFORM,
26
+ MILLISECONDS_PER_SECOND,
27
+ PROCESS_CHUNK_BYTES,
28
+ TOOL_RESULT_POLL_MS,
29
+ TOOL_START_ACTIVE_LIMIT,
30
+ TOOL_START_TIMEOUT_MS,
31
+ WINDOWS_PLATFORM,
32
+ )
33
+ from gitpane.system.path_identity import canonical_path
34
+ from gitpane.system.startup_activation import NO_STARTUP_ACTIVATION, StartupActivation, no_startup_activation
35
+ from gitpane.tool.editor_recipe import EditorRecipe, build_editor_arguments, file_open_options
36
+ from gitpane.tool.file_reveal import reveal_file, selection_helper_available
37
+ from gitpane.tool.terminal_recipe import (
38
+ RECIPE_BY_ID,
39
+ SH_EXECUTABLE_BASENAME,
40
+ TerminalLaunch,
41
+ bundle_recipe_program,
42
+ executable_available,
43
+ git_bash_candidate_filenames,
44
+ inside_system_root,
45
+ terminal_arguments,
46
+ )
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Callable, Mapping
50
+
51
+ from gitpane.history.repository_value import RepositoryContext
52
+ from gitpane.tool.terminal_recipe import TerminalDescriptor, TerminalRecipe
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class OperationResult:
57
+ """Report a completed file operation without including historical content."""
58
+
59
+ request_id: object
60
+ destination_filename: str
61
+ cause: str = ""
62
+
63
+
64
+ def _contained(filename: str, directory: str) -> bool:
65
+ try:
66
+ return os.path.commonpath((filename, directory)) == directory
67
+ except ValueError:
68
+ return False
69
+
70
+
71
+ def _protect_git_path(filename: str, context: RepositoryContext) -> str:
72
+ """Reject Git metadata even through a linked worktree or a directory symlink."""
73
+ resolved_filename = canonical_path(filename)
74
+ for directory in (context.git_dir, context.common_git_dir):
75
+ if _contained(resolved_filename, canonical_path(directory)):
76
+ raise ValueError("Git internal files cannot be used for this operation.")
77
+ for component in resolved_filename.replace("\\", "/").split("/"):
78
+ if component.casefold() == ".git":
79
+ raise ValueError("Git internal files cannot be used for this operation.")
80
+ return os.path.realpath(filename)
81
+
82
+
83
+ def worktree_filename(context: RepositoryContext, path: bytes) -> str:
84
+ """Build the absolute worktree filename even for a historical file that no longer exists."""
85
+ if context.is_bare or context.worktree_dir is None:
86
+ raise ValueError("This bare repository has no working tree folder.")
87
+ parts = path.split(b"/")
88
+ for part in parts:
89
+ if not part or part in (b".", b"..") or b"\0" in part or (os.name == "nt" and (b"\\" in part or b":" in part)):
90
+ raise ValueError("The selected Git path is not a safe local file path.")
91
+ return os.path.join(os.path.abspath(context.worktree_dir), *map(os.fsdecode, parts))
92
+
93
+
94
+ def file_location(context: RepositoryContext, path: bytes, *, allow_missing: bool = False) -> str:
95
+ """Resolve a file's folder, optionally finding an existing ancestor for a deleted working path."""
96
+ filename = worktree_filename(context, path)
97
+ worktree_dir = os.path.realpath(context.worktree_dir)
98
+ resolved_filename = _protect_git_path(filename, context)
99
+ if not _contained(os.path.normcase(resolved_filename), os.path.normcase(worktree_dir)):
100
+ raise ValueError("The selected file resolves outside this working tree.")
101
+ if not allow_missing and not os.path.exists(filename):
102
+ raise FileNotFoundError("This historical file is absent from the working tree. No file was restored.")
103
+ directory = os.path.dirname(resolved_filename)
104
+ while not os.path.isdir(directory):
105
+ if os.path.lexists(directory) or os.path.normcase(directory) == os.path.normcase(worktree_dir):
106
+ raise FileNotFoundError("The working tree folder is unavailable.")
107
+ directory = os.path.dirname(directory)
108
+ return directory
109
+
110
+
111
+ @dataclass
112
+ class FileExport:
113
+ """Share bounded chunks and cancellation with a disk worker that never touches Qt."""
114
+
115
+ request_id: object
116
+ context: RepositoryContext
117
+ destination_filename: str
118
+ overwrite_confirmed: bool
119
+ cancel_read: Callable[[], None]
120
+ chunks: queue.Queue[bytes | int] = field(default_factory=lambda: queue.Queue(FILE_EXPORT_BUFFER_CHUNKS))
121
+ cancelled: threading.Event = field(default_factory=threading.Event)
122
+ publish_lock: threading.Lock = field(default_factory=threading.Lock)
123
+ cause: str = ""
124
+ deadline: float = field(default_factory=lambda: time.monotonic() + GIT_READ_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
125
+
126
+
127
+ def _write_export(operation: FileExport, completion_queue: queue.SimpleQueue[OperationResult]) -> None:
128
+ """Publish complete bytes atomically and remove only this operation's temporary file."""
129
+ temporary_filename = ""
130
+ cause = ""
131
+ try:
132
+ destination_filename = os.path.abspath(operation.destination_filename)
133
+ resolved_filename = _protect_git_path(destination_filename, operation.context)
134
+ if os.path.islink(destination_filename):
135
+ raise ValueError("Choose a regular destination file instead of a symbolic link.")
136
+ original_stat = os.stat(destination_filename) if os.path.exists(destination_filename) else None
137
+ if original_stat is not None and not operation.overwrite_confirmed:
138
+ raise FileExistsError("Replacing this file requires explicit overwrite confirmation.")
139
+ if operation.cancelled.is_set():
140
+ raise OSError(operation.cause)
141
+ descriptor, temporary_filename = tempfile.mkstemp(prefix=".gitpane-export-", dir=os.path.dirname(resolved_filename))
142
+ with os.fdopen(descriptor, "wb") as stream:
143
+ written_size = 0
144
+ while True:
145
+ if operation.cancelled.is_set():
146
+ raise OSError(operation.cause)
147
+ if time.monotonic() >= operation.deadline:
148
+ raise TimeoutError("Saving the historical file timed out.")
149
+ try:
150
+ chunk = operation.chunks.get(timeout=TOOL_RESULT_POLL_MS / MILLISECONDS_PER_SECOND)
151
+ except queue.Empty:
152
+ continue
153
+ if isinstance(chunk, int):
154
+ if written_size != chunk:
155
+ raise OSError("The historical file stream ended with an incorrect byte count.")
156
+ break
157
+ written_size += stream.write(chunk)
158
+ stream.flush()
159
+ os.fsync(stream.fileno())
160
+ with operation.publish_lock:
161
+ if operation.cancelled.is_set():
162
+ raise OSError(operation.cause)
163
+ if _protect_git_path(destination_filename, operation.context) != resolved_filename or os.path.islink(destination_filename):
164
+ raise OSError("The destination changed while the file was being saved.")
165
+ current_stat = os.stat(destination_filename) if os.path.exists(destination_filename) else None
166
+ if current_stat != original_stat:
167
+ raise OSError("The destination changed while the file was being saved. Choose it again.")
168
+ if original_stat is None:
169
+ # Linking publishes a complete file without overwriting a destination created after confirmation.
170
+ os.link(temporary_filename, resolved_filename)
171
+ os.unlink(temporary_filename)
172
+ else:
173
+ os.replace(temporary_filename, resolved_filename)
174
+ temporary_filename = ""
175
+ except (OSError, ValueError) as error:
176
+ cause = str(error)
177
+ finally:
178
+ if temporary_filename:
179
+ try:
180
+ os.unlink(temporary_filename)
181
+ except OSError as error:
182
+ cause = f"{cause}; temporary file cleanup failed: {error}"
183
+ completion_queue.put(OperationResult(operation.request_id, operation.destination_filename, cause))
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class ToolResult:
188
+ """Report startup acceptance separately from the external application's lifetime."""
189
+
190
+ request_id: object
191
+ label: str
192
+ repository_dir: str
193
+ program_filename: str
194
+ arguments: tuple[str, ...]
195
+ cause: str = ""
196
+ timed_out: bool = False
197
+
198
+
199
+ def _run_until_answer(command_arguments: list[str], working_dir: str, run: Callable[..., subprocess.CompletedProcess[bytes]], **option_by_name: object) -> None:
200
+ """Run a command that answers as soon as it has handed its work over, and raise its refusal with what it wrote.
201
+
202
+ It is waited for, bounded by the tool startup timeout; the service calls this on its worker thread, never on the
203
+ window's.
204
+ """
205
+ completed = run(command_arguments, stdin=subprocess.DEVNULL, capture_output=True,
206
+ timeout=TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND, check=False, cwd=working_dir, **option_by_name)
207
+ if completed.returncode != 0:
208
+ message = completed.stderr.decode("utf-8", errors="replace").strip()
209
+ raise OSError(message or f"{os.path.basename(command_arguments[0])} exited with code {completed.returncode}")
210
+
211
+
212
+ def _run_open_command(arguments: list[str], working_dir: str, run: Callable[..., subprocess.CompletedProcess[bytes]]) -> None:
213
+ """Ask LaunchServices, through macOS's open command, to open something, and raise its refusal with what open wrote.
214
+
215
+ open answers as soon as the application has been asked, so waiting for it never waits for the application.
216
+ """
217
+ _run_until_answer([MACOS_OPEN_FILENAME, *arguments], working_dir, run)
218
+
219
+
220
+ def _start_detached_terminal(program_filename: str, arguments: list[str], recipe: TerminalRecipe, repository_dir: str, platform: str,
221
+ popen: Callable[..., subprocess.Popen], activation: StartupActivation) -> None:
222
+ """Start a terminal program that outlives GitPane, in the repository folder, without waiting for it.
223
+
224
+ Its environment is GitPane's without any inherited startup value, plus the one read when the user clicked: Qt
225
+ removes its own activation token from the C environment only, and never an inherited X11 startup identifier.
226
+ """
227
+ option_by_name: dict[str, object] = {"cwd": repository_dir, "shell": False, "close_fds": True, "env": activation.environment(os.environ)}
228
+ if platform == WINDOWS_PLATFORM:
229
+ # Only the shell requested by the user receives a visible console; no PowerShell helper is launched.
230
+ option_by_name["creationflags"] = subprocess.CREATE_NEW_CONSOLE if recipe.new_console else subprocess.DETACHED_PROCESS
231
+ else:
232
+ option_by_name.update(start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
233
+ process = popen([program_filename, *arguments], **option_by_name)
234
+ # Popen never waits for terminal exit. Its own nonblocking cleanup reaps exited children on later starts.
235
+ process.poll()
236
+
237
+
238
+ def launch_terminal(descriptor: TerminalDescriptor, repository_dir: str, platform: str = sys.platform,
239
+ popen: Callable[..., subprocess.Popen] = subprocess.Popen,
240
+ run: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run, *, activation: StartupActivation = NO_STARTUP_ACTIVATION) -> None:
241
+ """Start an independent terminal with captured cwd and no constructed shell command.
242
+
243
+ The recipe's launch form decides how: its own program, or a program inside a macOS application, started detached;
244
+ or macOS's open command (run), whose refusal is raised with its message. A terminal whose program or application
245
+ has gone is refused before anything starts, instead of selecting another one. A terminal started directly receives
246
+ the startup value read when the user clicked, so its window may come to the front; macOS's open is asked as before.
247
+ """
248
+ if not os.path.isdir(repository_dir):
249
+ raise FileNotFoundError(f"Folder unavailable: {repository_dir}")
250
+ arguments = terminal_arguments(descriptor, repository_dir)
251
+ recipe = RECIPE_BY_ID[descriptor.recipe_id]
252
+ program_filename = available_terminal_program(descriptor)
253
+ if not program_filename:
254
+ raise FileNotFoundError(f"Terminal unavailable : {descriptor.label} — {descriptor.program_filename}")
255
+ if recipe.launch == TerminalLaunch.OPEN:
256
+ _run_open_command(arguments, repository_dir, run)
257
+ return
258
+ _start_detached_terminal(program_filename, arguments, recipe, repository_dir, platform, popen, activation)
259
+
260
+
261
+ def available_terminal_program(descriptor: TerminalDescriptor) -> str:
262
+ """Return what a terminal starts, its program or the macOS application folder open is asked for, or an empty text when it has gone.
263
+
264
+ These are the cheap checks that refuse a terminal before anything starts, so the service also runs them on the
265
+ window's thread before it asks for the click's startup value.
266
+ """
267
+ if not descriptor.available:
268
+ return ""
269
+ recipe = RECIPE_BY_ID[descriptor.recipe_id]
270
+ if recipe.launch == TerminalLaunch.OPEN:
271
+ return descriptor.program_filename if os.path.isdir(descriptor.program_filename) else ""
272
+ program_filename = descriptor.program_filename
273
+ if recipe.launch == TerminalLaunch.BUNDLE_PROGRAM:
274
+ program_filename = bundle_recipe_program(recipe, descriptor.program_filename)
275
+ return program_filename if executable_available(program_filename) else ""
276
+
277
+
278
+ class TextEditorCommand(Protocol):
279
+ """Build the full start command of the editor that opens a working copy file.
280
+
281
+ Two real forms exist: an editor recipe from Settings or detection, started without a shell, and the editor line
282
+ Git itself would use, started the way Git starts it. Both answer the same question, so the launcher and the
283
+ unavailable reason never need to tell them apart.
284
+ """
285
+
286
+ def arguments(self, filename: str) -> list[str]:
287
+ """Return the program followed by its arguments, the file as a single argument; raise ValueError with a readable cause when the editor cannot start."""
288
+ ...
289
+
290
+ def answers_at_once(self) -> bool:
291
+ """Tell whether the command hands the file over and ends at once with the system's answer, so its refusal can be reported.
292
+
293
+ Any other command is the editor itself, or a command that may wait for it, and is started detached without waiting.
294
+ """
295
+ ...
296
+
297
+
298
+ @dataclass(frozen=True)
299
+ class RecipeTextEditor:
300
+ """Start an editor recipe from Settings, or a detected one, as a plain argument list without any shell."""
301
+
302
+ recipe: EditorRecipe
303
+
304
+ def arguments(self, filename: str) -> list[str]:
305
+ """Check the recipe still names a usable program, then insert the absolute filename as one literal argument."""
306
+ self.recipe.validate()
307
+ return [self.recipe.program_filename, *build_editor_arguments(self.recipe.argument_template, filename)]
308
+
309
+ def answers_at_once(self) -> bool:
310
+ """Answer no: a recipe names the editor, which may wait until the file is closed."""
311
+ return False
312
+
313
+
314
+ # The open option that picks the default text editor of macOS, the one LaunchServices chooses (TextEdit unless the user changed it).
315
+ MACOS_DEFAULT_TEXT_EDITOR_OPEN_OPTIONS = ("-t",)
316
+
317
+
318
+ @dataclass(frozen=True)
319
+ class MacOpenTextEditor:
320
+ """Open a file through macOS's open command, in the default text editor or in the application its options name.
321
+
322
+ open hands the file to that editor and returns at once, so nothing waits for the editor; no permission is asked.
323
+ """
324
+
325
+ open_options: tuple[str, ...] = MACOS_DEFAULT_TEXT_EDITOR_OPEN_OPTIONS
326
+
327
+ def arguments(self, filename: str) -> list[str]:
328
+ """Return open and its options, followed by the file name as one argument, exactly as given."""
329
+ return [MACOS_OPEN_FILENAME, *self.open_options, filename]
330
+
331
+ def answers_at_once(self) -> bool:
332
+ """Answer yes: open ends as soon as LaunchServices has handed the file over, or refused it."""
333
+ return True
334
+
335
+
336
+ # The editor a platform opens when neither Settings nor detection gives one; a platform missing here greys the entry.
337
+ DEFAULT_TEXT_EDITOR_BY_PLATFORM: dict[str, TextEditorCommand] = {MACOS_PLATFORM: MacOpenTextEditor()}
338
+ # The shell through which Git itself starts an editor line on POSIX systems.
339
+ POSIX_SHELL_FILENAME = "/bin/sh"
340
+ GIT_EDITOR_ENVIRONMENT_NAME = "GIT_EDITOR"
341
+ VISUAL_ENVIRONMENT_NAME = "VISUAL"
342
+ EDITOR_ENVIRONMENT_NAME = "EDITOR"
343
+ TERM_ENVIRONMENT_NAME = "TERM"
344
+ DUMB_TERMINAL_NAME = "dumb"
345
+ # The editor value Git reads as "no editor at all".
346
+ GIT_NO_EDITOR_VALUE = ":"
347
+ FILE_NOT_IN_WORKTREE_REASON = "The file does not exist in the working tree."
348
+ NOT_A_FILE_REASON = "A folder cannot be opened in a text editor."
349
+ NO_TEXT_EDITOR_REASON = "No text editor set in Settings."
350
+ GIT_SHELL_NOT_FOUND_REASON = "The shell of Git was not found, so the editor set for Git cannot be started."
351
+ GIT_EDITOR_NULL_CHARACTER_REASON = "The editor set for Git or the file name contains a null character."
352
+ TEXT_EDITOR_LABEL = "text editor"
353
+
354
+
355
+ @dataclass(frozen=True)
356
+ class GitTextEditor:
357
+ """Start the editor line set for Git (GIT_EDITOR, core.editor, VISUAL or EDITOR) the way Git starts it.
358
+
359
+ That value is a shell line the user wrote for Git, which Git runs through a shell, so it may hold options, quotes
360
+ or a variable. It is started the same way here: the line goes into the shell script, the file name only arrives
361
+ as a positional parameter, so a file name coming from a commit is never read as shell code. A Settings recipe is
362
+ still started without any shell.
363
+ """
364
+
365
+ command_line: str
366
+ shell_filename: str
367
+
368
+ def arguments(self, filename: str) -> list[str]:
369
+ """Return the shell, the script `<line> "$@"`, the line as the script name, then the absolute file name."""
370
+ if not self.shell_filename:
371
+ raise ValueError(GIT_SHELL_NOT_FOUND_REASON)
372
+ if "\0" in self.command_line or "\0" in filename:
373
+ raise ValueError(GIT_EDITOR_NULL_CHARACTER_REASON)
374
+ return [self.shell_filename, "-c", self.command_line + ' "$@"', self.command_line, os.path.abspath(filename)]
375
+
376
+ def answers_at_once(self) -> bool:
377
+ """Answer no: the line set for Git starts the editor itself, which may wait until the file is closed."""
378
+ return False
379
+
380
+
381
+ def git_editor_line(environment_by_name: Mapping[str, str], core_editor: str) -> str:
382
+ """Return the editor line Git would start, in Git's order, without Git's compiled-in default.
383
+
384
+ That default is not a choice of the user and usually names a terminal editor, which shows nothing without a
385
+ terminal, so nothing set gives an empty line. VISUAL only counts with a terminal that is not dumb, as in Git. The
386
+ first value that is not blank decides: the colon Git reads as "no editor" ends the search with no editor, as in
387
+ Git; an empty or blank value is skipped, since Git would fail to start it. No process is started: asking `git var`
388
+ would answer the compiled-in default too.
389
+ """
390
+ terminal_name = environment_by_name.get(TERM_ENVIRONMENT_NAME, "")
391
+ candidates = [environment_by_name.get(GIT_EDITOR_ENVIRONMENT_NAME, ""), core_editor]
392
+ if terminal_name and terminal_name != DUMB_TERMINAL_NAME:
393
+ candidates.append(environment_by_name.get(VISUAL_ENVIRONMENT_NAME, ""))
394
+ candidates.append(environment_by_name.get(EDITOR_ENVIRONMENT_NAME, ""))
395
+ for candidate in candidates:
396
+ line = candidate.strip()
397
+ if not line:
398
+ continue
399
+ if line == GIT_NO_EDITOR_VALUE:
400
+ return ""
401
+ return line
402
+ return ""
403
+
404
+
405
+ def uses_git_editor(platform: str = sys.platform) -> bool:
406
+ """Tell whether Show in text editor may start the editor set for Git on this platform.
407
+
408
+ On Windows that editor is a graphical program chosen in the Git for Windows installer, so it opens a window. Elsewhere
409
+ it is often a terminal editor (vi, nano) which, started without a terminal, shows nothing and reports no error.
410
+ """
411
+ return platform == WINDOWS_PLATFORM
412
+
413
+
414
+ def git_shell_filename(platform: str = sys.platform, git_filename: str = "", git_utility_dir: str = "", which: Callable[[str], str | None] = shutil.which,
415
+ available: Callable[[str], bool] = executable_available, environment_by_name: Mapping[str, str] | None = None) -> str:
416
+ """Name the shell that starts the editor line set for Git: /bin/sh on POSIX, the sh.exe of Git for Windows otherwise.
417
+
418
+ On Windows the sh.exe is searched like Git Bash, starting from the Git GitPane runs; nothing found gives an empty
419
+ name, which greys the entry with its reason. Only files are looked at.
420
+ """
421
+ if platform != WINDOWS_PLATFORM:
422
+ return POSIX_SHELL_FILENAME
423
+ environment_by_name = os.environ if environment_by_name is None else environment_by_name
424
+ for candidate_filename in git_bash_candidate_filenames(SH_EXECUTABLE_BASENAME, git_filename, git_utility_dir, which, environment_by_name):
425
+ if inside_system_root(candidate_filename, environment_by_name):
426
+ continue
427
+ if available(candidate_filename):
428
+ return os.path.abspath(candidate_filename)
429
+ return ""
430
+
431
+
432
+ def text_editor_command(editor_recipe: EditorRecipe, git_editor: str, shell_filename: Callable[[], str], *, saved: bool, platform: str = sys.platform) -> TextEditorCommand | None:
433
+ """Choose the editor that opens a working copy file, with one rule per platform.
434
+
435
+ The recipe is the one Settings shows: the saved choice (``saved``), even when its program has gone, so its own reason
436
+ is shown instead of silently opening another editor; otherwise the detected editor, which is not saved.
437
+
438
+ On Windows the saved editor comes first, then the editor line set for Git, started by the sh.exe of Git for Windows,
439
+ then the detected editor (Notepad): the editor the user chose for Git in its installer keeps opening. Without that
440
+ sh.exe the detected editor opens; with nothing detected either, the Git line is kept so the entry is greyed with the
441
+ reason that the shell is missing. Searching the shell only looks at files.
442
+
443
+ Elsewhere an editor set for Git is often a terminal editor (vi, nano) which, started without a terminal, shows nothing
444
+ and reports no error. So only the saved or detected editor opens there; the Git line and the shell are never looked
445
+ at. With neither, macOS opens its default text editor (open -t), so the entry is never greyed for lack of an editor,
446
+ while Linux greys the entry with its reason. A macOS preset whose command only waits for the rebase, such as
447
+ TextEdit's separate waiting copy or the new waiting window of VS Code, Sublime Text and Zed, opens the file with
448
+ its plain open options instead, so it goes to the editor's window already open.
449
+ """
450
+ if not uses_git_editor(platform):
451
+ if editor_recipe.program_filename:
452
+ open_options = file_open_options(editor_recipe)
453
+ if open_options:
454
+ return MacOpenTextEditor(open_options)
455
+ return RecipeTextEditor(editor_recipe)
456
+ return DEFAULT_TEXT_EDITOR_BY_PLATFORM.get(platform)
457
+ if saved and editor_recipe.program_filename:
458
+ return RecipeTextEditor(editor_recipe)
459
+ if git_editor:
460
+ git_shell = shell_filename()
461
+ if git_shell:
462
+ return GitTextEditor(git_editor, git_shell)
463
+ if editor_recipe.program_filename:
464
+ return RecipeTextEditor(editor_recipe)
465
+ return GitTextEditor(git_editor, "")
466
+ if editor_recipe.program_filename:
467
+ return RecipeTextEditor(editor_recipe)
468
+ return None
469
+
470
+
471
+ def text_editor_unavailable_reason(context: RepositoryContext, path: bytes, command: TextEditorCommand | None) -> str:
472
+ """Tell why the working copy of a Git path cannot be opened in a text editor, or answer an empty reason.
473
+
474
+ The reason greys the menu entry, so it only looks at the disk and the chosen command and never starts a process.
475
+ """
476
+ try:
477
+ file_location(context, path, allow_missing=True)
478
+ filename = worktree_filename(context, path)
479
+ except (OSError, ValueError) as error:
480
+ return str(error)
481
+ if os.path.isdir(filename):
482
+ return NOT_A_FILE_REASON
483
+ if not os.path.isfile(filename):
484
+ return FILE_NOT_IN_WORKTREE_REASON
485
+ if command is None:
486
+ return NO_TEXT_EDITOR_REASON
487
+ try:
488
+ command.arguments(filename)
489
+ except ValueError as error:
490
+ return str(error)
491
+ return ""
492
+
493
+
494
+ def editor_environment() -> dict[str, str]:
495
+ """Copy the environment without GitPane's own and trace variables, which an editor started by GitPane must not inherit."""
496
+ environment_by_name = {}
497
+ for name, value in os.environ.items():
498
+ if name.upper().startswith(EDITOR_HIDDEN_ENVIRONMENT_PREFIXES):
499
+ continue
500
+ environment_by_name[name] = value
501
+ return environment_by_name
502
+
503
+
504
+ def launch_text_editor(command: TextEditorCommand, filename: str, platform: str = sys.platform,
505
+ popen: Callable[..., subprocess.Popen] = subprocess.Popen,
506
+ run: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run, *, activation: StartupActivation = NO_STARTUP_ACTIVATION) -> None:
507
+ """Start the chosen editor on a working copy file without ever waiting for it.
508
+
509
+ An editor set to wait until its window closes (`--wait`) would otherwise keep GitPane blocked for as long as the
510
+ file stays open. A command that only hands the file over, such as macOS's open, ends at once with the system's
511
+ answer: it is run until that answer (run), so a refusal is reported with its message, as for a terminal. The
512
+ editor receives the startup value read when the user clicked (an X11 startup identifier or a Wayland activation
513
+ token), so its window may come to the front, and never one GitPane inherited.
514
+ """
515
+ arguments = command.arguments(filename)
516
+ working_dir = os.path.dirname(os.path.abspath(filename))
517
+ if command.answers_at_once():
518
+ _run_until_answer(arguments, working_dir, run, env=activation.environment(editor_environment()))
519
+ return
520
+ option_by_name: dict[str, object] = {"cwd": working_dir, "shell": False, "close_fds": True, "env": activation.environment(editor_environment())}
521
+ if platform == WINDOWS_PLATFORM:
522
+ option_by_name["creationflags"] = subprocess.DETACHED_PROCESS
523
+ else:
524
+ option_by_name.update(start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
525
+ process = popen(arguments, **option_by_name)
526
+ # Popen never waits for the editor to exit. Its own nonblocking cleanup reaps exited children on later starts.
527
+ process.poll()
528
+
529
+
530
+ class TerminalStart(Protocol):
531
+ """Start a terminal in a folder, handing it the startup value read when the user clicked."""
532
+
533
+ def __call__(self, descriptor: TerminalDescriptor, repository_dir: str, *, activation: StartupActivation) -> None:
534
+ """Start the terminal without waiting for it, or raise why it could not start."""
535
+
536
+
537
+ class TextEditorStart(Protocol):
538
+ """Start an editor on a working copy file, handing it the startup value read when the user clicked."""
539
+
540
+ def __call__(self, command: TextEditorCommand, filename: str, *, activation: StartupActivation) -> None:
541
+ """Start the editor without waiting for it, or raise why it could not start."""
542
+
543
+
544
+ def open_explorer(repository_dir: str, open_url: Callable[[QUrl], bool] = QDesktopServices.openUrl) -> None:
545
+ """Ask the desktop to open the exact local directory and report startup refusal."""
546
+ if not os.path.isdir(repository_dir):
547
+ raise FileNotFoundError(f"Folder unavailable: {repository_dir}")
548
+ if not open_url(QUrl.fromLocalFile(repository_dir)):
549
+ raise OSError("The system refused to open the folder in the file explorer.")
550
+
551
+
552
+ @dataclass
553
+ class PendingLaunch:
554
+ """Retain capacity after timeout until an uninterruptible OS startup actually returns."""
555
+
556
+ result: ToolResult
557
+ deadline: float
558
+ reported: bool = False
559
+
560
+
561
+ def _launch_worker(start: Callable[[], None], result: ToolResult, completion_queue: queue.SimpleQueue[ToolResult]) -> None:
562
+ """Send plain data back from a daemon worker without touching any Qt object."""
563
+ cause = ""
564
+ try:
565
+ start()
566
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
567
+ cause = str(error)
568
+ completion_queue.put(ToolResult(result.request_id, result.label, result.repository_dir, result.program_filename, result.arguments, cause))
569
+
570
+
571
+ def _reveal_worker(reveal: Callable[[str, str], bool], filename: str, startup_id: str, result: ToolResult, completion_queue: queue.SimpleQueue[tuple[ToolResult, bool]]) -> None:
572
+ """Request native selection off the GUI thread and leave folder fallback to Qt.
573
+
574
+ A selection helper that does not answer in time counts as no selection, so the folder still opens; any other
575
+ failure is reported with its cause.
576
+ """
577
+ cause = ""
578
+ selected = False
579
+ try:
580
+ selected = reveal(filename, startup_id)
581
+ except subprocess.TimeoutExpired:
582
+ selected = False
583
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
584
+ cause = str(error)
585
+ completion_queue.put((ToolResult(result.request_id, result.label, result.repository_dir, result.program_filename, result.arguments, cause), selected))
586
+
587
+
588
+ class SystemToolService(QObject):
589
+ """Bound startup work while keeping external terminals independent of application shutdown."""
590
+
591
+ started = Signal(object, str)
592
+ finished = Signal(object)
593
+ export_finished = Signal(object)
594
+ export_idle = Signal()
595
+
596
+ def __init__(self, launch: TerminalStart = launch_terminal,
597
+ explorer: Callable[[str], None] = open_explorer, parent: QObject | None = None,
598
+ export_writer: Callable[[FileExport, queue.SimpleQueue[OperationResult]], None] = _write_export,
599
+ reveal: Callable[[str, str], bool] = reveal_file,
600
+ text_editor_launch: TextEditorStart = launch_text_editor,
601
+ activation: Callable[[], StartupActivation] = no_startup_activation,
602
+ reveal_available: Callable[[], bool] = selection_helper_available) -> None:
603
+ """Inject OS calls and return worker results through a GUI-owned polling timer.
604
+
605
+ The activation source tells the desktop that the user asked for the program GitPane starts (an X11 startup
606
+ identifier or a Wayland activation token), so its window may come to the front; it is read on the GUI thread
607
+ when the user clicks, before the worker starts, and never otherwise. The application injects the desktop's
608
+ source, which lives in the interface layer; without it, no value is sent and the desktop decides alone. It is
609
+ not read for a launch the cheap checks already know will not use it (a terminal that has gone, no selection
610
+ helper for Show in folder): under Wayland a token nobody uses shows a busy pointer for up to 15 s.
611
+ """
612
+ super().__init__(parent)
613
+ self.launch = launch
614
+ self.text_editor_launch = text_editor_launch
615
+ self.explorer = explorer
616
+ self.reveal = reveal
617
+ self.reveal_available = reveal_available
618
+ self.activation = activation
619
+ self.export_writer = export_writer
620
+ self.export_by_id: dict[object, FileExport] = {}
621
+ self.export_completion_queue: queue.SimpleQueue[OperationResult] = queue.SimpleQueue()
622
+ self.pending_by_id: dict[object, PendingLaunch] = {}
623
+ self.completion_queue: queue.SimpleQueue[ToolResult] = queue.SimpleQueue()
624
+ self.reveal_completion_queue: queue.SimpleQueue[tuple[ToolResult, bool]] = queue.SimpleQueue()
625
+ self.closed = False
626
+ self.timer = QTimer(self)
627
+ self.timer.setInterval(TOOL_RESULT_POLL_MS)
628
+ self.timer.timeout.connect(self._poll)
629
+
630
+ def show_file_location(self, request_id: object, context: RepositoryContext, path: bytes, *, allow_missing: bool = False) -> None:
631
+ """Select a contained working file, falling back to its folder when selection is unavailable."""
632
+ if self.closed or request_id in self.pending_by_id:
633
+ return
634
+ try:
635
+ directory = file_location(context, path, allow_missing=allow_missing)
636
+ filename = worktree_filename(context, path)
637
+ except (OSError, ValueError) as error:
638
+ self.finished.emit(ToolResult(request_id, "file location", context.repository_dir, "", (), str(error)))
639
+ return
640
+ if not os.path.lexists(filename):
641
+ self.open_explorer(request_id, directory)
642
+ return
643
+ result = ToolResult(request_id, "file explorer", directory, "", ())
644
+ if len(self.pending_by_id) >= TOOL_START_ACTIVE_LIMIT:
645
+ self.finished.emit(ToolResult(request_id, result.label, directory, "", (), "Too many tools are starting. Try again after they finish."))
646
+ return
647
+ self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
648
+ self.started.emit(request_id, result.label)
649
+ # Read now, on the GUI thread, so the value is the one of this request and Qt windows are only touched here.
650
+ activation = self.activation() if self.reveal_available() else NO_STARTUP_ACTIVATION
651
+ threading.Thread(target=_reveal_worker, args=(self.reveal, filename, activation.value, result, self.reveal_completion_queue), daemon=True).start()
652
+ self.timer.start()
653
+
654
+ def save_historical_file(self, request_id: object, context: RepositoryContext, destination_filename: str,
655
+ read: Callable[[Callable[[bytes], None], Callable[[int], None]], None], cancel_read: Callable[[], None], overwrite_confirmed: bool = False) -> None:
656
+ """Stream an immutable Git read into a bounded, cancellable atomic disk writer."""
657
+ if self.closed or len(self.export_by_id) >= FILE_EXPORT_ACTIVE_LIMIT:
658
+ self.export_finished.emit(OperationResult(request_id, destination_filename, "Another file export is still running. Try again after it finishes."))
659
+ return
660
+ operation = FileExport(request_id, context, destination_filename, overwrite_confirmed, cancel_read)
661
+ self.export_by_id[request_id] = operation
662
+ threading.Thread(target=self.export_writer, args=(operation, self.export_completion_queue), daemon=True).start()
663
+ self.timer.start()
664
+
665
+ def consume(chunk: bytes) -> None:
666
+ """Keep UI delivery nonblocking even when the destination disk stalls."""
667
+ if operation.cancelled.is_set():
668
+ return
669
+ try:
670
+ for offset in range(0, len(chunk), PROCESS_CHUNK_BYTES):
671
+ operation.chunks.put_nowait(chunk[offset:offset + PROCESS_CHUNK_BYTES])
672
+ except queue.Full:
673
+ self.fail_export(request_id, "The destination cannot keep up with the file stream. Saving was cancelled.")
674
+
675
+ def completed(size: int) -> None:
676
+ """Allow publication only after Git confirms the complete object framing."""
677
+ try:
678
+ operation.chunks.put_nowait(size)
679
+ except queue.Full:
680
+ self.fail_export(request_id, "The destination cannot keep up with the file stream. Saving was cancelled.")
681
+
682
+ try:
683
+ read(consume, completed)
684
+ except (OSError, ValueError, RuntimeError) as error:
685
+ self.fail_export(request_id, str(error))
686
+
687
+ def fail_export(self, request_id: object, cause: str) -> None:
688
+ """Stop a failed Git read and let the disk worker remove its temporary file."""
689
+ operation = self.export_by_id.get(request_id)
690
+ if operation is None:
691
+ return
692
+ with operation.publish_lock:
693
+ operation.cause = cause
694
+ operation.cancelled.set()
695
+ operation.cancel_read()
696
+
697
+ def cancel_export(self, request_id: object) -> None:
698
+ """Cancel an unfinished export without touching an existing destination."""
699
+ self.fail_export(request_id, "Saving was cancelled.")
700
+
701
+ def launch_terminal(self, request_id: object, descriptor: TerminalDescriptor, repository_dir: str) -> bool:
702
+ """Capture a request once and schedule startup without blocking the Qt event loop."""
703
+ if self.closed or request_id in self.pending_by_id:
704
+ return False
705
+ result = ToolResult(request_id, descriptor.label, repository_dir, descriptor.program_filename, tuple(terminal_arguments(descriptor, repository_dir)))
706
+ if len(self.pending_by_id) >= TOOL_START_ACTIVE_LIMIT:
707
+ self.finished.emit(ToolResult(request_id, result.label, repository_dir, result.program_filename, result.arguments, "Too many tools are starting. Try again after they finish."))
708
+ return False
709
+ self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
710
+ self.started.emit(request_id, descriptor.label)
711
+ # Read now, on the GUI thread, so the value is the one of this request and Qt windows are only touched here.
712
+ activation = self.activation() if os.path.isdir(repository_dir) and available_terminal_program(descriptor) else NO_STARTUP_ACTIVATION
713
+ threading.Thread(target=_launch_worker, args=(lambda: self.launch(descriptor, repository_dir, activation=activation), result, self.completion_queue), daemon=True).start()
714
+ self.timer.start()
715
+ return True
716
+
717
+ def launch_text_editor(self, request_id: object, command: TextEditorCommand, filename: str) -> bool:
718
+ """Start the editor on a working copy file off the Qt thread, with the same startup limit and timeout as a terminal.
719
+
720
+ The result names the editor program and its arguments, so a failure tells which editor did not start.
721
+ """
722
+ if self.closed or request_id in self.pending_by_id:
723
+ return False
724
+ directory = os.path.dirname(filename)
725
+ try:
726
+ program_filename, *argument_list = command.arguments(filename)
727
+ except ValueError as error:
728
+ self.finished.emit(ToolResult(request_id, TEXT_EDITOR_LABEL, directory, "", (), str(error)))
729
+ return False
730
+ result = ToolResult(request_id, TEXT_EDITOR_LABEL, directory, program_filename, tuple(argument_list))
731
+ if len(self.pending_by_id) >= TOOL_START_ACTIVE_LIMIT:
732
+ self.finished.emit(ToolResult(request_id, result.label, directory, result.program_filename, result.arguments, "Too many tools are starting. Try again after they finish."))
733
+ return False
734
+ self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
735
+ self.started.emit(request_id, result.label)
736
+ # Read now, on the GUI thread, so the value is the one of this request and Qt windows are only touched here.
737
+ activation = self.activation()
738
+ threading.Thread(target=_launch_worker, args=(lambda: self.text_editor_launch(command, filename, activation=activation), result, self.completion_queue), daemon=True).start()
739
+ self.timer.start()
740
+ return True
741
+
742
+ def open_explorer(self, request_id: object, repository_dir: str) -> None:
743
+ """Invoke Qt's desktop integration on the GUI thread after displaying progress."""
744
+ if self.closed or request_id in self.pending_by_id:
745
+ return
746
+ result = ToolResult(request_id, "file explorer", repository_dir, "", ())
747
+ self.pending_by_id[request_id] = PendingLaunch(result, time.monotonic() + TOOL_START_TIMEOUT_MS / MILLISECONDS_PER_SECOND)
748
+ self.started.emit(request_id, result.label)
749
+ QTimer.singleShot(0, self, lambda: self._open_explorer(result))
750
+
751
+ def _open_explorer(self, result: ToolResult) -> None:
752
+ """Open a validated folder through Qt and report failures to the requesting window."""
753
+ if self.closed:
754
+ return
755
+ cause = ""
756
+ try:
757
+ self.explorer(result.repository_dir)
758
+ except (OSError, ValueError, RuntimeError) as error:
759
+ cause = str(error)
760
+ self.pending_by_id.pop(result.request_id, None)
761
+ self.finished.emit(ToolResult(result.request_id, result.label, result.repository_dir, "", (), cause))
762
+
763
+ def _poll(self) -> None:
764
+ """Report a bounded startup wait without pretending a timed-out OS call was cancelled."""
765
+ while True:
766
+ try:
767
+ result = self.export_completion_queue.get_nowait()
768
+ except queue.Empty:
769
+ break
770
+ operation = self.export_by_id.pop(result.request_id, None)
771
+ if operation is not None:
772
+ operation.cancel_read()
773
+ self.export_finished.emit(result)
774
+ if not self.export_by_id:
775
+ self.export_idle.emit()
776
+ while True:
777
+ try:
778
+ result, selected = self.reveal_completion_queue.get_nowait()
779
+ except queue.Empty:
780
+ break
781
+ pending = self.pending_by_id.get(result.request_id)
782
+ if pending is None:
783
+ continue
784
+ if pending.reported:
785
+ self.pending_by_id.pop(result.request_id)
786
+ continue
787
+ if not selected and not result.cause:
788
+ self._open_explorer(result)
789
+ continue
790
+ self.pending_by_id.pop(result.request_id)
791
+ self.finished.emit(result)
792
+ while True:
793
+ try:
794
+ result = self.completion_queue.get_nowait()
795
+ except queue.Empty:
796
+ break
797
+ pending = self.pending_by_id.pop(result.request_id, None)
798
+ if pending is not None and not pending.reported:
799
+ self.finished.emit(result)
800
+ for pending in tuple(self.pending_by_id.values()):
801
+ if not pending.reported and time.monotonic() >= pending.deadline:
802
+ pending.reported = True
803
+ result = pending.result
804
+ self.finished.emit(ToolResult(result.request_id, result.label, result.repository_dir, result.program_filename, result.arguments,
805
+ "Startup timed out. The system may still open this tool; another launch will wait for its response.", True))
806
+ if not self.pending_by_id and not self.export_by_id:
807
+ self.timer.stop()
808
+
809
+ def close(self) -> None:
810
+ """Stop receiving startup reports without terminating already launched tools."""
811
+ self.closed = True
812
+ for request_id in tuple(self.export_by_id):
813
+ self.cancel_export(request_id)
814
+ if not self.export_by_id:
815
+ self.timer.stop()
816
+ self.pending_by_id.clear()