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,138 @@
1
+ """Share typed historical-file intentions across the two repository file lists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import Signal
8
+ from PySide6.QtWidgets import QMenu
9
+
10
+ from gitpane.history.repository_value import FilePanel, RevisionEntry
11
+ from gitpane.ui.emoji.file_action_icon import file_action_icon
12
+ from gitpane.worktree.file_action_target import FileActionTargetKind
13
+ from gitpane.worktree.file_menu_action import FileAction
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable
17
+
18
+ from PySide6.QtWidgets import QWidget
19
+
20
+ from gitpane.worktree.file_action_target import FileActionSelection, FileRestoreTarget
21
+
22
+
23
+ class FileActionMenu(QMenu):
24
+ """Validate captured selection again before emitting a shared file intention."""
25
+
26
+ history_requested = Signal(object)
27
+ compare_requested = Signal(object)
28
+ filename_requested = Signal(object)
29
+ absolute_filename_requested = Signal(object)
30
+ folder_requested = Signal(object)
31
+ text_editor_requested = Signal(object)
32
+ save_requested = Signal(object)
33
+ restore_requested = Signal(object, object)
34
+ remove_history_requested = Signal(object)
35
+
36
+ def __init__(self, selection: FileActionSelection, validate: Callable[[FileActionSelection], bool], parent: QWidget,
37
+ restore_targets: list[FileRestoreTarget] | None = None, text_editor_reason: Callable[[FileActionSelection], str] | None = None,
38
+ remove_history_reason: Callable[[FileActionSelection], str] | None = None) -> None:
39
+ """Share validated file actions and offer external comparison for a Diff entry.
40
+
41
+ The text editor reason comes from the file action controller, which knows the preferences and the disk; a non-empty
42
+ reason greys the entry and shows why.
43
+ """
44
+ super().__init__(parent)
45
+ self.selection = selection
46
+ self.validate = validate
47
+ self.remove_history_reason = remove_history_reason
48
+ self.history_removal_requested = False
49
+ if selection.kind == FileActionTargetKind.FOLDER:
50
+ self.restore_menu = None
51
+ self.addAction(file_action_icon(FileAction.COPY_BASENAME, self), FileAction.COPY_BASENAME.label, lambda: self.request(self.filename_requested))
52
+ self.addAction(file_action_icon(FileAction.COPY_ABSOLUTE, self), FileAction.COPY_ABSOLUTE.label, lambda: self.request(self.absolute_filename_requested))
53
+ self._add_history_removal()
54
+ return
55
+ self.restore_menu = self.addMenu(FileAction.RESET_TO.label) if restore_targets is not None else None
56
+ self.set_restore_targets(restore_targets or [])
57
+ self.addAction(file_action_icon(FileAction.DISPLAY_HISTORY, self), FileAction.DISPLAY_HISTORY.label, lambda: self.request(self.history_requested))
58
+ if selection.panel_kind == FilePanel.DIFF:
59
+ self.addAction(file_action_icon(FileAction.COMPARE, self), FileAction.COMPARE.label, lambda: self.request(self.compare_requested))
60
+ self.addAction(file_action_icon(FileAction.FOLDER, self), FileAction.FOLDER.label, lambda: self.request(self.folder_requested))
61
+ text_editor_action = self.addAction(file_action_icon(FileAction.TEXT_EDITOR, self), FileAction.TEXT_EDITOR.label, lambda: self.request(self.text_editor_requested))
62
+ text_editor_unavailable = text_editor_reason(selection) if text_editor_reason is not None else ""
63
+ if text_editor_unavailable:
64
+ text_editor_action.setEnabled(False)
65
+ text_editor_action.setToolTip(text_editor_unavailable)
66
+ self.setToolTipsVisible(True)
67
+ self.addAction(file_action_icon(FileAction.COPY_BASENAME, self), FileAction.COPY_BASENAME.label, lambda: self.request(self.filename_requested))
68
+ self.addAction(file_action_icon(FileAction.COPY_ABSOLUTE, self), FileAction.COPY_ABSOLUTE.label, lambda: self.request(self.absolute_filename_requested))
69
+ action = self.addAction(file_action_icon(FileAction.SAVE_AS, self), FileAction.SAVE_AS.label, lambda: self.request(self.save_requested))
70
+ entry = selection.entry
71
+ mode = (entry.new_mode if entry.new_blob_oid else entry.old_mode) if isinstance(entry, RevisionEntry) else entry.mode
72
+ if mode == "160000":
73
+ action.setEnabled(False)
74
+ action.setToolTip("A submodule points to a commit, not a file blob.")
75
+ self.setToolTipsVisible(True)
76
+ self._add_history_removal()
77
+
78
+ def _add_history_removal(self) -> None:
79
+ """Keep the history mutation last, with a reason when the repository is unavailable."""
80
+ self.addSeparator()
81
+ self.remove_history_action = self.addAction(file_action_icon(FileAction.REMOVE_FROM_HISTORY, self), FileAction.REMOVE_FROM_HISTORY.label, self.request_remove_history)
82
+ reason = self.remove_history_reason(self.selection) if self.remove_history_reason is not None else ""
83
+ self.remove_history_action.setEnabled(not reason)
84
+ self.remove_history_action.setToolTip(reason)
85
+ self.setToolTipsVisible(True)
86
+ for action in self.actions():
87
+ action.setIconVisibleInMenu(True)
88
+
89
+ def request(self, signal: Signal) -> None:
90
+ """Ignore commands from menus whose owning selection has been invalidated."""
91
+ if self.validate(self.selection):
92
+ signal.emit(self.selection)
93
+
94
+ def request_remove_history(self) -> None:
95
+ """Recheck availability at activation and emit at most one removal per menu."""
96
+ if self.history_removal_requested or not self.validate(self.selection):
97
+ return
98
+ reason = self.remove_history_reason(self.selection) if self.remove_history_reason is not None else ""
99
+ if reason:
100
+ self.remove_history_action.setEnabled(False)
101
+ self.remove_history_action.setToolTip(reason)
102
+ return
103
+ self.history_removal_requested = True
104
+ self.remove_history_action.setEnabled(False)
105
+ self.request(self.remove_history_requested)
106
+
107
+ def set_restore_targets(self, targets: list[FileRestoreTarget]) -> None:
108
+ """Update parent metadata in place so an open menu retains its actions and order."""
109
+ if self.restore_menu is None:
110
+ return
111
+ icon = file_action_icon(FileAction.RESET_TO, self.restore_menu)
112
+ icon_size = next(iter(icon.availableSizes()))
113
+ normal_pixmap_key = icon.pixmap(icon_size).cacheKey()
114
+ menu_action = self.restore_menu.menuAction()
115
+ if menu_action.icon().pixmap(icon_size).cacheKey() != normal_pixmap_key:
116
+ menu_action.setIcon(icon)
117
+ menu_action.setIconVisibleInMenu(True)
118
+ actions = self.restore_menu.actions()
119
+ if len(actions) != len(targets):
120
+ self.restore_menu.clear()
121
+ for target in targets:
122
+ action = self.restore_menu.addAction(target.label)
123
+ action.triggered.connect(lambda checked=False, action=action: self.request_restore(action.data()))
124
+ actions = self.restore_menu.actions()
125
+ for action, target in zip(actions, targets, strict=True):
126
+ if action.icon().pixmap(icon_size).cacheKey() != normal_pixmap_key:
127
+ action.setIcon(icon)
128
+ action.setIconVisibleInMenu(True)
129
+ action.setText(target.label.replace("&", "&&"))
130
+ action.setData(target)
131
+ action.setToolTip(target.unavailable or target.message)
132
+ action.setEnabled(not target.unavailable)
133
+ self.restore_menu.setToolTipsVisible(True)
134
+
135
+ def request_restore(self, target: FileRestoreTarget) -> None:
136
+ """Emit the selected source only while its captured file selection remains current."""
137
+ if not target.unavailable and self.validate(self.selection):
138
+ self.restore_requested.emit(self.selection, target)
@@ -0,0 +1,235 @@
1
+ """Browse historical Git paths beside a literal, searchable content reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from time import monotonic
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QEvent, QSize, Qt, QTimer, Signal
9
+ from PySide6.QtGui import QFontDatabase, QTextCursor
10
+ from PySide6.QtWidgets import QPlainTextEdit, QSplitter, QStyle, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator, QVBoxLayout, QWidget
11
+
12
+ from gitpane.config import DIFF_FILE_STRETCH, DIFF_TEXT_STRETCH, FILE_PARSE_BUDGET_MS, FILE_TEXT_BATCH_CHARS, LAYOUT_SPACING
13
+ from gitpane.ui.reader.commit_message import CommitMessage
14
+ from gitpane.ui.reader.reader_toolbar import ReaderToolbar
15
+ from gitpane.ui.reader.revision_tree import FOLDER_PATH_ROLE
16
+ from gitpane.ui.reader.source_view import SourceFileView
17
+
18
+ if TYPE_CHECKING:
19
+ from gitpane.history.repository_value import BlobContent, FilePanel, TreeEntry
20
+
21
+
22
+ class FileTreePanel(QWidget):
23
+ """Keep revision objects and their original identity beside read-only text."""
24
+
25
+ selection_requested = Signal(object)
26
+ retry_requested = Signal(object)
27
+
28
+ def __init__(self) -> None:
29
+ """Build a compact tree and reuse the literal reader's local search controls."""
30
+ super().__init__()
31
+ self.entries: list[TreeEntry] = []
32
+ self.item_by_entry: dict[TreeEntry, QTreeWidgetItem] = {}
33
+ self.selected_entry: TreeEntry | None = None
34
+ self.content: BlobContent | None = None
35
+ self.render_generation = 0
36
+ self.tree_generation = 0
37
+ self.action_generation = 0
38
+ self.retry_kind: FilePanel | None = None
39
+ layout = QVBoxLayout(self)
40
+ layout.setContentsMargins(0, 0, 0, 0)
41
+ layout.setSpacing(LAYOUT_SPACING)
42
+ self.reader_toolbar = ReaderToolbar("Find in historical file")
43
+ self.status_label = self.reader_toolbar.status_label
44
+ self.status_label.setText("Select a commit")
45
+ self.retry_button = self.reader_toolbar.retry_button
46
+ self.retry_button.clicked.connect(lambda: self.retry_requested.emit(self.retry_kind))
47
+ self.file_tree = QTreeWidget()
48
+ self.file_tree.setHeaderHidden(True)
49
+ self.file_tree.setUniformRowHeights(True)
50
+ self.file_tree.setAccessibleName("Revision files")
51
+ self.reader = CommitMessage(SourceFileView())
52
+ self.reader.find_edit.setPlaceholderText("Find in this file")
53
+ self.reader.find_edit.setAccessibleName("Find in historical file")
54
+ self.text_edit = self.reader.text_edit
55
+ self.text_edit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
56
+ self.text_edit.setAccessibleName("Historical file content")
57
+ self.text_edit.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
58
+ self.reader_toolbar.find_button.clicked.connect(self.open_search)
59
+ self.reader_container = QWidget()
60
+ reader_layout = QVBoxLayout(self.reader_container)
61
+ reader_layout.setContentsMargins(0, 0, 0, 0)
62
+ reader_layout.setSpacing(LAYOUT_SPACING)
63
+ reader_layout.addWidget(self.reader_toolbar)
64
+ reader_layout.addWidget(self.reader, 1)
65
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
66
+ self.splitter.setChildrenCollapsible(False)
67
+ self.splitter.addWidget(self.file_tree)
68
+ self.splitter.addWidget(self.reader_container)
69
+ file_index, text_index = 0, 1
70
+ self.splitter.setStretchFactor(file_index, DIFF_FILE_STRETCH)
71
+ self.splitter.setStretchFactor(text_index, DIFF_TEXT_STRETCH)
72
+ layout.addWidget(self.splitter, 1)
73
+ self.file_tree.currentItemChanged.connect(self._select_item)
74
+ self.file_tree.currentItemChanged.connect(self._invalidate_action)
75
+ self.refresh_metrics()
76
+
77
+ def refresh_metrics(self) -> None:
78
+ """Scale native file and folder symbols with the tree's current font."""
79
+ side = max(self.file_tree.fontMetrics().height(), self.style().pixelMetric(QStyle.PixelMetric.PM_SmallIconSize))
80
+ self.file_tree.setIconSize(QSize(side, side))
81
+ self.file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon)
82
+ self.folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)
83
+ iterator = QTreeWidgetItemIterator(self.file_tree)
84
+ while iterator.value() is not None:
85
+ item = iterator.value()
86
+ item.setIcon(0, self.file_icon if item.data(0, Qt.ItemDataRole.UserRole) is not None else self.folder_icon)
87
+ iterator += 1
88
+
89
+ def changeEvent(self, event: QEvent) -> None:
90
+ """Follow system font, palette and style changes without rebuilding file identities."""
91
+ super().changeEvent(event)
92
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.ApplicationFontChange, QEvent.Type.PaletteChange, QEvent.Type.StyleChange) and hasattr(self, "file_tree"):
93
+ self.refresh_metrics()
94
+
95
+ def _invalidate_action(self) -> None:
96
+ self.action_generation += 1
97
+
98
+ def clear_selection(self, message: str = "Select a commit") -> None:
99
+ """Remove old paths and text immediately when the commit changes."""
100
+ self.reader_toolbar.finish_loading()
101
+ self.selected_entry = None
102
+ self.retry_button.hide()
103
+ self.retry_kind = None
104
+ self.action_generation += 1
105
+ self.render_generation += 1
106
+ self.tree_generation += 1
107
+ self.content = None
108
+ self.entries.clear()
109
+ self.item_by_entry.clear()
110
+ self.file_tree.clear()
111
+ self.text_edit.clear()
112
+ self.status_label.setText(message)
113
+ self.status_label.setVisible(bool(message))
114
+
115
+ def show_tree(self, entries: list[TreeEntry]) -> None:
116
+ """Build explicitly sorted folders without using displayed names as identity."""
117
+ self.clear_selection("Select a file" if entries else "Empty tree")
118
+ self.entries = list(entries)
119
+ item_by_path: dict[bytes, QTreeWidgetItem] = {}
120
+ pending = iter(sorted(entries, key=lambda entry: entry.path))
121
+ generation = self.tree_generation
122
+
123
+ def append_entries() -> None:
124
+ """Limit Qt row creation per turn and stop a superseded tree projection."""
125
+ if generation != self.tree_generation:
126
+ return
127
+ # PySide does not invalidate this native root when its tree is deleted; the recursive callback must not capture its wrapper.
128
+ root = self.file_tree.invisibleRootItem()
129
+ deadline = monotonic() + FILE_PARSE_BUDGET_MS / 1000
130
+ while monotonic() < deadline:
131
+ entry = next(pending, None)
132
+ if entry is None:
133
+ for parent in (root, *item_by_path.values()):
134
+ children = parent.takeChildren()
135
+ children.sort(key=lambda item: (item.data(0, Qt.ItemDataRole.UserRole) is not None, item.text(0).casefold(), item.text(0)))
136
+ parent.addChildren(children)
137
+ return
138
+ components = entry.path.split(b"/")
139
+ basename = components.pop()
140
+ parent = root
141
+ directory_parts = []
142
+ for component in components:
143
+ directory_parts.append(component)
144
+ path = b"/".join(directory_parts)
145
+ item = item_by_path.get(path)
146
+ if item is None:
147
+ item = QTreeWidgetItem([component.decode("utf-8", "replace")])
148
+ item.setData(0, FOLDER_PATH_ROLE, path)
149
+ item.setToolTip(0, path.decode("utf-8", "replace"))
150
+ item.setIcon(0, self.folder_icon)
151
+ parent.addChild(item)
152
+ item_by_path[path] = item
153
+ parent = item
154
+ item = QTreeWidgetItem([basename.decode("utf-8", "replace")])
155
+ item.setIcon(0, self.file_icon)
156
+ item.setToolTip(0, entry.path.decode("utf-8", "replace"))
157
+ item.setData(0, Qt.ItemDataRole.UserRole, entry)
158
+ parent.addChild(item)
159
+ self.item_by_entry[entry] = item
160
+ QTimer.singleShot(0, append_entries)
161
+
162
+ append_entries()
163
+
164
+ def show_blob(self, content: BlobContent) -> None:
165
+ """Present the display representation while retaining exact bytes separately."""
166
+ self.reader_toolbar.finish_loading()
167
+ self.retry_button.hide()
168
+ self.retry_kind = None
169
+ self.render_generation += 1
170
+ generation = self.render_generation
171
+ self.content = content
172
+ self.text_edit.clear()
173
+ self.text_edit.set_filename(self.selected_entry.path.decode("utf-8", "replace") if self.selected_entry is not None else "")
174
+ diagnostic = content.diagnostic
175
+ if self.selected_entry is not None and self.selected_entry.mode == "120000":
176
+ diagnostic = "Symbolic link — historical target" + (f"; {diagnostic}" if diagnostic else "")
177
+ self.status_label.setText(diagnostic)
178
+ self.status_label.setVisible(bool(diagnostic))
179
+ offset = 0
180
+
181
+ def append_text() -> None:
182
+ """Yield during document insertion and abandon any superseded render."""
183
+ nonlocal offset
184
+ if generation != self.render_generation:
185
+ return
186
+ deadline = monotonic() + FILE_PARSE_BUDGET_MS / 1000
187
+ while offset < len(content.display_text) and monotonic() < deadline:
188
+ end = offset + FILE_TEXT_BATCH_CHARS
189
+ if content.display_text[end - 1:end + 1] == "\r\n":
190
+ end += 1
191
+ self.text_edit.append_source_text(content.display_text[offset:end])
192
+ offset = end
193
+ if offset < len(content.display_text):
194
+ QTimer.singleShot(0, append_text)
195
+ return
196
+ self.text_edit.moveCursor(QTextCursor.MoveOperation.Start)
197
+ self.text_edit.refresh_syntax()
198
+
199
+ append_text()
200
+
201
+ def show_submodule(self, oid: str) -> None:
202
+ """Describe a gitlink without trying to resolve its object in the parent repository."""
203
+ self.show_error("Submodule commit")
204
+ self.text_edit.setPlainText(oid)
205
+
206
+ def show_error(self, cause: str, retry_kind: FilePanel | None = None) -> None:
207
+ """Clear misleading text and keep an unavailable object diagnostic visible."""
208
+ self.reader_toolbar.finish_loading()
209
+ self.retry_kind = retry_kind
210
+ self.retry_button.setVisible(retry_kind is not None)
211
+ self.content = None
212
+ self.render_generation += 1
213
+ self.text_edit.clear()
214
+ self.status_label.setText(cause)
215
+ self.status_label.show()
216
+
217
+ def begin_loading(self) -> None:
218
+ """Discard old file text immediately while keeping fast reads visually quiet."""
219
+ self.content = None
220
+ self.render_generation += 1
221
+ self.text_edit.clear()
222
+ self.retry_kind = None
223
+ self.reader_toolbar.start_loading("Loading file…")
224
+
225
+ def open_search(self) -> None:
226
+ """Focus search in the displayed historical file."""
227
+ self.reader.open_search()
228
+
229
+ def _select_item(self, item: QTreeWidgetItem | None, previous: QTreeWidgetItem | None) -> None:
230
+ """Emit the original object value, never a path reconstructed from labels."""
231
+ if item is None:
232
+ return
233
+ entry = item.data(0, Qt.ItemDataRole.UserRole)
234
+ if entry is not None:
235
+ self.selection_requested.emit(entry)
@@ -0,0 +1,56 @@
1
+ """Keep the file actions of a text reader above it, with a loading notice only for slow reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Qt, QTimer
6
+ from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QWidget
7
+
8
+ from gitpane.config import FIND_SHORTCUT, LAYOUT_SPACING
9
+ from gitpane.ui.widget.shortcut_text import with_shortcut
10
+
11
+ READER_LOADING_DELAY_MS = 200
12
+
13
+
14
+ class ReaderToolbar(QWidget):
15
+ """Reserve one stable control row, showing progress only for slower reads."""
16
+
17
+ def __init__(self, search_description: str) -> None:
18
+ """Place contextual feedback before right-aligned reader commands."""
19
+ super().__init__()
20
+ self.controls = QHBoxLayout(self)
21
+ self.controls.setContentsMargins(0, 0, 0, 0)
22
+ self.controls.setSpacing(LAYOUT_SPACING)
23
+ self.status_label = QLabel()
24
+ self.status_label.setTextFormat(Qt.TextFormat.PlainText)
25
+ self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
26
+ self.controls.addWidget(self.status_label, 1)
27
+ self.controls.addStretch(1)
28
+ self.retry_button = QPushButton("Retry reading")
29
+ self.retry_button.hide()
30
+ self.controls.addWidget(self.retry_button)
31
+ self.find_button = QPushButton("Find")
32
+ self.find_button.setAccessibleName(search_description)
33
+ self.find_button.setToolTip(with_shortcut(search_description, FIND_SHORTCUT))
34
+ self.controls.addWidget(self.find_button)
35
+ self.loading_message = ""
36
+ self.loading_timer = QTimer(self)
37
+ self.loading_timer.setSingleShot(True)
38
+ self.loading_timer.setInterval(READER_LOADING_DELAY_MS)
39
+ self.loading_timer.timeout.connect(self._show_loading)
40
+
41
+ def start_loading(self, message: str) -> None:
42
+ """Delay transient progress without moving the reader or showing stale errors."""
43
+ self.finish_loading()
44
+ self.status_label.clear()
45
+ self.retry_button.hide()
46
+ self.loading_message = message
47
+ self.loading_timer.start()
48
+
49
+ def finish_loading(self) -> None:
50
+ """Cancel progress publication when a result or a newer selection takes over."""
51
+ self.loading_timer.stop()
52
+ self.loading_message = ""
53
+
54
+ def _show_loading(self) -> None:
55
+ self.status_label.setText(self.loading_message)
56
+ self.status_label.show()
@@ -0,0 +1,80 @@
1
+ """Display compact revision paths in a tree drawn with native disclosure controls over dotted guides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PySide6.QtCore import Qt
8
+ from PySide6.QtGui import QPalette, QPen
9
+ from PySide6.QtWidgets import QTreeWidget
10
+
11
+ from gitpane.ui.look.theme_color import is_dark_palette
12
+
13
+ if TYPE_CHECKING:
14
+ from PySide6.QtCore import QModelIndex, QRect
15
+ from PySide6.QtGui import QPainter
16
+ from PySide6.QtWidgets import QTreeWidgetItem
17
+
18
+ BRANCH_DISCLOSURE_GAP = 5
19
+ FOLDER_PATH_ROLE = Qt.ItemDataRole.UserRole + 1
20
+
21
+
22
+ class RevisionTree(QTreeWidget):
23
+ """Keep file-to-folder relationships visible across native platform themes."""
24
+
25
+ def drawBranches(self, painter: QPainter, rectangle: QRect, index: QModelIndex) -> None:
26
+ """Draw dotted ancestor guides while retaining Qt's expand and collapse arrows.
27
+
28
+ A light theme draws them in the palette's mid tone. A dark one draws them in the view's text color: the dark mid
29
+ tone is barely darker or lighter than the dark background of a view, and the guides would not show.
30
+ """
31
+ palette = self.palette()
32
+ guide_role = QPalette.ColorRole.Text if is_dark_palette(palette) else QPalette.ColorRole.Mid
33
+ painter.save()
34
+ painter.setPen(QPen(palette.color(guide_role), 1, Qt.PenStyle.DotLine))
35
+ indentation = self.indentation()
36
+ center_x = rectangle.right() - indentation // 2
37
+ center_y = rectangle.center().y()
38
+ gap = BRANCH_DISCLOSURE_GAP if self.model().hasChildren(index) else 0
39
+ painter.drawLine(center_x + gap, center_y, rectangle.right(), center_y)
40
+ if index.row() > 0 or index.parent().isValid():
41
+ painter.drawLine(center_x, rectangle.top(), center_x, center_y - gap)
42
+ if index.row() + 1 < self.model().rowCount(index.parent()):
43
+ painter.drawLine(center_x, center_y + gap, center_x, rectangle.bottom())
44
+ ancestor = index.parent()
45
+ while ancestor.isValid():
46
+ center_x -= indentation
47
+ if ancestor.row() + 1 < self.model().rowCount(ancestor.parent()):
48
+ painter.drawLine(center_x, rectangle.top(), center_x, rectangle.bottom())
49
+ ancestor = ancestor.parent()
50
+ painter.restore()
51
+ super().drawBranches(painter, rectangle, index)
52
+
53
+
54
+ def compact_directory_tree(group: QTreeWidgetItem) -> None:
55
+ """Join single-directory chains with slashes while leaving their last folder separate."""
56
+ first_child_index = 0
57
+ pending_items = group.takeChildren()
58
+ group.addChildren(pending_items)
59
+ while pending_items:
60
+ item = pending_items.pop()
61
+ if item.data(first_child_index, Qt.ItemDataRole.UserRole) is not None:
62
+ continue
63
+ while item.childCount() == 1:
64
+ child = item.child(first_child_index)
65
+ if child.data(first_child_index, Qt.ItemDataRole.UserRole) is not None:
66
+ break
67
+ has_directory = False
68
+ for child_index in range(child.childCount()):
69
+ if child.child(child_index).data(first_child_index, Qt.ItemDataRole.UserRole) is None:
70
+ has_directory = True
71
+ break
72
+ if not has_directory:
73
+ break
74
+ child = item.takeChild(first_child_index)
75
+ item.setText(first_child_index, item.text(first_child_index) + "/" + child.text(first_child_index))
76
+ item.setToolTip(first_child_index, child.toolTip(first_child_index))
77
+ item.setData(first_child_index, FOLDER_PATH_ROLE, child.data(first_child_index, FOLDER_PATH_ROLE))
78
+ item.addChildren(child.takeChildren())
79
+ for child_index in range(item.childCount()):
80
+ pending_items.append(item.child(child_index))
@@ -0,0 +1,165 @@
1
+ """Show the wildcard matches of a reader, counted then highlighted, without rescanning unchanged text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_left
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PySide6.QtCore import QEvent, QObject
9
+ from PySide6.QtGui import QColor, QPalette, QTextCursor
10
+ from PySide6.QtWidgets import QApplication, QLabel, QTextEdit
11
+
12
+ from gitpane.history.revision_file import utf16_length
13
+ from gitpane.text.text_query import TextQuery
14
+ from gitpane.ui.reader.source_text import SourceTextEdit
15
+
16
+ if TYPE_CHECKING:
17
+ from PySide6.QtGui import QTextDocument
18
+
19
+ SEARCH_MATCH_BACKGROUND = "#fff176"
20
+ SEARCH_MATCH_FOREGROUND = "#202020"
21
+
22
+
23
+ # The class whose palette the commit graph and the other lists paint their selected rows with. Under macOS it holds the
24
+ # accent blue (#0640c6 in dark), where text controls get a muted text selection blue (#314f78); elsewhere no class
25
+ # palette exists and Qt answers the application palette, the one text controls already use.
26
+ LIST_SELECTION_PALETTE_CLASS = "QAbstractItemView"
27
+ SELECTION_ROLES = (QPalette.ColorRole.Highlight, QPalette.ColorRole.HighlightedText)
28
+ SELECTION_GROUPS = (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive)
29
+
30
+
31
+ def use_list_selection_colors(text_edit: QTextEdit | SourceTextEdit) -> bool:
32
+ """Paint a reader's selection, the current match, like a selected row of the commit graph, focused or not; answer whether the palette had to change.
33
+
34
+ Starts from the reader's own palette, which pins only the colors set here, so a later light/dark switch still
35
+ reaches its other colors. A palette that already has them is left alone, so no palette change is raised for nothing.
36
+ """
37
+ list_palette = QApplication.palette(LIST_SELECTION_PALETTE_CLASS)
38
+ palette = text_edit.palette()
39
+ changed = False
40
+ for role in SELECTION_ROLES:
41
+ list_color = list_palette.color(QPalette.ColorGroup.Active, role)
42
+ for group in SELECTION_GROUPS:
43
+ if palette.color(group, role) == list_color and palette.isBrushSet(group, role):
44
+ continue
45
+ palette.setColor(group, role, list_color)
46
+ changed = True
47
+ if changed:
48
+ text_edit.setPalette(palette)
49
+ return changed
50
+
51
+
52
+ class ActiveSelectionKeeper(QObject):
53
+ """Keep a searched reader's selection, the current match, in the selected-row blue of the lists, also after a light/dark switch.
54
+
55
+ The focus stays in the find field while the user goes from match to match, so the reader would paint its selection
56
+ with the colors of an inactive widget, grey in a dark Fusion theme, over the current match; and the text selection
57
+ blue of macOS is too dull next to the yellow of the other matches. The reader takes the selection colors of the
58
+ lists for both groups, and takes them again each time its palette changes.
59
+ """
60
+
61
+ def __init__(self, text_edit: QTextEdit | SourceTextEdit) -> None:
62
+ """Belong to the reader and follow its palette notices."""
63
+ super().__init__(text_edit)
64
+ use_list_selection_colors(text_edit)
65
+ text_edit.installEventFilter(self)
66
+
67
+ def eventFilter(self, watched: QObject, event: QEvent) -> bool:
68
+ """Set the selection colors again when the reader's palette changes.
69
+
70
+ When they had to change, setting them raised a palette notice of its own, already delivered with the final
71
+ colors; this older notice is stopped so the reader does not lay its look twice.
72
+ """
73
+ if event.type() != QEvent.Type.PaletteChange:
74
+ return False
75
+ return use_list_selection_colors(watched)
76
+
77
+
78
+ class SearchResultLabel(QLabel):
79
+ """Share match totals and yellow highlights between commit, diff and file readers."""
80
+
81
+ def __init__(self) -> None:
82
+ """Invalidate the count whenever its query or document content changes."""
83
+ super().__init__()
84
+ self.document: QTextDocument | None = None
85
+ self.revision = -1
86
+ self.query = ""
87
+ self.match_count = 0
88
+ self.match_ranges: list[tuple[int, int]] = []
89
+ self.match_selections: list[QTextEdit.ExtraSelection] = []
90
+
91
+ def show_result(self, text_edit: QTextEdit | SourceTextEdit, query: str) -> None:
92
+ """Count local matches without letting wildcard gaps span whitespace or line breaks."""
93
+ if not query:
94
+ self.query = ""
95
+ self.match_count = 0
96
+ self.match_ranges.clear()
97
+ self.match_selections.clear()
98
+ self.clear()
99
+ return
100
+ document = text_edit.document()
101
+ revision = document.revision()
102
+ if self.document is not document or self.revision != revision or self.query != query:
103
+ self.document = document
104
+ self.revision = revision
105
+ self.query = query
106
+ self.match_ranges.clear()
107
+ self.match_selections.clear()
108
+ if isinstance(text_edit, SourceTextEdit):
109
+ self.match_ranges.extend(text_edit.find_match_range(query, wildcard=True, wildcard_whitespace=False))
110
+ else:
111
+ text = text_edit.toPlainText()
112
+ previous = 0
113
+ position = 0
114
+ for begin, end in TextQuery(query).find_match(text, wildcard_whitespace=False):
115
+ position += utf16_length(text[previous:begin])
116
+ self.match_ranges.append((position, position + utf16_length(text[begin:end])))
117
+ previous = begin
118
+ self.match_count = len(self.match_ranges)
119
+ if not self.match_count:
120
+ self.setText("No matches")
121
+ return
122
+ noun = "match" if self.match_count == 1 else "matches"
123
+ self.setText(f"{self.match_count} {noun} found")
124
+
125
+ def find_match(self, text_edit: QTextEdit | SourceTextEdit, query: str, start: int, backward: bool = False) -> QTextCursor:
126
+ """Navigate the same cached spans used by the total and yellow highlights."""
127
+ self.show_result(text_edit, query)
128
+ index = bisect_left(self.match_ranges, (start,))
129
+ if backward:
130
+ index -= 1
131
+ if index >= 0:
132
+ _begin, end = self.match_ranges[index]
133
+ if end > start:
134
+ index -= 1
135
+ if not 0 <= index < len(self.match_ranges):
136
+ return QTextCursor()
137
+ begin, end = self.match_ranges[index]
138
+ cursor = QTextCursor(text_edit.document())
139
+ cursor.setPosition(begin)
140
+ cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
141
+ return cursor
142
+
143
+ def highlight_selections(self, text_edit: QTextEdit | SourceTextEdit, query: str, current: QTextCursor | None = None) -> list[QTextEdit.ExtraSelection]:
144
+ """Mark all matches in yellow and keep the active result in the native selection color."""
145
+ if not query:
146
+ return []
147
+ self.show_result(text_edit, query)
148
+ if not self.match_selections:
149
+ for begin, end in self.match_ranges:
150
+ selection = QTextEdit.ExtraSelection()
151
+ selection.cursor = QTextCursor(text_edit.document())
152
+ selection.cursor.setPosition(begin)
153
+ selection.cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
154
+ selection.format.setBackground(QColor(SEARCH_MATCH_BACKGROUND))
155
+ selection.format.setForeground(QColor(SEARCH_MATCH_FOREGROUND))
156
+ self.match_selections.append(selection)
157
+ selections = list(self.match_selections)
158
+ if current is not None and current.hasSelection():
159
+ selection = QTextEdit.ExtraSelection()
160
+ selection.cursor = QTextCursor(current)
161
+ palette = text_edit.palette()
162
+ selection.format.setBackground(palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Highlight))
163
+ selection.format.setForeground(palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText))
164
+ selections.append(selection)
165
+ return selections