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,302 @@
1
+ """Move confirmed working files through the OS trash service without permanent removal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from dataclasses import dataclass, replace
8
+ from enum import Enum
9
+ from typing import TYPE_CHECKING
10
+
11
+ from PySide6.QtCore import QFile, QObject, QTimer, Signal
12
+
13
+ from gitpane.config import TRASH_TIMEOUT_MS
14
+ from gitpane.operation.diagnostic import Diagnostic
15
+ from gitpane.operation.operation_status import OperationStatus
16
+ from gitpane.operation.repository_operation import OperationAction
17
+ from gitpane.worktree.command_sequence import FileTask, GitCommand
18
+ from gitpane.worktree.worktree_file import FileImage, capture_image, validate_destination, validate_image
19
+ from gitpane.worktree.worktree_snapshot import WorktreeKey, conflict_side_by_path, verify_local_state
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable, Generator
23
+
24
+ from gitpane.history.repository_value import RepositoryContext
25
+ from gitpane.operation.diagnostic import PreparedDiagnostic
26
+ from gitpane.worktree.mutation import MutationService
27
+ from gitpane.worktree.worktree_service import WorktreeService
28
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
29
+
30
+
31
+ def move_file_to_trash(target_filename: str) -> None:
32
+ """Use one QFile instance so a failed OS move retains its actual error cause."""
33
+ target = QFile(target_filename)
34
+ if not target.moveToTrash():
35
+ raise OSError(target.errorString())
36
+
37
+
38
+ def trash_filename(context: RepositoryContext, path: bytes) -> str:
39
+ """Require a lossless OS and QString path before handing a name to Qt."""
40
+ try:
41
+ decoded = os.fsdecode(path)
42
+ decoded.encode("utf-8", "strict")
43
+ if os.fsencode(decoded) != path or "\ufffd" in decoded:
44
+ raise ValueError("The file name cannot be represented exactly by the trash service")
45
+ except UnicodeError as error:
46
+ raise ValueError("The file name cannot be represented exactly by the trash service") from error
47
+ filename, _parents = validate_destination(context, path)
48
+ try:
49
+ filename.encode("utf-8", "strict")
50
+ except UnicodeError as error:
51
+ raise ValueError("The absolute file name cannot be represented by the trash service") from error
52
+ return filename
53
+
54
+
55
+ def tracked_trash_path_set(index_entries: bytes) -> set[bytes]:
56
+ """Name every path the index records, each stage of a conflict included, from `ls-files --stage -z` records.
57
+
58
+ The records go through the same validation as the conflict reader first, so a malformed listing is refused instead
59
+ of turning into a wrong tracked or untracked label in the confirmation.
60
+ """
61
+ try:
62
+ conflict_side_by_path(index_entries)
63
+ except ValueError as error:
64
+ raise ValueError("Git returned malformed index entries; refresh and select the files again") from error
65
+ tracked_path_set: set[bytes] = set()
66
+ for record in index_entries.split(b"\0"):
67
+ if not record:
68
+ continue
69
+ _metadata, _separator, path = record.partition(b"\t")
70
+ tracked_path_set.add(path)
71
+ return tracked_path_set
72
+
73
+
74
+ class TrashTargetState(Enum):
75
+ """Where one confirmed file stands in a move to the Trash; the value is the word the per-file details show."""
76
+
77
+ NOT_STARTED = "not_started"
78
+ """The file was not handed to the system, because the move stopped before it."""
79
+ PENDING = "pending"
80
+ """The system is moving the file and has not answered yet."""
81
+ MOVED = "moved"
82
+ """The system moved the file to the Trash."""
83
+ FAILED = "failed"
84
+ """The system refused the move, and the file is still in place."""
85
+ UNKNOWN = "unknown"
86
+ """The system reported an error but the file is gone, so whether it reached the Trash is not known."""
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class TrashTarget:
91
+ """Retain the confirmed identity alongside each observed OS result."""
92
+
93
+ path: bytes
94
+ filename: str
95
+ image: FileImage
96
+ tracked: bool
97
+ status: TrashTargetState = TrashTargetState.NOT_STARTED
98
+ cause: str = ""
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class TrashRequest:
103
+ """Freeze the selected paths and their file images before asking for confirmation."""
104
+
105
+ operation_id: str
106
+ snapshot: WorktreeSnapshot
107
+ targets: tuple[TrashTarget, ...]
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class TrashOutcome:
112
+ """Keep per-file results and the real completion state beyond the view lifetime."""
113
+
114
+ operation_id: str
115
+ key: WorktreeKey
116
+ status: OperationStatus
117
+ targets: tuple[TrashTarget, ...]
118
+ finished: bool
119
+ snapshot: WorktreeSnapshot | None = None
120
+ diagnostic: PreparedDiagnostic | None = None
121
+
122
+
123
+ class TrashService(QObject):
124
+ """Share mutation reservations and the existing file worker with a replaceable OS service."""
125
+
126
+ finished = Signal(object)
127
+ waiting = Signal(object)
128
+
129
+ def __init__(self, worktree_service: WorktreeService, mutation: MutationService, move_file_to_trash: Callable[[str], None] = move_file_to_trash, parent: QObject | None = None) -> None:
130
+ """Inject the trash primitive and keep all file work under application ownership."""
131
+ super().__init__(parent)
132
+ self.worktree_service = worktree_service
133
+ self.mutation = mutation
134
+ self.move_file_to_trash = move_file_to_trash
135
+ self.prepared_by_id: dict[str, TrashRequest] = {}
136
+ self.active_by_id: dict[str, TrashRequest] = {}
137
+ self.outcome_by_id: dict[str, TrashOutcome] = {}
138
+
139
+ def prepare(self, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...], callback: Callable[[TrashRequest | None, Exception | None], None]) -> str:
140
+ """Capture validated file identities while holding a shared preparation reservation.
141
+
142
+ A refused reservation gives the real reason, worked out right away so it describes the state that refused it:
143
+ another operation is named only when a write really holds the repository.
144
+ """
145
+ operation_id = uuid.uuid4().hex
146
+ if not self.mutation.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
147
+ cause = self.mutation.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
148
+ QTimer.singleShot(0, lambda: callback(None, ValueError(cause)))
149
+ return operation_id
150
+
151
+ def complete(request: TrashRequest | None, error: Exception | None) -> None:
152
+ """Release calculation ownership while the user reviews the captured paths."""
153
+ self.mutation.release(snapshot.key, operation_id)
154
+ if error is None:
155
+ self.prepared_by_id[operation_id] = request
156
+ callback(request, error)
157
+
158
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self._prepare(operation_id, snapshot, entries), complete, operation_id=operation_id)
159
+ return operation_id
160
+
161
+ def _prepare(self, operation_id: str, snapshot: WorktreeSnapshot, entries: tuple[LocalEntry, ...]) -> Generator:
162
+ """Check the selection against the index bytes and files of the displayed read, then capture each file to move.
163
+
164
+ This only prepares the confirmation: the index bytes and selected files are compared without a process before
165
+ and after, the tracked paths come from the index records of the read, and Git itself checks HEAD, configuration
166
+ and attributes again before the first move.
167
+ """
168
+ if not snapshot.fresh or not entries or any(entry not in snapshot.entries for entry in entries):
169
+ raise ValueError("The selected changes are stale; Refresh and select them again")
170
+ paths = tuple(dict.fromkeys(entry.path for entry in entries))
171
+ yield FileTask(lambda: verify_local_state(snapshot, paths))
172
+ index_entries = snapshot.index_entries
173
+ if index_entries is None:
174
+ # A read that kept no index records lists the index once. The local checks before and after tie that
175
+ # listing to the index bytes of the read.
176
+ index_entries = yield GitCommand(["ls-files", "--stage", "-z"])
177
+ tracked_path_set = tracked_trash_path_set(index_entries)
178
+ targets: list[TrashTarget] = []
179
+ entry_by_path: dict[bytes, LocalEntry] = {}
180
+ for entry in entries:
181
+ entry_by_path[entry.path] = entry
182
+ for path in sorted(entry_by_path):
183
+ entry = entry_by_path[path]
184
+ if entry.old_mode == "160000" or entry.new_mode == "160000":
185
+ raise ValueError("A submodule directory cannot be moved to Trash as a file")
186
+ filename = yield FileTask(lambda path=entry.path: trash_filename(snapshot.context, path))
187
+ image = yield FileTask(lambda path=entry.path: capture_image(snapshot.context, path))
188
+ if not image.exists:
189
+ raise ValueError("The selected file is absent; Refresh and select the changes again")
190
+ targets.append(TrashTarget(entry.path, filename, image, entry.path in tracked_path_set))
191
+ yield FileTask(lambda: verify_local_state(snapshot, paths))
192
+ return TrashRequest(operation_id, snapshot, tuple(targets))
193
+
194
+ def cancel(self, request: TrashRequest) -> None:
195
+ """Forget a declined confirmation without touching any working path."""
196
+ self.prepared_by_id.pop(request.operation_id, None)
197
+
198
+ def submit(self, request: TrashRequest) -> str:
199
+ """Accept each captured intention at most once and reserve until the actual result.
200
+
201
+ A refused reservation moves nothing and gives the real reason, worked out right away so it describes the state
202
+ that refused it.
203
+ """
204
+ operation_id = request.operation_id
205
+ if operation_id in self.active_by_id or operation_id in self.outcome_by_id:
206
+ return operation_id
207
+ if self.prepared_by_id.pop(operation_id, None) is not request:
208
+ raise ValueError("This trash confirmation is no longer current")
209
+ snapshot = request.snapshot
210
+ if not self.mutation.acquire(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT):
211
+ cause = self.mutation.acquire_refusal_cause(snapshot, operation_id, action=OperationAction.WORKTREE_EDIT)
212
+ outcome = self._outcome(request, OperationStatus.REFUSED, request.targets, None, cause)
213
+ self.outcome_by_id[operation_id] = outcome
214
+ QTimer.singleShot(0, lambda: self.finished.emit(outcome))
215
+ return operation_id
216
+ self.active_by_id[operation_id] = request
217
+
218
+ def complete(outcome: TrashOutcome | None, error: Exception | None) -> None:
219
+ """Release ownership only once both OS work and the final read have returned."""
220
+ if error is not None:
221
+ outcome = self._outcome(request, OperationStatus.UNKNOWN, request.targets, None, str(error))
222
+ self.active_by_id.pop(operation_id, None)
223
+ self.outcome_by_id[operation_id] = outcome
224
+ self.mutation.release(snapshot.key, operation_id)
225
+ self.finished.emit(outcome)
226
+ self.mutation.reread_requested.emit(snapshot.key)
227
+
228
+ self.worktree_service.run(snapshot.context, snapshot.git_filename, self._execute(request), complete, operation_id=operation_id)
229
+ return operation_id
230
+
231
+ def _execute(self, request: TrashRequest) -> Generator:
232
+ """Have Git verify the confirmed state, move in a worker, and report the complete state read afterwards.
233
+
234
+ The verification needs no complete read: it compares HEAD, index, configuration and attributes with fresh Git
235
+ reads, and each file is checked again immediately before its own move.
236
+ """
237
+ snapshot = request.snapshot
238
+ targets = request.targets
239
+ cause = ""
240
+ status = OperationStatus.REFUSED
241
+ try:
242
+ yield from self.worktree_service.verify(snapshot, tuple(target.path for target in targets))
243
+ for target in targets:
244
+ yield FileTask(lambda target=target: validate_image(snapshot.context, target.path, target.image))
245
+ results: list[TrashTarget] = []
246
+ status = OperationStatus.SUCCESS
247
+ for target in targets:
248
+ waiting_targets = tuple(results) + (replace(target, status=TrashTargetState.PENDING),) + targets[len(results) + 1:]
249
+ timer = QTimer(self)
250
+ timer.setSingleShot(True)
251
+ timer.timeout.connect(lambda waiting_targets=waiting_targets: self._waiting(request, waiting_targets))
252
+ timer.start(TRASH_TIMEOUT_MS)
253
+ try:
254
+ result = yield FileTask(lambda target=target: self._move(snapshot.context, target))
255
+ finally:
256
+ timer.stop()
257
+ timer.deleteLater()
258
+ results.append(result)
259
+ if result.status != TrashTargetState.MOVED:
260
+ cause = result.cause
261
+ status = OperationStatus.PARTIAL if any(previous.status == TrashTargetState.MOVED for previous in results) else OperationStatus.FAILED
262
+ results.extend(targets[len(results):])
263
+ break
264
+ targets = tuple(results)
265
+ except (OSError, ValueError, RuntimeError) as error:
266
+ cause = str(error)
267
+ after = None
268
+ try:
269
+ after = yield from self.worktree_service.capture(snapshot.context, snapshot.git_filename)
270
+ except (OSError, ValueError, RuntimeError) as error:
271
+ cause += " The result could not be refreshed: " + str(error)
272
+ status = OperationStatus.UNKNOWN
273
+ return self._outcome(request, status, targets, after, cause)
274
+
275
+ def _move(self, context: RepositoryContext, target: TrashTarget) -> TrashTarget:
276
+ """Validate immediately before the native call without following a leaf symlink."""
277
+ started = False
278
+ try:
279
+ self.mutation.validate_write(WorktreeKey.from_context(context))
280
+ validate_image(context, target.path, target.image)
281
+ started = True
282
+ self.move_file_to_trash(target.filename)
283
+ return replace(target, status=TrashTargetState.MOVED)
284
+ except (OSError, ValueError, RuntimeError) as error:
285
+ # A failed native response cannot prove that a now-absent path was never moved.
286
+ state = TrashTargetState.UNKNOWN if started and not os.path.lexists(target.filename) else TrashTargetState.FAILED
287
+ return replace(target, status=state, cause=str(error))
288
+
289
+ def _waiting(self, request: TrashRequest, targets: tuple[TrashTarget, ...]) -> None:
290
+ """Report a presentation deadline while leaving the OS call and reservation active."""
291
+ if request.operation_id not in self.active_by_id:
292
+ return
293
+ outcome = self._outcome(request, OperationStatus.PENDING, targets, None, "Waiting for the OS Trash operation to finish. Its result is still unknown; do not repeat the operation.")
294
+ self.waiting.emit(replace(outcome, finished=False))
295
+
296
+ def _outcome(self, request: TrashRequest, status: OperationStatus, targets: tuple[TrashTarget, ...], snapshot: WorktreeSnapshot | None, cause: str) -> TrashOutcome:
297
+ """Prepare a readable per-path result without claiming that a batch can be undone."""
298
+ diagnostic = None
299
+ if cause:
300
+ details = "\n".join(target.path.decode("utf-8", "replace") + ": " + target.status.value + (" — " + target.cause if target.cause else "") for target in targets)
301
+ diagnostic = Diagnostic("Move to Trash", request.snapshot.context.repository_dir, request.snapshot.git_filename, [], cause + "\n" + details).prepare()
302
+ return TrashOutcome(request.operation_id, request.snapshot.key, status, targets, True, snapshot, diagnostic)