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,38 @@
1
+ """Keep the operation consoles of GitPane so the ones that reported a pause can close once it is answered."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shiboken6 import isValid
8
+
9
+ if TYPE_CHECKING:
10
+ from gitpane.ui.console.operation_dialog import OperationDialog
11
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
12
+
13
+
14
+ class ConsoleRegistry:
15
+ """The consoles of the reference, rebase and transport operations by operation, and the ones of each worktree that wait for the answer to a pause."""
16
+
17
+ def __init__(self) -> None:
18
+ """Start with no console; the wiring builds the registry after the window activation filter, and the controllers that open consoles receive it."""
19
+ self.rebase_output_by_id: dict[str, OperationDialog] = {}
20
+ # Launch, Continue and Abort consoles of a rebase, cherry-pick or revert that published a pause, per worktree, so they can be settled once
21
+ # the user answered the pause or the operation ended instead of lingering after the pause they reported is gone.
22
+ self.paused_console_ids_by_key: dict[WorktreeKey, list[str]] = {}
23
+ self.reference_output_by_id: dict[str, OperationDialog] = {}
24
+ self.transport_dialog_by_id: dict[str, OperationDialog] = {}
25
+
26
+ def settle_paused_consoles(self, key: WorktreeKey) -> None:
27
+ """Let the consoles that reported a pause of this worktree close by Keep dialog open now that the operation ended."""
28
+ for operation_id in self.paused_console_ids_by_key.pop(key, []):
29
+ self.settle_answered_console(operation_id)
30
+
31
+ def settle_answered_console(self, operation_id: str) -> None:
32
+ """Let one console that waited for the user's answer (a paused replay, a refused push) close by Keep dialog open.
33
+
34
+ A console already dismissed by the user is simply gone.
35
+ """
36
+ output = self.reference_output_by_id.get(operation_id) or self.rebase_output_by_id.get(operation_id) or self.transport_dialog_by_id.get(operation_id)
37
+ if output is not None and isValid(output):
38
+ output.settle_pause()
@@ -0,0 +1,95 @@
1
+ """Open the emoji keyboard of GitPane where its user left it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from shiboken6 import isValid
8
+
9
+ from gitpane.config import EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY
10
+ from gitpane.history.file_history import WindowCapability
11
+ from gitpane.operation.diagnostic import Diagnostic
12
+ from gitpane.ui.emoji.emoji_font import load_emoji_font
13
+ from gitpane.ui.emoji.emoji_window import EmojiWindow
14
+
15
+ if TYPE_CHECKING:
16
+ from PySide6.QtCore import QRect
17
+ from PySide6.QtWidgets import QApplication
18
+
19
+ from gitpane.controller.operation_policy import OperationPolicy
20
+ from gitpane.controller.session_registry import SessionRegistry
21
+ from gitpane.operation.diagnostic import DiagnosticLog
22
+ from gitpane.preference.preference_store import PreferenceStore
23
+
24
+
25
+ class EmojiWindowController:
26
+ """The single floating emoji keyboard shared by every repository window, with its saved place and the one-time report of its font."""
27
+
28
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, application: QApplication, preference: PreferenceStore, diagnostic_log: DiagnosticLog | None) -> None:
29
+ """Receive the application, the preferences and the lower controllers the keyboard works with; the wiring builds it after the welcome controller, and the repository windows ask it for the keyboard."""
30
+ self.session_registry_controller = session_registry_controller
31
+ self.operation_policy_controller = operation_policy_controller
32
+ self.application = application
33
+ self.preference = preference
34
+ self.diagnostic_log = diagnostic_log
35
+ self.emoji_window: EmojiWindow | None = None
36
+ self.emoji_font_reported = False
37
+
38
+ def open_emoji_window(self, session_id: str) -> EmojiWindow | None:
39
+ """Open the single floating emoji keyboard, shared by every repository window, or bring it back.
40
+
41
+ It has no Qt parent so that it survives a repository switch in the window that asked for it, and a second
42
+ request, from any window, brings the same window back instead of opening another one. Without a saved geometry,
43
+ its first size is computed on the screen of the window that asked, where it opens.
44
+ """
45
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
46
+ return None
47
+ if self.emoji_window is not None and isValid(self.emoji_window):
48
+ self.emoji_window.showNormal()
49
+ self.emoji_window.raise_()
50
+ self.emoji_window.activateWindow()
51
+ return self.emoji_window
52
+ window = EmojiWindow(self.application.clipboard())
53
+ self.emoji_window = window
54
+ window.geometry_saved.connect(self._save_emoji_window_geometry)
55
+ if not window.restore_saved_geometry(str(self.preference.read(EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY))):
56
+ # The keyboard is shared by every window and has no Qt parent; it opens over the window that asked for it.
57
+ window.placement_anchor = self.session_registry_controller.window_by_session.get(session_id)
58
+ window.resize_to_whole_grid(self._emoji_window_available_area(session_id))
59
+ window.showNormal()
60
+ self.report_emoji_font()
61
+ return window
62
+
63
+ def _emoji_window_available_area(self, session_id: str) -> QRect:
64
+ """Return the usable area of the screen the keyboard opens on: that of the window that asked, else the primary screen.
65
+
66
+ The keyboard has no parent and is not shown yet, so its own screen is still the primary one whatever window asked.
67
+ """
68
+ anchor = self.session_registry_controller.window_by_session.get(session_id)
69
+ screen = anchor.screen() if anchor is not None else self.application.primaryScreen()
70
+ return screen.availableGeometry()
71
+
72
+ def report_emoji_font(self) -> None:
73
+ """Write once per process, to the --log file, why the bundled colour emoji font is not used.
74
+
75
+ The application keeps working with the system fonts, so nothing is shown on screen; only the font warning is
76
+ written, never a symbol or the message draft. The first symbol area that needs the font triggers the report.
77
+ """
78
+ if self.emoji_font_reported:
79
+ return
80
+ self.emoji_font_reported = True
81
+ status = load_emoji_font()
82
+ if status.warning and self.diagnostic_log is not None:
83
+ self.diagnostic_log.write_line(status.warning)
84
+
85
+ def _save_emoji_window_geometry(self, value: str) -> None:
86
+ """Keep the floating keyboard's place and size for its next opening, in this session or a later one.
87
+
88
+ A refused write only costs the remembered place, so it goes to the --log file and never interrupts the user.
89
+ """
90
+ try:
91
+ self.preference.update({EMOJI_WINDOW_GEOMETRY_PREFERENCE_KEY: value})
92
+ except (OSError, ValueError, TypeError) as error:
93
+ diagnostic = Diagnostic("Save emoji keyboard position", "", "", [], str(error)).prepare()
94
+ if self.diagnostic_log is not None:
95
+ self.diagnostic_log.write(diagnostic)
@@ -0,0 +1,479 @@
1
+ """Act on a file a repository window or Prepare changes shows, outside Git's history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import uuid
8
+ from typing import TYPE_CHECKING
9
+
10
+ from PySide6.QtCore import QStandardPaths
11
+ from PySide6.QtWidgets import QFileDialog, QMessageBox, QPushButton
12
+ from shiboken6 import isValid
13
+
14
+ from gitpane.config import ProcessPriority, SharedText
15
+ from gitpane.history.file_history import FileHistoryAnchor, WindowCapability
16
+ from gitpane.history.repository_value import FilePanel, FileReadIdentity, RequestIdentity, TreeEntry, historical_selection
17
+ from gitpane.operation.diagnostic import Diagnostic, SensitiveTextFilter
18
+ from gitpane.sequencer.conflict import conflict_path_label
19
+ from gitpane.system.qt_folder import writable_folder
20
+ from gitpane.tool.difftool import DifftoolRecipe
21
+ from gitpane.tool.git_executable import terminal_git_filename
22
+ from gitpane.tool.kdiff3_install import status_bar_refusal_text
23
+ from gitpane.tool.system_tool import git_editor_line, git_shell_filename, text_editor_command, text_editor_unavailable_reason, uses_git_editor, worktree_filename
24
+ from gitpane.ui.console.error_dialog import ErrorDialog
25
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
26
+ from gitpane.worktree.command_sequence import GitCommand, GitCommandError
27
+ from gitpane.worktree.historical_restore import RESTORE_SELECTED_VERSION_TITLE
28
+ from gitpane.worktree.worktree_snapshot import WorktreeKey
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Generator
32
+
33
+ from gitpane.controller.history_read_controller import HistoryReadController
34
+ from gitpane.controller.opening_controller import OpeningController
35
+ from gitpane.controller.operation_policy import OperationPolicy
36
+ from gitpane.controller.refresh_controller import FullRefreshRead, RefreshController
37
+ from gitpane.controller.session_registry import SessionRegistry
38
+ from gitpane.controller.staging_session_registry import StagingSessionRegistry
39
+ from gitpane.history.repository import GitRepository
40
+ from gitpane.history.repository_value import RepositoryContext, RevisionEntry
41
+ from gitpane.operation.diagnostic import DiagnosticLog, PreparedDiagnostic
42
+ from gitpane.preference.preference_store import PreferenceStore
43
+ from gitpane.tool.difftool import DifftoolLaunch, DifftoolService
44
+ from gitpane.tool.system_editor_default import SystemEditorDefault
45
+ from gitpane.tool.system_tool import OperationResult, SystemToolService, TextEditorCommand, ToolResult
46
+ from gitpane.tool.terminal_recipe import TerminalDescriptor
47
+ from gitpane.ui.repository.repository_window import RepositoryWindow
48
+ from gitpane.worktree.file_action_target import FileActionSelection, FileRestoreTarget
49
+ from gitpane.worktree.historical_restore import HistoricalRestoreRequest, HistoricalRestoreService
50
+ from gitpane.worktree.mutation import MutationOutcome
51
+ from gitpane.worktree.worktree_service import WorktreeService
52
+
53
+ # Names of the file tools in their start requests: those of Prepare changes are told apart from those of a repository window.
54
+ STAGING_FOLDER_TOOL = "staging-folder"
55
+ STAGING_TEXT_EDITOR_TOOL = "staging-text-editor"
56
+ TEXT_EDITOR_TOOL = "text-editor"
57
+ # Tools started from Prepare changes: their request carries the Git folder, and their failures go to its status area.
58
+ STAGING_TOOL_SET = frozenset((STAGING_FOLDER_TOOL, STAGING_TEXT_EDITOR_TOOL))
59
+ RESTORE_UNCHANGED_TEXT = "'{path}' already matches the selected version. Nothing was restored."
60
+ RESTORE_UNCHANGED_STATUS = "The selected version is already present."
61
+ RESTORED_STATUS = "Selected version restored. The index and HEAD are unchanged."
62
+
63
+
64
+ class FileActionController:
65
+ """The actions on one file of a repository window or of Prepare changes: external tools, file history, restoring a selected version, external comparison and Save as, each checked against the authority of the window that asks."""
66
+
67
+ def __init__(self, *, session_registry_controller: SessionRegistry, operation_policy_controller: OperationPolicy, staging_session_registry_controller: StagingSessionRegistry, refresh_controller: RefreshController,
68
+ history_read_controller: HistoryReadController, opening_controller: OpeningController, preference: PreferenceStore, repository: GitRepository, difftool: DifftoolService, worktree_service: WorktreeService,
69
+ historical_restore: HistoricalRestoreService, diagnostic_log: DiagnosticLog | None, system_tool: SystemToolService, system_editor_default: SystemEditorDefault) -> None:
70
+ """Receive the services and the lower controllers the file actions work with; the wiring builds it after the emoji keyboard controller, and the repository windows and Prepare changes reach it through the wiring.
71
+
72
+ ``system_editor_default`` keeps the text editor the system names, read in the background, which the editor
73
+ detection follows while no editor choice is saved.
74
+ """
75
+ self.session_registry_controller = session_registry_controller
76
+ self.operation_policy_controller = operation_policy_controller
77
+ self.staging_session_registry_controller = staging_session_registry_controller
78
+ self.preference = preference
79
+ self.repository = repository
80
+ self.worktree_service = worktree_service
81
+ self.diagnostic_log = diagnostic_log
82
+ self.system_tool = system_tool
83
+ self.system_editor_default = system_editor_default
84
+ self.refresh_controller = refresh_controller
85
+ self.history_read_controller = history_read_controller
86
+ self.opening_controller = opening_controller
87
+ self.difftool = difftool
88
+ self.historical_restore = historical_restore
89
+ # The core.editor value of each repository, read once per repository window in the background, because no
90
+ # process may start while a file menu is being built.
91
+ self.git_editor_setting_by_repository_dir: dict[str, str] = {}
92
+ # The window that asked for each restoration of a selected version, until its result is shown.
93
+ self.restore_session_by_operation_id: dict[str, str] = {}
94
+
95
+ def open_explorer(self, session_id: str) -> bool:
96
+ """Open the worktree only from a window allowed to invoke external tools."""
97
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
98
+ return False
99
+ self.system_tool.open_explorer((session_id, "explorer"), self.session_registry_controller.session_by_id[session_id].repository_context.repository_dir)
100
+ return True
101
+
102
+ def launch_terminal(self, session_id: str, descriptor: TerminalDescriptor | None) -> bool:
103
+ """Reject terminal intents from limited windows even when emitted directly."""
104
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE) or descriptor is None:
105
+ return False
106
+ self.system_tool.launch_terminal((session_id, "terminal"), descriptor, self.session_registry_controller.session_by_id[session_id].repository_context.repository_dir)
107
+ return True
108
+
109
+ def _file_action_allowed(self, session_id: str, selection: FileActionSelection) -> bool:
110
+ """Validate direct intentions as well as menu clicks against the live owning window."""
111
+ window = self.session_registry_controller.window_by_session.get(session_id)
112
+ return (self.operation_policy_controller.allows(session_id, WindowCapability.FILE_MENU) and window is not None and isValid(window)
113
+ and window.file_action_is_current(selection))
114
+
115
+ def show_file_folder(self, session_id: str, selection: FileActionSelection) -> None:
116
+ """Locate the selected historical name in the real working tree without restoring it."""
117
+ if not self._file_action_allowed(session_id, selection):
118
+ return
119
+ session = self.session_registry_controller.session_by_id[session_id]
120
+ identity = FileReadIdentity(RequestIdentity(session_id, session.generation, oid=selection.commit_oid))
121
+ chosen = historical_selection(selection.entry, identity)
122
+ self.system_tool.show_file_location((session_id, "file-folder"), session.repository_context, chosen.path)
123
+
124
+ def show_file_in_text_editor(self, session_id: str, selection: FileActionSelection) -> None:
125
+ """Open the working copy of the selected historical name in the editor, never a version of the commit."""
126
+ if not self._file_action_allowed(session_id, selection):
127
+ return
128
+ session = self.session_registry_controller.session_by_id[session_id]
129
+ identity = FileReadIdentity(RequestIdentity(session_id, session.generation, oid=selection.commit_oid))
130
+ chosen = historical_selection(selection.entry, identity)
131
+ self.launch_text_editor((session_id, TEXT_EDITOR_TOOL), session.repository_context, chosen.path)
132
+
133
+ def launch_text_editor(self, request_id: tuple[str, str], context: RepositoryContext, path: bytes) -> None:
134
+ """Start the chosen editor on the working copy file of a Git path, checking again that it can open.
135
+
136
+ The file may have gone, or the editor changed, between the menu and the click; the reason is then shown
137
+ and nothing starts.
138
+ """
139
+ command = self.text_editor_command(context)
140
+ reason = text_editor_unavailable_reason(context, path, command)
141
+ if reason or command is None:
142
+ self._show_text_editor_refusal(request_id, reason)
143
+ return
144
+ self.system_tool.launch_text_editor(request_id, command, worktree_filename(context, path))
145
+
146
+ def _staging_consultation_failed(self, git_dir: str, cause: str, diagnostic: PreparedDiagnostic | None = None) -> None:
147
+ """Show why a file action of Prepare changes did not happen, in the status area of that Prepare changes.
148
+
149
+ A tool that failed to start passes its prepared diagnostic, so the details name the program that failed.
150
+ """
151
+ for key, staging in self.staging_session_registry_controller.staging_by_key.items():
152
+ if key.git_dir == git_dir:
153
+ staging._consultation_failed(cause, diagnostic)
154
+
155
+ def _show_text_editor_refusal(self, request_id: tuple[str, str], reason: str) -> None:
156
+ """Show in the requesting window why the editor was not started."""
157
+ session_id, tool = request_id
158
+ if tool in STAGING_TOOL_SET:
159
+ self._staging_consultation_failed(session_id, reason)
160
+ return
161
+ window = self.session_registry_controller.window_by_session.get(session_id)
162
+ if window is not None and isValid(window) and not window.is_closed:
163
+ window.statusBar().showMessage(reason)
164
+
165
+ def text_editor_command(self, context: RepositoryContext) -> TextEditorCommand | None:
166
+ """Choose the editor for a working copy file with the rule of this platform.
167
+
168
+ On Windows: Settings, then what Git would use, then the detected editor. Elsewhere: Settings or the detected
169
+ editor only, since an editor set for Git is often a terminal editor that shows nothing without a terminal. The
170
+ detected editor is the one Settings shows while nothing is saved, and it is not saved here either. No process
171
+ starts: core.editor, only kept on Windows, comes from the value read when the repository window opened, and a
172
+ read not finished yet counts as unset; the click asks again with the value known by then. The editor the system
173
+ names for plain text is read in the background the same way: a read not finished yet counts as the system
174
+ naming nothing.
175
+ """
176
+ editor_recipe = self.preference.initialize_editor(repository_dirs=(context.repository_dir,), system_editor_id=self.system_editor_default.system_editor_id)
177
+ git_editor = git_editor_line(os.environ, self.git_editor_setting_by_repository_dir.get(context.repository_dir, ""))
178
+ git_utility_dir = str(self.preference.value_by_key["git_utility_dir"])
179
+ return text_editor_command(editor_recipe, git_editor, lambda: git_shell_filename(git_filename=terminal_git_filename(self.repository.git_filename), git_utility_dir=git_utility_dir),
180
+ saved=self.preference.editor_choice_saved())
181
+
182
+ def text_editor_reason(self, session_id: str, selection: FileActionSelection) -> str:
183
+ """Tell the file menu why the working copy of its file cannot be opened in a text editor, or answer an empty reason."""
184
+ session = self.session_registry_controller.session_by_id.get(session_id)
185
+ if session is None:
186
+ return ""
187
+ identity = FileReadIdentity(RequestIdentity(session_id, session.generation, oid=selection.commit_oid))
188
+ chosen = historical_selection(selection.entry, identity)
189
+ context = session.repository_context
190
+ return text_editor_unavailable_reason(context, chosen.path, self.text_editor_command(context))
191
+
192
+ def read_git_editor_setting(self, context: RepositoryContext) -> None:
193
+ """Read core.editor once per repository window, in the background, because no process may start while a menu is built.
194
+
195
+ Git reads it in the repository itself, so its includes and its ownership check of the repository apply as
196
+ when Git starts the editor. A failed read keeps the value unset and goes to the --log file. Outside Windows the
197
+ editor set for Git is never started, so no process is started to read it.
198
+ """
199
+ if not uses_git_editor():
200
+ return
201
+ if context.is_bare or context.worktree_dir is None:
202
+ return
203
+
204
+ def read() -> Generator[GitCommand, bytes, bytes]:
205
+ """Ask Git for the value; exit code 1 means it is not set."""
206
+ output = yield GitCommand(["config", "--get", "core.editor"], accepted_codes=(0, 1))
207
+ return output
208
+
209
+ def complete(value: object, error: Exception | None) -> None:
210
+ """Keep the value for this repository, or log why it could not be read."""
211
+ if error is not None:
212
+ if self.diagnostic_log is not None:
213
+ self.diagnostic_log.write_line("Cannot read core.editor of " + context.repository_dir + ": " + str(error))
214
+ return
215
+ self.git_editor_setting_by_repository_dir[context.repository_dir] = bytes(value).decode("utf-8", "surrogateescape").strip()
216
+
217
+ self.worktree_service.run(context, self.repository.git_filename, read(), complete, priority=ProcessPriority.BACKGROUND)
218
+
219
+ def tool_started(self, request_id: tuple[str, str], label: str) -> None:
220
+ """Mark only the requesting window's tool action as starting."""
221
+ session_id, tool = request_id
222
+ window = self.session_registry_controller.window_by_session.get(session_id)
223
+ if window is not None and isValid(window) and not window.is_closed:
224
+ window.set_tool_busy(tool, True)
225
+ window.statusBar().showMessage(f"Opening {label}…")
226
+
227
+ def _tool_failure_diagnostic(self, result: ToolResult) -> PreparedDiagnostic:
228
+ """Prepare the diagnostic of a tool that did not start, naming its program and arguments, and write it to the --log file.
229
+
230
+ Prepare changes and the repository window show the same prepared text, so the screen and the log agree.
231
+ """
232
+ diagnostic = Diagnostic(f"Open {result.label}", result.repository_dir, result.program_filename, list(result.arguments), result.cause).prepare()
233
+ if self.diagnostic_log is not None:
234
+ self.diagnostic_log.write(diagnostic)
235
+ return diagnostic
236
+
237
+ def tool_finished(self, result: ToolResult) -> None:
238
+ """Release startup controls and show actual system failures in the owning window."""
239
+ session_id, tool = result.request_id
240
+ if tool in STAGING_TOOL_SET:
241
+ if result.cause:
242
+ self._staging_consultation_failed(session_id, result.cause, self._tool_failure_diagnostic(result))
243
+ return
244
+ window = self.session_registry_controller.window_by_session.get(session_id)
245
+ if window is None or not isValid(window) or window.is_closed:
246
+ return
247
+ window.set_tool_busy(tool, False)
248
+ window.statusBar().clearMessage()
249
+ if not result.cause:
250
+ return
251
+ diagnostic = self._tool_failure_diagnostic(result)
252
+ dialog = ErrorDialog(diagnostic, window)
253
+ if tool == "terminal":
254
+ choose_button = QPushButton(SharedText.TERMINAL_CHOOSE.value, dialog)
255
+ dialog.layout().addWidget(choose_button)
256
+ choose_button.clicked.connect(dialog.accept)
257
+ choose_button.clicked.connect(lambda: window.terminal_menu.popup(window.terminal_button.mapToGlobal(window.terminal_button.rect().bottomLeft())))
258
+ self.session_registry_controller.dialogs.append(dialog)
259
+ dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
260
+ dialog.finished.connect(dialog.deleteLater)
261
+ open_window_modal(dialog)
262
+
263
+ def open_file_history(self, session_id: str, entry: RevisionEntry | TreeEntry) -> RepositoryWindow | None:
264
+ """Create a separately captured read-only session for the chosen historical side."""
265
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.FILE_MENU):
266
+ return None
267
+ source = self.session_registry_controller.session_by_id[session_id]
268
+ if isinstance(entry, TreeEntry):
269
+ if entry not in source.tree_entries or source.selected_oid is None:
270
+ return None
271
+ anchor = FileHistoryAnchor(source.selected_oid, entry.path, entry.oid)
272
+ else:
273
+ if not any(entry in group.entries for group in source.diff_groups):
274
+ return None
275
+ anchor = FileHistoryAnchor.from_entry(entry)
276
+ identity = RequestIdentity(uuid.uuid4().hex, 1)
277
+ self.session_registry_controller.identity_by_session[identity.session_id] = identity
278
+ self.repository.context_by_session[identity.session_id] = source.repository_context
279
+ self.opening_controller.opened(identity, source.repository_context, None, anchor, self.session_registry_controller.window_by_session.get(session_id))
280
+ return self.session_registry_controller.window_by_session[identity.session_id]
281
+
282
+ def restore_historical_file(self, session_id: str, selection: FileActionSelection | None, target: FileRestoreTarget | None = None) -> None:
283
+ """Restore the viewed commit or its chosen parent, confirming only over a new local addition."""
284
+ if selection is None or not self.operation_policy_controller.allows(session_id, WindowCapability.RESTORE) or not self._file_action_allowed(session_id, selection):
285
+ return
286
+ session = self.session_registry_controller.session_by_id[session_id]
287
+ context = session.repository_context
288
+ if context.is_bare or context.worktree_dir is None:
289
+ return
290
+ window = self.session_registry_controller.window_by_session[session_id]
291
+ targets = window.file_restore_targets(selection) or []
292
+ if target is None:
293
+ target = next(iter(targets), None)
294
+ if target is None or target.unavailable or target not in targets:
295
+ return
296
+ message = target.message
297
+
298
+ def validate() -> bool:
299
+ """Prevent a closed or reoriented window from authorizing a previous selection."""
300
+ return self.operation_policy_controller.allows(session_id, WindowCapability.RESTORE) and self._file_action_allowed(session_id, selection)
301
+
302
+ def prepared(request: HistoricalRestoreRequest | None, error: Exception | None) -> None:
303
+ """Publish the prepared source, retaining confirmation when a new local file would be replaced."""
304
+ if error is not None:
305
+ if validate():
306
+ diagnostic = error.diagnostic if isinstance(error, GitCommandError) else Diagnostic(RESTORE_SELECTED_VERSION_TITLE, context.repository_dir,
307
+ self.repository.git_filename, [], str(error)).prepare()
308
+ if self.diagnostic_log is not None:
309
+ self.diagnostic_log.write(diagnostic)
310
+ dialog = ErrorDialog(diagnostic, window)
311
+ self.session_registry_controller.dialogs.append(dialog)
312
+ dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
313
+ dialog.finished.connect(dialog.deleteLater)
314
+ open_window_modal(dialog)
315
+ return
316
+ if not validate():
317
+ self.historical_restore.cancel(request)
318
+ return
319
+ needs_confirmation = request.before.exists and request.snapshot.requires_file_confirmation((request.path,))
320
+ accepted = not needs_confirmation or window.confirm_historical_restore(request)
321
+ if not accepted or not validate():
322
+ self.historical_restore.cancel(request)
323
+ return
324
+ self.restore_session_by_operation_id[request.operation_id] = session_id
325
+ self.historical_restore.restore(request)
326
+
327
+ self.historical_restore.prepare(context, self.repository.git_filename, selection, message, session.mode, validate, prepared, source_oid=target.commit_oid)
328
+
329
+ def historical_restore_finished(self, outcome: MutationOutcome) -> None:
330
+ """Refresh affected file views and report the actual retained restoration result.
331
+
332
+ A restoration that found the file already at the selected version wrote nothing, which a status bar text alone
333
+ hides too easily: the window where the user asked also opens a message saying so. The other windows of the
334
+ worktree only read again.
335
+ """
336
+ requesting_session_id = self.restore_session_by_operation_id.pop(outcome.operation_id, None)
337
+ if outcome.operation_id not in self.historical_restore.outcome_by_id:
338
+ return
339
+ shared_by_key: dict[WorktreeKey, FullRefreshRead] = {}
340
+ for session_id, session in tuple(self.session_registry_controller.session_by_id.items()):
341
+ if session.is_closed or WorktreeKey.from_context(session.repository_context) != outcome.key:
342
+ continue
343
+ window = self.session_registry_controller.window_by_session.get(session_id)
344
+ if window is None or not isValid(window) or window.is_closed:
345
+ continue
346
+ if outcome.diagnostic is not None:
347
+ dialog = ErrorDialog(outcome.diagnostic, window)
348
+ self.session_registry_controller.dialogs.append(dialog)
349
+ dialog.finished.connect(lambda dialog=dialog: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
350
+ dialog.finished.connect(dialog.deleteLater)
351
+ open_window_modal(dialog)
352
+ else:
353
+ window.statusBar().showMessage(RESTORE_UNCHANGED_STATUS if outcome.status == "unchanged" else RESTORED_STATUS)
354
+ if outcome.status == "unchanged" and session_id == requesting_session_id:
355
+ self._show_unchanged_restore(window, outcome)
356
+ self.refresh_controller.refresh(session_id, full_refresh_read=self.refresh_controller.full_refresh_read_for(session, shared_by_key))
357
+ self.refresh_controller.observe_full_refresh_pauses(shared_by_key)
358
+
359
+ def _show_unchanged_restore(self, window: RepositoryWindow, outcome: MutationOutcome) -> None:
360
+ """Tell the user, over the window that asked, that the file already had the selected version and nothing was written."""
361
+ path_label = ", ".join(conflict_path_label(path) for path in outcome.completed_paths)
362
+ message = QMessageBox(QMessageBox.Icon.Information, RESTORE_SELECTED_VERSION_TITLE, RESTORE_UNCHANGED_TEXT.format(path=path_label),
363
+ QMessageBox.StandardButton.Ok, window)
364
+ prepare_message_box(message, RESTORE_SELECTED_VERSION_TITLE)
365
+ self.session_registry_controller.dialogs.append(message)
366
+ message.finished.connect(lambda _result, message=message: self.session_registry_controller.dialogs.remove(message) if message in self.session_registry_controller.dialogs else None)
367
+ message.finished.connect(message.deleteLater)
368
+ open_window_modal(message)
369
+
370
+ def compare_external(self, session_id: str) -> None:
371
+ """Capture a main-window comparison and preferences only after explicit activation.
372
+
373
+ With no diff tool saved, the KDiff3 detected on this system is used and saved at this first use, as Settings shows it.
374
+ """
375
+ if not self.operation_policy_controller.allows(session_id, WindowCapability.MANAGE):
376
+ return
377
+ session = self.session_registry_controller.session_by_id.get(session_id)
378
+ window = self.session_registry_controller.window_by_session.get(session_id)
379
+ if session is None or window is None or session.selected_entry is None:
380
+ return
381
+ identity = session.file_identity_by_kind.get(FilePanel.DIFF)
382
+ entry = session.selected_entry
383
+ if identity is None or window.diff_panel.selected_entry != entry or not self.history_read_controller.file_is_current(identity):
384
+ return
385
+ difftool_filename = self.preference.initialize_difftool()
386
+ recipe = DifftoolRecipe(difftool_filename, str(self.preference.value_by_key["difftool_command"]))
387
+ self.difftool.compare(entry, identity, recipe, lambda: self.history_read_controller.file_is_current(identity))
388
+
389
+ def difftool_failed(self, launch: DifftoolLaunch, cause: str) -> None:
390
+ """Report comparison errors without logging templates or historical contents, bold red in the source window's status bar.
391
+
392
+ --log keeps the whole cause; the status bar, which shows a single line, gets several install commands as a
393
+ pointer to Settings › Tools instead.
394
+
395
+ A failure reported after the tool exited, such as a refused cleanup of its copies, names the tool and says in
396
+ its cause the code it closed with, which is not judged: --log neither blames the tool for it nor says it did not start.
397
+ """
398
+ if launch.return_code is None:
399
+ diagnostic = Diagnostic("External comparison", "", "", [], cause).prepare()
400
+ else:
401
+ closed_cause = f"{cause} The comparison tool had closed with exit code {launch.return_code}."
402
+ diagnostic = Diagnostic("External comparison", "", launch.recipe.program_filename, [], closed_cause, ended_before_failure=True).prepare()
403
+ if self.diagnostic_log is not None:
404
+ self.diagnostic_log.write(diagnostic)
405
+ window = self.session_registry_controller.window_by_session.get(launch.selection.selection_identity.request_identity.session_id)
406
+ if window is not None:
407
+ window.show_status_failure(status_bar_refusal_text(cause))
408
+
409
+ def difftool_closed(self, launch: DifftoolLaunch) -> None:
410
+ """Tell the still-live source window that the user closed the comparison, and write to --log how the tool ended.
411
+
412
+ The exit code is not judged, so the window only says the comparison closed; the --log line keeps the tool, its
413
+ exit status and how long it ran, which explain a tool that closed at once without showing anything. The line
414
+ carries neither the arguments nor the names of the historical copies.
415
+ """
416
+ if self.diagnostic_log is not None:
417
+ closed_line = f"External comparison closed: {launch.recipe.program_filename}, exit status {launch.return_code}, ran {launch.run_duration_ms} ms"
418
+ self.diagnostic_log.write_line(SensitiveTextFilter().redact(closed_line))
419
+ window = self.session_registry_controller.window_by_session.get(launch.selection.selection_identity.request_identity.session_id)
420
+ if window is not None:
421
+ window.statusBar().showMessage("External comparison closed.")
422
+
423
+ def difftool_started(self, launch: DifftoolLaunch) -> None:
424
+ """Confirm startup only to the still-live source window."""
425
+ window = self.session_registry_controller.window_by_session.get(launch.selection.selection_identity.request_identity.session_id)
426
+ if window is not None:
427
+ window.statusBar().showMessage("External comparison opened. Historical copies remain available until it closes.")
428
+
429
+ def save_file_as(self, session_id: str, selection: FileActionSelection) -> None:
430
+ """Confirm the destination while selection is valid, then lock export to its exact blob."""
431
+ if not self._file_action_allowed(session_id, selection):
432
+ return
433
+ session = self.session_registry_controller.session_by_id[session_id]
434
+ identity = FileReadIdentity(RequestIdentity(uuid.uuid4().hex, 1, oid=selection.commit_oid), FilePanel.EXPORT)
435
+ chosen = historical_selection(selection.entry, identity)
436
+ if chosen.mode == "160000":
437
+ return
438
+ window = self.session_registry_controller.window_by_session[session_id]
439
+ suggested_filename = os.path.join(writable_folder(QStandardPaths.StandardLocation.DocumentsLocation, sys.platform), selection.basename())
440
+ destination_filename, _selected_filter = QFileDialog.getSaveFileName(window, "Save selected as", suggested_filename)
441
+ if not destination_filename:
442
+ return
443
+ if not self._file_action_allowed(session_id, selection):
444
+ if isValid(window) and not window.is_closed:
445
+ window.statusBar().showMessage("Saving was cancelled because the file selection changed.")
446
+ return
447
+ # A separate read session preserves the accepted blob across subsequent commit changes and Refresh.
448
+ export_session_id = identity.request_identity.session_id
449
+ self.repository.context_by_session[export_session_id] = session.repository_context
450
+ self.repository.identity_by_session[export_session_id] = identity.request_identity
451
+ self.repository.executable_by_session[export_session_id] = self.repository.executable_by_session[session_id]
452
+ self.session_registry_controller.export_owner_by_identity[identity] = session_id
453
+ window.statusBar().showMessage("Saving historical file…")
454
+ self.system_tool.save_historical_file(identity, session.repository_context, destination_filename,
455
+ lambda consume, completed: self.repository.stream_blob(chosen.blob_oid, identity, consume, completed),
456
+ lambda: self.repository.cancel(export_session_id), overwrite_confirmed=os.path.exists(destination_filename))
457
+
458
+ def export_finished(self, result: OperationResult) -> None:
459
+ """Release export resources and present errors only to a still-live source window."""
460
+ session_id = self.session_registry_controller.export_owner_by_identity.pop(result.request_id, None)
461
+ if session_id is None:
462
+ return
463
+ export_session_id = result.request_id.request_identity.session_id
464
+ self.repository.cancel(export_session_id)
465
+ self.repository.context_by_session.pop(export_session_id, None)
466
+ window = self.session_registry_controller.window_by_session.get(session_id)
467
+ if window is None or not isValid(window) or window.is_closed:
468
+ return
469
+ if not result.cause:
470
+ window.statusBar().showMessage("Historical file saved")
471
+ return
472
+ diagnostic = Diagnostic("Save selected as", window.context.repository_dir, "", [], result.cause).prepare()
473
+ if self.diagnostic_log is not None:
474
+ self.diagnostic_log.write(diagnostic)
475
+ dialog = ErrorDialog(diagnostic, window)
476
+ self.session_registry_controller.dialogs.append(dialog)
477
+ dialog.finished.connect(lambda: self.session_registry_controller.dialogs.remove(dialog) if dialog in self.session_registry_controller.dialogs else None)
478
+ dialog.finished.connect(dialog.deleteLater)
479
+ open_window_modal(dialog)