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,356 @@
1
+ """Translate native text selection into captured byte-line intentions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QEvent, QObject, Qt, Signal
9
+ from PySide6.QtWidgets import QMenu, QMessageBox
10
+
11
+ from gitpane.config import ERROR_WIDTH, SPACING_MD, STAGE_SHORTCUT
12
+ from gitpane.history.repository_value import BlobContentKind
13
+ from gitpane.history.revision_file import DiffLineKind, decode_blob_display
14
+ from gitpane.ui.emoji.file_action_icon import file_action_icon
15
+ from gitpane.ui.widget.shortcut_text import with_shortcut
16
+ from gitpane.ui.widget.system_shortcut import trash_key_pressed
17
+ from gitpane.ui.window_control.dialog_modality import prepare_message_box
18
+ from gitpane.worktree.file_menu_action import READ_ONLY_FILE_ACTION_SET, FileAction
19
+ from gitpane.worktree.ignore_rule import IgnoreDestination
20
+ from gitpane.worktree.partial_patch import RawLineSelection
21
+ from gitpane.worktree.worktree_snapshot import StagingSide, local_history_path
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+
26
+ from PySide6.QtCore import QPoint
27
+ from PySide6.QtWidgets import QListWidget
28
+
29
+ from gitpane.ui.reader.diff_panel import DiffView
30
+ from gitpane.ui.staging.staging_window import StagingWindow
31
+ from gitpane.worktree.partial_patch import RawPatch
32
+ from gitpane.worktree.staged_reset import ResetCandidate
33
+ from gitpane.worktree.trash import TrashRequest
34
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
35
+
36
+
37
+ SELECT_ONE_FILE_REASON = "Select one file"
38
+ MOVE_TO_TRASH_TITLE = "Move selected file(s) to Trash"
39
+
40
+
41
+ def configure_confirmation(dialog: QMessageBox, action_label: str, cancel_label: str) -> None:
42
+ """Name the exact effect and make both default activation and Escape cancel safely."""
43
+ dialog.setTextFormat(Qt.TextFormat.PlainText)
44
+ dialog.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
45
+ dialog.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
46
+ dialog.button(QMessageBox.StandardButton.Yes).setText(action_label)
47
+ dialog.button(QMessageBox.StandardButton.Cancel).setText(cancel_label)
48
+ dialog.setDefaultButton(QMessageBox.StandardButton.Cancel)
49
+ dialog.setEscapeButton(QMessageBox.StandardButton.Cancel)
50
+
51
+
52
+ def show_confirmation_targets(dialog: QMessageBox, summary: str, labels: list[str]) -> None:
53
+ """Keep a short native summary above scrollable, complete target names and calculated details."""
54
+ preview = next(iter(labels)) if len(labels) == 1 else f"{len(labels)} selected files"
55
+ available_width = min(ERROR_WIDTH, dialog.screen().availableGeometry().width()) - SPACING_MD * 2
56
+ preview = dialog.fontMetrics().elidedText(preview.replace("\r", "\\r").replace("\n", "\\n"), Qt.TextElideMode.ElideMiddle, available_width)
57
+ dialog.setText(summary + "\n" + preview)
58
+ details = dialog.detailedText()
59
+ dialog.setDetailedText("\n".join(labels) + ("\n\n" + details if details else ""))
60
+
61
+
62
+ class FileResetMenu(QObject):
63
+ """Capture exact list selections for a direct side-specific Reset action."""
64
+
65
+ requested = Signal(object)
66
+ delete_requested = Signal(object, object)
67
+ ignore_requested = Signal(object, object, object)
68
+ action_requested = Signal(object, object, object)
69
+
70
+ def __init__(self, window: StagingWindow) -> None:
71
+ """Attach menus to both lists while keeping the source side with each entry."""
72
+ super().__init__(window)
73
+ self.window = window
74
+ # The system whose trash keys the file lists follow; a test sets another one to check that system's keys.
75
+ self.platform = sys.platform
76
+ for file_list in (window.unstaged_list, window.staged_list):
77
+ file_list.installEventFilter(self)
78
+ file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
79
+ file_list.customContextMenuRequested.connect(lambda position, file_list=file_list: self._open(file_list, position))
80
+
81
+ def create_menu(self, file_list: QListWidget) -> QMenu:
82
+ """Recheck availability and retain the captured selection for delayed activation."""
83
+ menu = QMenu(self.window)
84
+ entries = self.window.selected_entries(file_list)
85
+ snapshot = self.window.snapshot
86
+ unstaged = file_list is self.window.unstaged_list
87
+ enabled = bool(entries) and snapshot is not None and snapshot.fresh and not self.window.busy
88
+ readable = enabled
89
+ file_entries = tuple(entry for entry in entries if not entry.is_gitlink)
90
+ if unstaged:
91
+ stage_action = menu.addAction(file_action_icon(FileAction.STAGE, menu), FileAction.STAGE.label)
92
+ stage_action.setEnabled(enabled and self.window.stage_button.isEnabled())
93
+ stage_action.setToolTip(with_shortcut(FileAction.STAGE.label, STAGE_SHORTCUT))
94
+ stage_action.triggered.connect(lambda: self._request(FileAction.STAGE, entries, snapshot, file_list))
95
+ action = menu.addAction(file_action_icon(FileAction.RESET, menu), FileAction.RESET.label)
96
+ enabled = enabled and not any(entry.conflict_stages for entry in snapshot.entries) and not any(entry.skip_worktree for entry in entries)
97
+ new_file = snapshot is not None and snapshot.requires_file_confirmation(tuple(entry.path for entry in file_entries))
98
+ action.setEnabled(enabled and bool(file_entries) and not new_file)
99
+ action.setToolTip("New files cannot be reset. Use Unstage or Delete file." if new_file else "Reset changes on the selected side")
100
+ action.triggered.connect(lambda checked=False: self._request(FileAction.RESET, entries, snapshot, file_list))
101
+ delete_action = menu.addAction(file_action_icon(FileAction.DELETE, menu), FileAction.DELETE.label)
102
+ delete_action.setEnabled(bool(file_entries) and snapshot is not None and snapshot.fresh and not self.window.busy)
103
+ delete_action.triggered.connect(lambda checked=False: self._request(FileAction.DELETE, entries, snapshot, file_list))
104
+ menu.setToolTipsVisible(True)
105
+ if unstaged:
106
+ copy_menu = menu.addMenu(FileAction.COPY_PATHS.label)
107
+ copy_menu.menuAction().setIcon(file_action_icon(FileAction.COPY_PATHS, menu))
108
+ copy_menu.setEnabled(readable)
109
+ for copy_file_action in (FileAction.COPY_BASENAME, FileAction.COPY_ABSOLUTE):
110
+ copy_action = copy_menu.addAction(file_action_icon(copy_file_action, copy_menu), copy_file_action.label)
111
+ copy_action.setIconVisibleInMenu(True)
112
+ copy_action.triggered.connect(lambda checked=False, copy_file_action=copy_file_action: self._request(copy_file_action, entries, snapshot, file_list))
113
+ single_file_reason = SELECT_ONE_FILE_REASON if len(entries) != 1 else ""
114
+ self._add_consultation(menu, FileAction.FOLDER, single_file_reason, "Open the existing file's folder", readable, entries, snapshot, file_list)
115
+ text_editor_reason = single_file_reason
116
+ if not text_editor_reason and self.window.text_editor_reason is not None:
117
+ text_editor_reason = self.window.text_editor_reason(next(iter(entries)))
118
+ self._add_consultation(menu, FileAction.TEXT_EDITOR, text_editor_reason, "Open the working copy file in the text editor", readable, entries, snapshot, file_list)
119
+ history_reason = single_file_reason
120
+ if not history_reason and (snapshot is None or next(iter(entries)).is_gitlink or local_history_path(snapshot, next(iter(entries))) is None):
121
+ history_reason = "No file history exists at HEAD for this file"
122
+ self._add_consultation(menu, FileAction.HISTORY, history_reason, "File history (H)", readable, entries, snapshot, file_list)
123
+ if unstaged:
124
+ for destination in IgnoreDestination:
125
+ ignore_action = menu.addAction(file_action_icon(destination, menu), destination.label)
126
+ ignore_action.setEnabled(enabled and bool(file_entries))
127
+ ignore_action.triggered.connect(lambda checked=False, destination=destination: self._request_ignore(destination, entries, snapshot, file_list))
128
+ stop_action = menu.addAction(file_action_icon(FileAction.STOP_TRACKING, menu), FileAction.STOP_TRACKING.label)
129
+ tracked = bool(entries) and all(entry.old_mode in ("100644", "100755", "120000") for entry in entries)
130
+ stop_action.setEnabled(enabled and tracked)
131
+ stop_action.setToolTip("Keep files on disk and stage removal of their tracking" if tracked else "Select tracked files")
132
+ stop_action.triggered.connect(lambda: self._request(FileAction.STOP_TRACKING, entries, snapshot, file_list))
133
+ for action in menu.actions():
134
+ action.setIconVisibleInMenu(True)
135
+ return menu
136
+
137
+ def _add_consultation(self, menu: QMenu, file_action: FileAction, reason: str, tooltip: str, readable: bool, entries: tuple[LocalEntry, ...],
138
+ snapshot: WorktreeSnapshot | None, file_list: QListWidget) -> None:
139
+ """Add a consultation of the selected file, greyed with its reason when it cannot apply to the selection.
140
+
141
+ The Staged list only gets the actions of the read-only set, which never change the index: the session accepts
142
+ exactly those from it, so the menu never offers one the session would refuse.
143
+ """
144
+ if file_list is not self.window.unstaged_list and file_action not in READ_ONLY_FILE_ACTION_SET:
145
+ return
146
+ action = menu.addAction(file_action_icon(file_action, menu), file_action.label)
147
+ action.setEnabled(readable and not reason)
148
+ action.setToolTip(reason or tooltip)
149
+ action.triggered.connect(lambda checked=False: self._request(file_action, entries, snapshot, file_list))
150
+
151
+ def _fresh_current_read(self, snapshot: WorktreeSnapshot | None) -> WorktreeSnapshot | None:
152
+ """Answer the window's current read while the window can act and both the menu's read and the current one are fresh, else None.
153
+
154
+ Every menu action checks this first, so a busy or closing window, or a read that went stale, never starts one.
155
+ """
156
+ current = self.window.snapshot
157
+ if self.window.busy or self.window.close_requested or snapshot is None or not snapshot.fresh or current is None or not current.fresh:
158
+ return None
159
+ return current
160
+
161
+ def _selection_is_current(self, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot | None, file_list: QListWidget) -> bool:
162
+ """Tell whether the menu's read is still the window's fresh current read and the list still selects the same entries.
163
+
164
+ Every action that works on the selection as it is now, rather than on captured content, requires both.
165
+ """
166
+ current = self._fresh_current_read(snapshot)
167
+ return not (current is None or current is not snapshot or entries != self.window.selected_entries(file_list))
168
+
169
+ def _request(self, file_action: FileAction, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot | None, file_list: QListWidget) -> None:
170
+ """Keep captured Delete content for service validation and require current selections for other actions."""
171
+ if file_action == FileAction.DELETE:
172
+ current = self._fresh_current_read(snapshot)
173
+ if current is None or current.key != snapshot.key or current.git_filename != snapshot.git_filename:
174
+ return
175
+ # Trash validates the original bytes so a refresh cannot replace the user's captured intention.
176
+ entries = tuple(entry for entry in entries if not entry.is_gitlink)
177
+ if entries:
178
+ self.delete_requested.emit(entries, snapshot)
179
+ return
180
+ if not self._selection_is_current(entries, snapshot, file_list):
181
+ return
182
+ if file_action in (FileAction.RESET, FileAction.HISTORY, FileAction.STOP_TRACKING):
183
+ entries = tuple(entry for entry in entries if not entry.is_gitlink)
184
+ if not entries:
185
+ return
186
+ if file_action == FileAction.RESET:
187
+ if snapshot.requires_file_confirmation(tuple(entry.path for entry in entries)):
188
+ return
189
+ self.requested.emit(entries)
190
+ else:
191
+ self.action_requested.emit(file_action, entries, snapshot)
192
+
193
+ def _request_ignore(self, destination: IgnoreDestination, entries: tuple[LocalEntry, ...], snapshot: WorktreeSnapshot | None, file_list: QListWidget) -> None:
194
+ """Send a rule for the captured selection to its destination, only while the window, the read and the selection are still current.
195
+
196
+ Submodules are left out, since an ignore rule cannot apply to them; nothing is sent when no file remains.
197
+ """
198
+ if not self._selection_is_current(entries, snapshot, file_list):
199
+ return
200
+ entries = tuple(entry for entry in entries if not entry.is_gitlink)
201
+ if not entries:
202
+ return
203
+ self.ignore_requested.emit(entries, snapshot, destination)
204
+
205
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
206
+ """Restrict file shortcuts to their source list and suppress keyboard auto-repeat.
207
+
208
+ The filter watches only the two file lists, so the trash keys of the system (Delete, plus Backspace and
209
+ Cmd+Backspace on macOS) never act from a text field, where Backspace erases a character.
210
+ """
211
+ if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_H and not event.modifiers() and watched is self.window.unstaged_list:
212
+ if not event.isAutoRepeat():
213
+ self._request(FileAction.HISTORY, self.window.selected_entries(watched), self.window.snapshot, watched)
214
+ return True
215
+ if event.type() == QEvent.Type.KeyPress and trash_key_pressed(event.key(), event.modifiers(), self.platform):
216
+ snapshot = self.window.snapshot
217
+ if not event.isAutoRepeat() and not self.window.busy and snapshot is not None and snapshot.fresh:
218
+ entries = self.window.selected_entries(watched)
219
+ if entries:
220
+ self._request(FileAction.DELETE, entries, snapshot, watched)
221
+ return True
222
+ return super().eventFilter(watched, event)
223
+
224
+ def confirm_stop_tracking(self, entries: tuple[LocalEntry, ...]) -> bool:
225
+ """Explain cached removal and preservation of local content before modifying the index."""
226
+ labels: list[str] = []
227
+ for entry in entries:
228
+ labels.append(entry.label)
229
+ dialog = QMessageBox(QMessageBox.Icon.Warning, FileAction.STOP_TRACKING.label, "Stop tracking:\n" + "\n".join(labels), parent=self.window)
230
+ prepare_message_box(dialog, FileAction.STOP_TRACKING.label)
231
+ dialog.setTextFormat(Qt.TextFormat.PlainText)
232
+ dialog.setInformativeText("Removal from tracking will be staged. All file content stays on disk. No ignore rule is added. Git will refuse if the index contains a unique version; review staged changes before retrying.")
233
+ show_confirmation_targets(dialog, "Stop tracking:", labels)
234
+ configure_confirmation(dialog, "Stop tracking selected files", "Cancel tracking change")
235
+ accepted = dialog.exec() == QMessageBox.StandardButton.Yes
236
+ dialog.deleteLater()
237
+ return accepted
238
+
239
+ def confirm_trash(self, request: TrashRequest) -> bool:
240
+ """Name every captured target and explain the difference between Trash and Reset."""
241
+ labels: list[str] = []
242
+ for target in request.targets:
243
+ labels.append(target.path.decode("utf-8", "replace") + (" (tracked)" if target.tracked else " (untracked)"))
244
+ dialog = QMessageBox(QMessageBox.Icon.Warning, MOVE_TO_TRASH_TITLE, MOVE_TO_TRASH_TITLE + ":\n" + "\n".join(labels), parent=self.window)
245
+ prepare_message_box(dialog, MOVE_TO_TRASH_TITLE)
246
+ dialog.setTextFormat(Qt.TextFormat.PlainText)
247
+ dialog.setInformativeText("Tracked deletions remain Unstaged. Reset restores the index version, not the deleted local edits. The OS Trash owns moved files; a batch cannot be rolled back automatically.")
248
+ show_confirmation_targets(dialog, MOVE_TO_TRASH_TITLE + ":", labels)
249
+ configure_confirmation(dialog, "Move files to Trash", "Cancel deletion")
250
+ accepted = dialog.exec() == QMessageBox.StandardButton.Yes
251
+ dialog.deleteLater()
252
+ return accepted
253
+
254
+ def _open(self, file_list: QListWidget, position: QPoint) -> None:
255
+ """Open the captured menu without retargeting another list."""
256
+ menu = self.create_menu(file_list)
257
+ menu.exec(file_list.mapToGlobal(position))
258
+ menu.deleteLater()
259
+
260
+ def confirm(self, prepared: ResetCandidate) -> bool:
261
+ """Explain the chosen side and show the calculated result before publication."""
262
+ from difflib import unified_diff
263
+
264
+ from gitpane.history.revision_file import decode_blob_display
265
+
266
+ labels: list[str] = []
267
+ details: list[str] = []
268
+ for change in prepared.changes:
269
+ label = change.path.decode("utf-8", "replace")
270
+ labels.append(label)
271
+ before = decode_blob_display("", change.before.read()).display_text
272
+ after = decode_blob_display("", change.after.read()).display_text
273
+ result = "File removed" if not change.after.exists else f"Result: {len(change.after.read())} bytes"
274
+ details.append(label + "\n" + result + "\n" + "".join(unified_diff(before.splitlines(keepends=True), after.splitlines(keepends=True), fromfile="Current", tofile="After Reset")))
275
+ title = "Reset " + prepared.side.title
276
+ dialog = QMessageBox(QMessageBox.Icon.Warning, title, "Reset " + prepared.side.label + " changes in:\n" + "\n".join(labels), parent=self.window)
277
+ prepare_message_box(dialog, title)
278
+ dialog.setTextFormat(Qt.TextFormat.PlainText)
279
+ dialog.setInformativeText(prepared.explanation + " Files are replaced before the index; an interrupted operation may require recovery.")
280
+ dialog.setDetailedText("\n\n".join(details))
281
+ show_confirmation_targets(dialog, "Reset " + prepared.side.label + " changes in:", labels)
282
+ configure_confirmation(dialog, "Reset " + prepared.side.label + " changes", "Cancel reset")
283
+ accepted = dialog.exec() == QMessageBox.StandardButton.Yes
284
+ dialog.deleteLater()
285
+ return accepted
286
+
287
+
288
+ class StagingActionMenu(QObject):
289
+ """Offer exactly the two Unstaged actions without changing native text selection."""
290
+
291
+ stage_requested = Signal(object)
292
+ reset_requested = Signal(object)
293
+
294
+ def __init__(self, view: DiffView, available: Callable[[], bool]) -> None:
295
+ """Bind line identity to the displayed patch and ask the controller about freshness."""
296
+ super().__init__(view)
297
+ self.view = view
298
+ self.available = available
299
+ self.model: RawPatch | None = None
300
+ self.patch_index_by_line_index: dict[int, int] = {}
301
+ view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
302
+ view.customContextMenuRequested.connect(self._open)
303
+
304
+ def bind(self, model: RawPatch | None) -> None:
305
+ """Associate logical display lines explicitly, refusing incomplete previews."""
306
+ self.model = None
307
+ self.patch_index_by_line_index.clear()
308
+ if model is None:
309
+ return
310
+ content = decode_blob_display("", model.patch)
311
+ if not content.complete or content.content_kind == BlobContentKind.BINARY:
312
+ return
313
+ offset = 1 if content.diagnostic else 0
314
+ for change in model.changes:
315
+ if change.patch_index is not None and change.kind in (DiffLineKind.ADDED, DiffLineKind.REMOVED):
316
+ self.patch_index_by_line_index[change.patch_index + offset] = change.patch_index
317
+ self.model = model
318
+
319
+ def selection(self) -> RawLineSelection | None:
320
+ """Include intersected logical lines, excluding a following line at its start boundary."""
321
+ cursor = self.view.textCursor()
322
+ if self.model is None or not self.available() or not cursor.hasSelection():
323
+ return None
324
+ start = self.view.source_position(cursor.selectionStart())
325
+ end = self.view.source_position(cursor.selectionEnd())
326
+ first_index = self.view.logical_line_index(cursor.selectionStart())
327
+ last_index = self.view.logical_line_index(cursor.selectionEnd())
328
+ line_ids: list[int] = []
329
+ for line_index in range(first_index, last_index + 1):
330
+ line_start = self.view.source_position(self.view.line_positions[line_index])
331
+ if line_start >= end:
332
+ break
333
+ patch_index = self.patch_index_by_line_index.get(line_index)
334
+ if patch_index is not None and end > start:
335
+ line_ids.append(patch_index)
336
+ if not line_ids:
337
+ return None
338
+ return RawLineSelection(self.model.snapshot.key, self.model.snapshot.generation, StagingSide.UNSTAGED, self.model.entry.path, tuple(line_ids))
339
+
340
+ def create_menu(self) -> QMenu:
341
+ """Capture an intention at menu opening so delayed actions cannot retarget a new diff."""
342
+ if self.model is None:
343
+ return self.view.createStandardContextMenu()
344
+ menu = QMenu(self.view)
345
+ selection = self.selection()
346
+ for label, signal in (("Stage selected lines", self.stage_requested), ("Reset selected lines", self.reset_requested)):
347
+ action = menu.addAction(label)
348
+ action.setEnabled(selection is not None)
349
+ action.triggered.connect(lambda checked=False, signal=signal, selection=selection: signal.emit(selection))
350
+ return menu
351
+
352
+ def _open(self, position: QPoint) -> None:
353
+ """Open at the requested position without moving the original text cursor."""
354
+ menu = self.create_menu()
355
+ menu.exec(self.view.mapToGlobal(position))
356
+ menu.deleteLater()