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,626 @@
1
+ """Browse historical changes in a compact, integrated reading panel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from math import sqrt
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pygments.lexers import get_lexer_for_filename
9
+ from pygments.styles import get_style_by_name
10
+ from pygments.util import ClassNotFound
11
+ from PySide6.QtCore import QEvent, QPointF, QRect, QRectF, QSize, Qt, Signal
12
+ from PySide6.QtGui import QColor, QFontDatabase, QIcon, QKeySequence, QPainter, QPainterPath, QPalette, QPen, QPixmap, QShortcut, QSyntaxHighlighter, QTextCharFormat, QTextCursor, QTextFormat
13
+ from PySide6.QtWidgets import QApplication, QCheckBox, QHBoxLayout, QLineEdit, QPlainTextEdit, QPushButton, QSplitter, QStyle, QTextEdit, QTreeWidgetItem, QTreeWidgetItemIterator, QVBoxLayout, QWidget
14
+
15
+ from gitpane.config import (
16
+ DARK_BACKGROUND_LIGHTNESS_LIMIT,
17
+ DIFF_FILE_STRETCH,
18
+ DIFF_ICON_PADDING_RATIO,
19
+ DIFF_ICON_RENAME_BADGE_AREA_RATIO,
20
+ DIFF_ICON_STROKE_RATIO,
21
+ DIFF_NUMBER_COLUMN_COUNT,
22
+ DIFF_SYNTAX_LIMIT_CHARS,
23
+ DIFF_TEXT_STRETCH,
24
+ LAYOUT_SPACING,
25
+ SharedText,
26
+ )
27
+ from gitpane.history.repository_value import FileStatus
28
+ from gitpane.history.revision_file import DiffDocument, DiffLineKind, decode_blob_display, parse_unified_diff, utf16_length
29
+ from gitpane.ui.look.icon_density import physical_icon_side
30
+ from gitpane.ui.look.theme_color import line_number_gutter_color
31
+ from gitpane.ui.reader.reader_toolbar import ReaderToolbar
32
+ from gitpane.ui.reader.revision_tree import FOLDER_PATH_ROLE, RevisionTree, compact_directory_tree
33
+ from gitpane.ui.reader.search_result import ActiveSelectionKeeper, SearchResultLabel
34
+ from gitpane.ui.reader.source_text import SourceTextEdit
35
+ from gitpane.ui.reader.syntax_span import SyntaxLine, syntax_spans
36
+ from gitpane.ui.widget.system_shortcut import FIND_NEXT_ACTION, FIND_PREVIOUS_ACTION, find_shortcuts
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Callable
40
+
41
+ from PySide6.QtGui import QPaintEvent, QResizeEvent
42
+
43
+ from gitpane.history.repository_value import ComparisonGroup, RevisionEntry
44
+
45
+ # Line backgrounds and changed-fragment accents of a diff, by line kind, in the light and in the dark theme. A kind
46
+ # missing from a table keeps the view's own background.
47
+ DIFF_BACKGROUND_BY_KIND: dict[DiffLineKind, str] = {DiffLineKind.ADDED: "#d4f8d4", DiffLineKind.REMOVED: "#ffd7d7", DiffLineKind.HUNK: "#e4eef5"}
48
+ DIFF_DARK_BACKGROUND_BY_KIND: dict[DiffLineKind, str] = {DiffLineKind.ADDED: "#183b28", DiffLineKind.REMOVED: "#4b2528", DiffLineKind.HUNK: "#253442"}
49
+ DIFF_FRAGMENT_BY_KIND: dict[DiffLineKind, str] = {DiffLineKind.ADDED: "#8ee59a", DiffLineKind.REMOVED: "#f4a0a0"}
50
+ DIFF_DARK_FRAGMENT_BY_KIND: dict[DiffLineKind, str] = {DiffLineKind.ADDED: "#286540", DiffLineKind.REMOVED: "#793a43"}
51
+ # Colour of the change symbol drawn for a file, by its status; any other status draws the modified-file symbol.
52
+ DIFF_ICON_COLOR_BY_STATUS: dict[FileStatus, str] = {FileStatus.ADDED: "#23963b", FileStatus.DELETED: "#c73939", FileStatus.MODIFIED: "#ba8614", FileStatus.RENAMED: "#e87500"}
53
+
54
+
55
+ class DiffHighlighter(QSyntaxHighlighter):
56
+ """Layer syntax foregrounds and replacement accents over line backgrounds."""
57
+
58
+ def __init__(self, view: DiffView) -> None:
59
+ """Bind formatting to a reader while keeping its patch unchanged."""
60
+ super().__init__(view.document())
61
+ self.view = view
62
+ self.lexer = None
63
+ self.token_spans_by_line_index: dict[int, SyntaxLine] = {}
64
+
65
+ def highlightBlock(self, text: str) -> None:
66
+ """Use UTF-16 offsets for syntax and intraline fragments, including emoji."""
67
+ block = self.currentBlock()
68
+ line_index = self.view.logical_line_index(block.position())
69
+ if line_index >= len(self.view.diff_document.lines):
70
+ return
71
+ line = self.view.diff_document.lines[line_index]
72
+ line_position = self.view.line_positions[line_index]
73
+ block_offset = block.position() - line_position
74
+ background = self.view.background_by_kind.get(line.kind)
75
+ if background is not None:
76
+ base_format = QTextCharFormat()
77
+ base_format.setBackground(QColor(background))
78
+ self.setFormat(0, utf16_length(text), base_format)
79
+ if self.lexer is not None and line.kind in (DiffLineKind.ADDED, DiffLineKind.REMOVED, DiffLineKind.CONTEXT) and len(line.text) <= DIFF_SYNTAX_LIMIT_CHARS:
80
+ prefix_length = 1
81
+ if line_index not in self.token_spans_by_line_index:
82
+ self.token_spans_by_line_index[line_index] = syntax_spans(line.text, self.lexer, self.view.syntax_style, prefix_length)
83
+ for start, length, syntax_format in self.token_spans_by_line_index[line_index].block_spans(block_offset, utf16_length(text)):
84
+ token_format = self.format(start)
85
+ token_format.merge(syntax_format)
86
+ self.setFormat(start, length, token_format)
87
+ for start, end in line.changed_spans:
88
+ fragment_start = max(0, utf16_length(line.text[:start]) - block_offset)
89
+ fragment_end = min(utf16_length(text), utf16_length(line.text[:end]) - block_offset)
90
+ for position in range(fragment_start, fragment_end):
91
+ fragment_format = self.format(position)
92
+ fragment_format.setBackground(QColor(self.view.fragment_by_kind[line.kind]))
93
+ self.setFormat(position, 1, fragment_format)
94
+
95
+
96
+ class DiffNumberMargin(QWidget):
97
+ """Paint old and new line numbers outside the selectable text document."""
98
+
99
+ def __init__(self, view: DiffView) -> None:
100
+ """Share scrolling and native font metrics with the reader."""
101
+ super().__init__(view)
102
+ self.view = view
103
+
104
+ def paintEvent(self, event: QPaintEvent) -> None:
105
+ """Render only the visible blocks so long patches do not require full repaint."""
106
+ painter = QPainter(self)
107
+ painter.fillRect(event.rect(), line_number_gutter_color(self.palette()))
108
+ painter.setFont(self.view.font())
109
+ block = self.view.firstVisibleBlock()
110
+ column_width = self.width() // DIFF_NUMBER_COLUMN_COUNT
111
+ while block.isValid():
112
+ top = round(self.view.blockBoundingGeometry(block).translated(self.view.contentOffset()).top())
113
+ height = round(self.view.blockBoundingRect(block).height())
114
+ if top > event.rect().bottom():
115
+ break
116
+ line_index = self.view.logical_line_index(block.position())
117
+ if block.isVisible() and line_index < len(self.view.diff_document.lines):
118
+ line = self.view.diff_document.lines[line_index]
119
+ background = self.view.background_by_kind.get(line.kind)
120
+ if background is not None:
121
+ painter.fillRect(0, top, self.width(), height, QColor(background))
122
+ painter.setPen(self.palette().color(QPalette.ColorRole.Text))
123
+ for column_index, number in enumerate((line.old_number, line.new_number)):
124
+ if number is not None and block.position() == self.view.line_positions[line_index]:
125
+ painter.drawText(column_index * column_width, top, column_width - LAYOUT_SPACING, height, Qt.AlignmentFlag.AlignRight, str(number))
126
+ block = block.next()
127
+
128
+
129
+ class DiffView(SourceTextEdit):
130
+ """Read and search a unified patch with independent numbering and syntax."""
131
+
132
+ def __init__(self) -> None:
133
+ """Build reusable local search and a margin without adding repository dependencies."""
134
+ super().__init__()
135
+ self.dark_theme: bool | None = None
136
+ self.largest_line_number = 1
137
+ self.diff_document = DiffDocument()
138
+ self.saved_selection: tuple[str, str, int, int | None, int | None] | None = None
139
+ self.setReadOnly(True)
140
+ self.setUndoRedoEnabled(False)
141
+ self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
142
+ self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
143
+ self.setAccessibleName("Unified diff")
144
+ self.selection_keeper = ActiveSelectionKeeper(self)
145
+ self.number_margin = DiffNumberMargin(self)
146
+ self.highlighter = DiffHighlighter(self)
147
+ self.search_bar = QWidget(self)
148
+ search_layout = QHBoxLayout(self.search_bar)
149
+ search_layout.setContentsMargins(0, 0, 0, 0)
150
+ self.find_edit = QLineEdit()
151
+ self.find_edit.setPlaceholderText("Find in diff")
152
+ self.find_edit.setAccessibleName("Find in diff")
153
+ self.find_edit.setToolTip(SharedText.TEXT_SEARCH_HELP.value)
154
+ self.result_label = SearchResultLabel()
155
+ self.previous_button = QPushButton("Previous")
156
+ self.next_button = QPushButton("Next")
157
+ self.close_button = QPushButton("Close")
158
+ search_layout.addWidget(self.find_edit, 1)
159
+ search_layout.addWidget(self.result_label)
160
+ for button in (self.previous_button, self.next_button, self.close_button):
161
+ search_layout.addWidget(button)
162
+ self.search_bar.hide()
163
+ self.find_edit.textChanged.connect(self._restart_search)
164
+ self.find_edit.returnPressed.connect(self.find_next)
165
+ self.previous_button.clicked.connect(self.find_previous)
166
+ self.next_button.clicked.connect(self.find_next)
167
+ self.close_button.clicked.connect(self.close_search)
168
+ self.shortcuts: list[QShortcut] = []
169
+ callback_by_action = {FIND_NEXT_ACTION: self.find_next, FIND_PREVIOUS_ACTION: self.find_previous}
170
+ key_callbacks: list[tuple[str, Callable[[], None]]] = []
171
+ for key, action in find_shortcuts():
172
+ key_callbacks.append((key, callback_by_action[action]))
173
+ key_callbacks.append(("Escape", self.close_search))
174
+ for key, callback in key_callbacks:
175
+ shortcut = QShortcut(QKeySequence(key), self)
176
+ shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
177
+ shortcut.activated.connect(callback)
178
+ self.shortcuts.append(shortcut)
179
+ self.updateRequest.connect(self._update_margin)
180
+ self.refresh_metrics()
181
+
182
+ def show_patch(self, patch: bytes, basename: str = "", *, comparison_labels: tuple[str, str] | None = None) -> None:
183
+ """Parse Git text and choose a lexer without interpreting markup or file paths."""
184
+ self._remember_selection()
185
+ content = decode_blob_display("", patch)
186
+ display_text = content.display_text
187
+ if comparison_labels is not None:
188
+ old_label, new_label = comparison_labels
189
+ label_by_prefix = {"diff --git ": f"diff --git {old_label} -> {new_label}", "--- ": "--- " + old_label, "+++ ": "+++ " + new_label}
190
+ texts = []
191
+ in_header = True
192
+ for text in display_text.splitlines(keepends=True):
193
+ if text.startswith("@@"):
194
+ in_header = False
195
+ if in_header:
196
+ for prefix, label in label_by_prefix.items():
197
+ if text.startswith(prefix):
198
+ # Only metadata changes: raw patch offsets and every changed line stay intact.
199
+ text = label + ("\n" if text.endswith("\n") else "")
200
+ break
201
+ texts.append(text)
202
+ display_text = "".join(texts)
203
+ if content.diagnostic:
204
+ display_text = content.diagnostic + "\n" + display_text
205
+ self.diff_document = parse_unified_diff(display_text)
206
+ self._remember_largest_line_number()
207
+ self.highlighter.token_spans_by_line_index.clear()
208
+ try:
209
+ self.highlighter.lexer = get_lexer_for_filename(basename)
210
+ except ClassNotFound:
211
+ self.highlighter.lexer = None
212
+ texts = []
213
+ for line in self.diff_document.lines:
214
+ texts.append(line.text)
215
+ # One coloring pass is enough: the highlighter formats every block that setPlainText inserts, and the parsed
216
+ # patch, the line positions, the theme colors and the lexer are all in place before the text goes in.
217
+ # Colors only depend on the theme, which refresh_metrics follows, so placing the margins is all that is left;
218
+ # the search restart below rebuilds the line decorations.
219
+ self.setPlainText("\n".join(texts))
220
+ self._place_margins()
221
+ self.moveCursor(QTextCursor.MoveOperation.Start)
222
+ self._restart_search()
223
+ if self.saved_selection is not None:
224
+ selected_text, block_text, offset, old_number, new_number = self.saved_selection
225
+ block_match = self.find_source(block_text, 0)
226
+ for line_index, line in enumerate(self.diff_document.lines):
227
+ if line.text == block_text and (line.old_number, line.new_number) == (old_number, new_number):
228
+ block_match = QTextCursor(self.document())
229
+ block_match.setPosition(self.line_positions[line_index])
230
+ break
231
+ if not block_match.isNull():
232
+ cursor = QTextCursor(self.document())
233
+ source_position = self.source_position(block_match.selectionStart()) + offset
234
+ position = self.document_position(source_position)
235
+ end = self.document_position(source_position + utf16_length(selected_text))
236
+ if end >= self.document().characterCount():
237
+ self.saved_selection = None
238
+ return
239
+ cursor.setPosition(position)
240
+ cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
241
+ if self.selected_source_text(cursor) == selected_text:
242
+ self.setTextCursor(cursor)
243
+ self.ensureCursorVisible()
244
+ self._decorate(cursor)
245
+ self.saved_selection = None
246
+
247
+ def clear(self, preserve_selection: bool = False) -> None:
248
+ """Discard stale numbering and highlighting along with the visible patch."""
249
+ if preserve_selection:
250
+ self._remember_selection()
251
+ else:
252
+ self.saved_selection = None
253
+ self.diff_document = DiffDocument()
254
+ self._remember_largest_line_number()
255
+ self.highlighter.token_spans_by_line_index.clear()
256
+ super().clear()
257
+ self.setExtraSelections([])
258
+ self.refresh_metrics()
259
+
260
+ def _remember_selection(self) -> None:
261
+ """Retain the selected text and containing line across a context reload."""
262
+ cursor = self.textCursor()
263
+ if cursor.hasSelection():
264
+ start = QTextCursor(cursor)
265
+ start.setPosition(cursor.selectionStart())
266
+ line_index = self.logical_line_index(start.position())
267
+ if line_index >= len(self.diff_document.lines):
268
+ return
269
+ line = self.diff_document.lines[line_index]
270
+ offset = self.source_position(start.position()) - self.source_position(self.line_positions[line_index])
271
+ self.saved_selection = (self.selected_source_text(cursor), line.text, offset, line.old_number, line.new_number)
272
+
273
+ def refresh_metrics(self) -> None:
274
+ """Follow native fonts and palettes for line numbers, search and highlights.
275
+
276
+ Recoloring the whole patch is the expensive part, so it only happens when the light/dark theme really
277
+ changed; a font change, a window shown again or the search bar opening only moves the margins and rebuilds
278
+ the line decorations.
279
+ """
280
+ self.search_bar.setFont(QApplication.font())
281
+ dark = self.palette().color(QPalette.ColorRole.Base).lightnessF() < DARK_BACKGROUND_LIGHTNESS_LIMIT
282
+ theme_changed = dark != self.dark_theme
283
+ self.dark_theme = dark
284
+ self.background_by_kind = DIFF_DARK_BACKGROUND_BY_KIND if dark else DIFF_BACKGROUND_BY_KIND
285
+ self.fragment_by_kind = DIFF_DARK_FRAGMENT_BY_KIND if dark else DIFF_FRAGMENT_BY_KIND
286
+ syntax_style = get_style_by_name("native" if dark else "default")
287
+ if getattr(self, "syntax_style", None) != syntax_style:
288
+ self.highlighter.token_spans_by_line_index.clear()
289
+ self.syntax_style = syntax_style
290
+ self._place_margins()
291
+ if theme_changed:
292
+ self.highlighter.rehighlight()
293
+ self._decorate()
294
+
295
+ def _remember_largest_line_number(self) -> None:
296
+ """Compute once per patch the widest old or new line number, which sizes the number margin."""
297
+ largest_number = 1
298
+ for line in self.diff_document.lines:
299
+ largest_number = max(largest_number, line.old_number or 0, line.new_number or 0)
300
+ self.largest_line_number = largest_number
301
+
302
+ def _place_margins(self) -> None:
303
+ """Size and place the number margin and the search bar around the text, without touching any color."""
304
+ column_width = self.fontMetrics().horizontalAdvance(str(self.largest_line_number)) + LAYOUT_SPACING * 2
305
+ margin_width = column_width * DIFF_NUMBER_COLUMN_COUNT
306
+ search_height = 0 if self.search_bar.isHidden() else self.search_bar.sizeHint().height()
307
+ self.setViewportMargins(margin_width, search_height, 0, 0)
308
+ rectangle = self.contentsRect()
309
+ self.search_bar.setGeometry(rectangle.left(), rectangle.top(), rectangle.width(), search_height)
310
+ self.number_margin.setGeometry(rectangle.left(), self.viewport().geometry().top(), margin_width, self.viewport().height())
311
+ self.number_margin.update()
312
+
313
+ def resizeEvent(self, event: QResizeEvent) -> None:
314
+ """Keep the two margins aligned when the surrounding splitter moves; colors do not depend on the size."""
315
+ super().resizeEvent(event)
316
+ self._place_margins()
317
+
318
+ def changeEvent(self, event: QEvent) -> None:
319
+ """Refresh colors and logical geometry after font or palette changes."""
320
+ super().changeEvent(event)
321
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.ApplicationFontChange, QEvent.Type.PaletteChange) and hasattr(self, "search_bar"):
322
+ self.refresh_metrics()
323
+
324
+ def open_search(self) -> None:
325
+ """Focus the local query without modifying the history filter."""
326
+ self.search_bar.show()
327
+ self.result_label.show_result(self, self.find_edit.text())
328
+ self.refresh_metrics()
329
+ self.find_edit.setFocus(Qt.FocusReason.ShortcutFocusReason)
330
+ self.find_edit.selectAll()
331
+
332
+ def close_search(self) -> None:
333
+ """Hide the query while retaining all syntax and change decoration."""
334
+ self.search_bar.hide()
335
+ self.refresh_metrics()
336
+ self.setFocus(Qt.FocusReason.ShortcutFocusReason)
337
+
338
+ def find_next(self) -> None:
339
+ """Select the next Unicode match, wrapping at the end."""
340
+ self._find(False)
341
+
342
+ def find_previous(self) -> None:
343
+ """Select the previous Unicode match, wrapping at the start."""
344
+ self._find(True)
345
+
346
+ def _restart_search(self) -> None:
347
+ """Discard obsolete search selection after the patch or query changes."""
348
+ cursor = self.textCursor()
349
+ cursor.clearSelection()
350
+ self.setTextCursor(cursor)
351
+ self.result_label.clear()
352
+ self._decorate()
353
+ self._find(False, restart=True)
354
+
355
+ def _find(self, backward: bool, restart: bool = False) -> None:
356
+ """Use document offsets so surrogate pairs never shift matches or accents."""
357
+ query = self.find_edit.text()
358
+ if not query or self.search_bar.isHidden():
359
+ return
360
+ self.result_label.show_result(self, query)
361
+ cursor = self.textCursor()
362
+ start = cursor.selectionStart() if backward else cursor.selectionEnd()
363
+ if restart:
364
+ start = 0
365
+ match = self.result_label.find_match(self, query, start, backward)
366
+ if match.isNull():
367
+ start = self.document().characterCount() - 1 if backward else 0
368
+ match = self.result_label.find_match(self, query, start, backward)
369
+ if match.isNull():
370
+ return
371
+ self.setTextCursor(match)
372
+ self.ensureCursorVisible()
373
+ self._decorate(match)
374
+
375
+ def _decorate(self, match: QTextCursor | None = None) -> None:
376
+ """Keep full-width change backgrounds when search selections come and go."""
377
+ selections = []
378
+ block = self.document().begin()
379
+ while block.isValid():
380
+ line_index = self.logical_line_index(block.position())
381
+ if line_index >= len(self.diff_document.lines):
382
+ break
383
+ line = self.diff_document.lines[line_index]
384
+ background = self.background_by_kind.get(line.kind)
385
+ if background is None:
386
+ block = block.next()
387
+ continue
388
+ selection = QTextEdit.ExtraSelection()
389
+ selection.cursor = QTextCursor(block)
390
+ selection.format.setBackground(QColor(background))
391
+ selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
392
+ selections.append(selection)
393
+ if block.position() != self.line_positions[line_index]:
394
+ block = block.next()
395
+ continue
396
+ source_position = self.source_position(block.position())
397
+ for start, end in line.changed_spans:
398
+ fragment = QTextEdit.ExtraSelection()
399
+ fragment.cursor = QTextCursor(self.document())
400
+ fragment.cursor.setPosition(self.document_position(source_position + utf16_length(line.text[:start])))
401
+ fragment.cursor.setPosition(self.document_position(source_position + utf16_length(line.text[:end])), QTextCursor.MoveMode.KeepAnchor)
402
+ fragment.format.setBackground(QColor(self.fragment_by_kind[line.kind]))
403
+ selections.append(fragment)
404
+ block = block.next()
405
+ if not self.search_bar.isHidden():
406
+ selections.extend(self.result_label.highlight_selections(self, self.find_edit.text(), match))
407
+ self.setExtraSelections(selections)
408
+
409
+ def _update_margin(self, rectangle: QRect, distance: int) -> None:
410
+ self.number_margin.update()
411
+
412
+
413
+ def status_icon(status: FileStatus, side: int, density: float) -> QIcon:
414
+ """Draw change symbols with an orange R over the modified-file icon for renames."""
415
+ physical_side = physical_icon_side(side, density)
416
+ pixmap = QPixmap(physical_side, physical_side)
417
+ pixmap.setDevicePixelRatio(density)
418
+ pixmap.fill(Qt.GlobalColor.transparent)
419
+ painter = QPainter(pixmap)
420
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
421
+ painter.setPen(QPen(QColor(DIFF_ICON_COLOR_BY_STATUS.get(status, DIFF_ICON_COLOR_BY_STATUS[FileStatus.MODIFIED])), side * DIFF_ICON_STROKE_RATIO))
422
+ padding = side * DIFF_ICON_PADDING_RATIO
423
+ center = side / 2
424
+ if status in (FileStatus.ADDED, FileStatus.DELETED):
425
+ painter.drawLine(QPointF(padding, center), QPointF(side - padding, center))
426
+ if status == FileStatus.ADDED:
427
+ painter.drawLine(QPointF(center, padding), QPointF(center, side - padding))
428
+ else:
429
+ paper_padding = padding / 2
430
+ painter.setPen(QPen(QColor("#a0a0a0"), side * DIFF_ICON_STROKE_RATIO / 2))
431
+ painter.setBrush(QColor("#fffef4"))
432
+ painter.drawRect(QRectF(paper_padding, paper_padding, side - padding, side - padding))
433
+ pencil_stroke_ratio = 0.15
434
+ painter.setPen(QPen(QColor(DIFF_ICON_COLOR_BY_STATUS[FileStatus.MODIFIED]), side * pencil_stroke_ratio))
435
+ painter.drawLine(QPointF(padding, side - padding), QPointF(side - padding, padding))
436
+ if status == FileStatus.RENAMED:
437
+ badge_side = side * sqrt(DIFF_ICON_RENAME_BADGE_AREA_RATIO)
438
+ badge_font = painter.font()
439
+ badge_font.setBold(True)
440
+ badge_font.setPixelSize(side)
441
+ badge_path = QPainterPath()
442
+ badge_path.addText(QPointF(), badge_font, FileStatus.RENAMED.value)
443
+ bounds = badge_path.boundingRect()
444
+ scale = min(badge_side / bounds.width(), badge_side / bounds.height())
445
+ painter.translate(side - bounds.width() * scale, side - bounds.height() * scale)
446
+ painter.scale(scale, scale)
447
+ painter.translate(-bounds.left(), -bounds.top())
448
+ painter.setPen(Qt.PenStyle.NoPen)
449
+ painter.setBrush(QColor(DIFF_ICON_COLOR_BY_STATUS[FileStatus.RENAMED]))
450
+ painter.drawPath(badge_path)
451
+ painter.end()
452
+ return QIcon(pixmap)
453
+
454
+
455
+ class DiffPanel(QWidget):
456
+ """Emit file selections and display only the comparison supplied by the history read controller."""
457
+
458
+ entry_selected = Signal(object)
459
+ retry_requested = Signal()
460
+ full_context_requested = Signal(bool)
461
+
462
+ def __init__(self) -> None:
463
+ """Keep file navigation beside a selectable, read-only patch."""
464
+ super().__init__()
465
+ self.entries: list[RevisionEntry] = []
466
+ self.item_by_entry: dict[RevisionEntry, QTreeWidgetItem] = {}
467
+ self.selected_entry: RevisionEntry | None = None
468
+ self.action_generation = 0
469
+ layout = QVBoxLayout(self)
470
+ layout.setContentsMargins(0, 0, 0, 0)
471
+ layout.setSpacing(LAYOUT_SPACING)
472
+ self.reader_toolbar = ReaderToolbar("Find in diff")
473
+ self.status_label = self.reader_toolbar.status_label
474
+ self.status_label.setText("Select a commit")
475
+ self.retry_button = self.reader_toolbar.retry_button
476
+ self.retry_button.clicked.connect(self.retry_requested.emit)
477
+ self.full_context_checkbox = QCheckBox("Show entire file")
478
+ self.full_context_checkbox.setEnabled(False)
479
+ self.full_context_checkbox.toggled.connect(self.full_context_requested.emit)
480
+ find_button_index = self.reader_toolbar.controls.count() - 1
481
+ self.reader_toolbar.controls.insertWidget(find_button_index, self.full_context_checkbox)
482
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
483
+ self.splitter.setChildrenCollapsible(False)
484
+ self.file_tree = RevisionTree()
485
+ self.file_tree.setHeaderHidden(True)
486
+ self.file_tree.setUniformRowHeights(True)
487
+ self.file_tree.setAccessibleName("Changed files")
488
+ self.diff_view = DiffView()
489
+ self.reader_toolbar.find_button.clicked.connect(self.open_search)
490
+ self.reader_container = QWidget()
491
+ reader_layout = QVBoxLayout(self.reader_container)
492
+ reader_layout.setContentsMargins(0, 0, 0, 0)
493
+ reader_layout.setSpacing(LAYOUT_SPACING)
494
+ reader_layout.addWidget(self.reader_toolbar)
495
+ reader_layout.addWidget(self.diff_view, 1)
496
+ self.splitter.addWidget(self.file_tree)
497
+ self.splitter.addWidget(self.reader_container)
498
+ file_index, text_index = 0, 1
499
+ self.splitter.setStretchFactor(file_index, DIFF_FILE_STRETCH)
500
+ self.splitter.setStretchFactor(text_index, DIFF_TEXT_STRETCH)
501
+ layout.addWidget(self.splitter, 1)
502
+ self.file_tree.currentItemChanged.connect(self._select_item)
503
+ self.file_tree.currentItemChanged.connect(self._invalidate_action)
504
+ self.refresh_metrics()
505
+
506
+ def _invalidate_action(self) -> None:
507
+ self.action_generation += 1
508
+
509
+ def refresh_metrics(self) -> None:
510
+ """Scale file symbols with the tree font and current device density."""
511
+ side = self.file_tree.fontMetrics().height()
512
+ self.file_tree.setIconSize(QSize(side, side))
513
+ self.file_tree.setIndentation(side + LAYOUT_SPACING)
514
+ for entry, item in self.item_by_entry.items():
515
+ item.setIcon(0, status_icon(entry.status, side, self.devicePixelRatioF()))
516
+
517
+ def changeEvent(self, event: QEvent) -> None:
518
+ """Update symbols after a system font or palette change."""
519
+ super().changeEvent(event)
520
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.PaletteChange) and hasattr(self, "file_tree"):
521
+ self.refresh_metrics()
522
+
523
+ def clear_selection(self, message: str = "Select a commit") -> None:
524
+ """Clear stale files immediately when a commit changes."""
525
+ self.reader_toolbar.finish_loading()
526
+ self.action_generation += 1
527
+ self.entries.clear()
528
+ self.item_by_entry.clear()
529
+ self.selected_entry = None
530
+ self.full_context_checkbox.setEnabled(False)
531
+ self.file_tree.clear()
532
+ self.diff_view.clear()
533
+ self.status_label.setText(message)
534
+ self.status_label.setVisible(bool(message))
535
+ self.retry_button.hide()
536
+
537
+ def show_changes(self, groups: list[ComparisonGroup]) -> None:
538
+ """Keep the original comparison attached to every displayed file."""
539
+ self.clear_selection("No changed files")
540
+ for group in groups:
541
+ description = "Initial commit" if group.parent_oid is None else f"Diff with {group.parent_short_oid} {group.parent_subject}"
542
+ file_label = "file" if group.file_count == 1 else "files"
543
+ group_item = QTreeWidgetItem([f"{group.file_count} {file_label} — {description}"])
544
+ group_item.setToolTip(0, description)
545
+ group_item.setIcon(0, self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogDetailedView))
546
+ self.file_tree.addTopLevelItem(group_item)
547
+ directory_item_by_path: dict[bytes, QTreeWidgetItem] = {}
548
+ for entry in sorted(group.entries, key=lambda change: change.new_path or change.old_path or b""):
549
+ self.entries.append(entry)
550
+ path = entry.new_path or entry.old_path or b""
551
+ directories = path.split(b"/")
552
+ basename = directories.pop()
553
+ parent_item = group_item
554
+ directory_path = b""
555
+ for directory in directories:
556
+ directory_path += directory + b"/"
557
+ directory_item = directory_item_by_path.get(directory_path)
558
+ if directory_item is None:
559
+ directory_item = QTreeWidgetItem([directory.decode("utf-8", "replace")])
560
+ directory_item.setData(0, FOLDER_PATH_ROLE, directory_path.removesuffix(b"/"))
561
+ directory_item.setToolTip(0, directory_path.decode("utf-8", "replace"))
562
+ directory_item.setIcon(0, self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon))
563
+ parent_item.addChild(directory_item)
564
+ directory_item_by_path[directory_path] = directory_item
565
+ parent_item = directory_item
566
+ label = basename.decode("utf-8", "replace")
567
+ tooltip = path.decode("utf-8", "replace")
568
+ if entry.status == FileStatus.RENAMED and entry.old_path is not None:
569
+ old_basename = entry.old_path.rsplit(b"/", maxsplit=1).pop().decode("utf-8", "replace")
570
+ label += f" ({old_basename})"
571
+ tooltip += "\nRenamed from " + entry.old_path.decode("utf-8", "replace")
572
+ item = QTreeWidgetItem([label])
573
+ item.setToolTip(0, tooltip)
574
+ item.setData(0, Qt.ItemDataRole.UserRole, entry)
575
+ parent_item.addChild(item)
576
+ self.item_by_entry[entry] = item
577
+ for directory_item in (*directory_item_by_path.values(), group_item):
578
+ children = directory_item.takeChildren()
579
+ children.sort(key=lambda child: (child.data(0, Qt.ItemDataRole.UserRole) is not None, child.text(0).casefold(), child.text(0)))
580
+ directory_item.addChildren(children)
581
+ compact_directory_tree(group_item)
582
+ self.file_tree.expandAll()
583
+ self.refresh_metrics()
584
+ if self.entries:
585
+ self.status_label.setText("Select a file to view its diff")
586
+
587
+ def first_entry(self) -> RevisionEntry | None:
588
+ """Choose the first visible file after folder grouping and sorting."""
589
+ iterator = QTreeWidgetItemIterator(self.file_tree)
590
+ while item := iterator.value():
591
+ entry = item.data(0, Qt.ItemDataRole.UserRole)
592
+ if entry is not None:
593
+ return entry
594
+ iterator += 1
595
+ return None
596
+
597
+ def show_patch(self, entry: RevisionEntry, patch: bytes) -> None:
598
+ """Display Git's patch as literal text without executing repository content."""
599
+ self.reader_toolbar.finish_loading()
600
+ self.selected_entry = entry
601
+ self.full_context_checkbox.setEnabled(True)
602
+ self.diff_view.show_patch(patch, (entry.new_path or entry.old_path or b"").decode("utf-8", "replace"))
603
+ self.status_label.setText("" if patch else "No content changes")
604
+ self.status_label.setVisible(not patch)
605
+ self.retry_button.hide()
606
+
607
+ def show_error(self, cause: str) -> None:
608
+ """Keep a failed read explicit and allow a fresh request."""
609
+ self.reader_toolbar.finish_loading()
610
+ self.diff_view.clear()
611
+ self.status_label.setText(cause)
612
+ self.status_label.show()
613
+ self.retry_button.show()
614
+
615
+ def _select_item(self, item: QTreeWidgetItem | None, previous: QTreeWidgetItem | None) -> None:
616
+ """Request the complete entry identity when a file row becomes current."""
617
+ if item is None:
618
+ return
619
+ entry = item.data(0, Qt.ItemDataRole.UserRole)
620
+ if entry is not None:
621
+ self.entry_selected.emit(entry)
622
+
623
+ def open_search(self) -> None:
624
+ """Reveal local search and identify the reader receiving the window command."""
625
+ self.diff_view.open_search()
626
+ self.diff_view.setAccessibleDescription("Search applies to the displayed comparison")