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,378 @@
1
+ """Present one commit in a scrolling panel: its exact text, or its explicit loading state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from html import escape
9
+ from typing import TYPE_CHECKING, Self
10
+
11
+ from PySide6.QtCore import QEvent, Qt, Signal
12
+ from PySide6.QtGui import QColor, QImage, QPalette, QPixmap, QTextBlockFormat, QTextCursor
13
+ from PySide6.QtWidgets import QApplication, QGridLayout, QLabel, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget
14
+
15
+ from gitpane.config import LAYOUT_SPACING, MESSAGE_LINE_HEIGHT_PERCENT, SPACING_MD, SPACING_XS, SharedText
16
+ from gitpane.history.avatar import AvatarService, author_color, author_initials
17
+ from gitpane.ui.look.avatar_label import AvatarLabel
18
+ from gitpane.ui.look.fixed_font import body_sized_fixed_font
19
+ from gitpane.ui.reader.commit_message import CommitMessage
20
+ from gitpane.ui.widget.commit_date_text import format_commit_time
21
+ from gitpane.ui.widget.plain_label import plain_label
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+
26
+ from PySide6.QtGui import QKeyEvent
27
+
28
+ from gitpane.history.repository_value import Commit, CommitLink, CommitRelation
29
+
30
+
31
+ class CommitPanelText(Enum):
32
+ """Texts the commit detail panel shows, one member per text."""
33
+
34
+ DETAIL_EMPTY = "Select a commit to view its details."
35
+ DETAIL_RETRY = "Retry reading"
36
+ MESSAGE_EMPTY = "Empty message"
37
+ AUTHOR_UNKNOWN = "Unknown author"
38
+ AUTHOR = "Author"
39
+ AUTHOR_DATE = "Author date"
40
+ COMMITTER_DATE = "Committer date"
41
+ PARENT_EMPTY = "No parents"
42
+ CHILD_EMPTY = "No children"
43
+
44
+
45
+ class CopiedHash(Enum):
46
+ """Which form of a commit hash is copied, with the text of its copy control and the message shown once it is copied."""
47
+
48
+ FULL = ("full", "Copy full hash", "Full hash copied.")
49
+ SHORT = ("short", "Copy short hash", "Short hash copied.")
50
+
51
+ def __new__(cls, value: str, copy_label: str, copied_message: str) -> Self:
52
+ """Keep the name of the hash form as the value, and its two texts beside it."""
53
+ member = object.__new__(cls)
54
+ member._value_ = value
55
+ member.copy_label = copy_label
56
+ member.copied_message = copied_message
57
+ return member
58
+
59
+
60
+ class HashButton(QPushButton):
61
+ """Make a displayed hash a native, keyboard-activatable copy control."""
62
+
63
+ def __init__(self) -> None:
64
+ """Use the native focus indicator and underline the copied value."""
65
+ super().__init__()
66
+ self.setFlat(True)
67
+ self.setStyleSheet("QPushButton { text-align: left; padding: 0; }")
68
+ font = self.font()
69
+ font.setUnderline(True)
70
+ self.setFont(font)
71
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
72
+ self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
73
+
74
+ def keyPressEvent(self, event: QKeyEvent) -> None:
75
+ """Support Enter outside dialogs alongside the native Space activation."""
76
+ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
77
+ self.click()
78
+ event.accept()
79
+ return
80
+ super().keyPressEvent(event)
81
+
82
+
83
+ class RelativeLabel(QLabel):
84
+ """Expose an underlined relative link with the same keyboard activation as hashes."""
85
+
86
+ def keyPressEvent(self, event: QKeyEvent) -> None:
87
+ """Activate the sole local target with Enter or Space, retaining native focus."""
88
+ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space):
89
+ self.linkActivated.emit(str(self.property("oid")))
90
+ event.accept()
91
+ return
92
+ super().keyPressEvent(event)
93
+
94
+
95
+ class CommitPanel(QScrollArea):
96
+ """Keep metadata, copy values and navigation bound to the visible commit."""
97
+
98
+ copy_requested = Signal(str, object)
99
+ navigation_requested = Signal(str)
100
+ retry_requested = Signal(str)
101
+
102
+ def __init__(self, clock: Callable[[], datetime] | None = None) -> None:
103
+ """Build plain text fields and reserve a stable square for the author image."""
104
+ super().__init__()
105
+ self.clock = clock or (lambda: datetime.now().astimezone())
106
+ self.commit: Commit | None = None
107
+ self.oid: str | None = None
108
+ self.avatar_service: AvatarService | None = None
109
+ self.avatar_identity: object | None = None
110
+ self.setWidgetResizable(True)
111
+ content = QWidget()
112
+ layout = QVBoxLayout(content)
113
+ layout.setSpacing(LAYOUT_SPACING)
114
+ layout.setContentsMargins(LAYOUT_SPACING, LAYOUT_SPACING, LAYOUT_SPACING, LAYOUT_SPACING)
115
+ self.state_label = plain_label("")
116
+ self.avatar_label = AvatarLabel()
117
+ self.avatar_label.setTextFormat(Qt.TextFormat.PlainText)
118
+ self.avatar_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
119
+ self.avatar_label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
120
+ self.avatar_label.setAccessibleName("Author avatar")
121
+ self.author_label = plain_label("")
122
+ self.author_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
123
+ self.date_label = plain_label("")
124
+ self.committer_date_label = plain_label("")
125
+ self.full_hash_button = HashButton()
126
+ self.short_hash_button = HashButton()
127
+ self.full_hash_button.setAccessibleName(CopiedHash.FULL.copy_label)
128
+ self.short_hash_button.setAccessibleName(CopiedHash.SHORT.copy_label)
129
+ self.full_hash_button.clicked.connect(lambda: self.copy_requested.emit(self.full_hash_button.text(), CopiedHash.FULL))
130
+ self.short_hash_button.clicked.connect(lambda: self.copy_requested.emit(self.short_hash_button.text(), CopiedHash.SHORT))
131
+ self.retry_button = QPushButton(CommitPanelText.DETAIL_RETRY.value)
132
+ self.retry_button.clicked.connect(lambda: self.retry_requested.emit(self.oid) if self.oid else None)
133
+ self.message_panel = CommitMessage()
134
+ self.message_edit = self.message_panel.text_edit
135
+ self.message_state_label = plain_label("")
136
+ self.parent_label = plain_label("")
137
+ self.child_label = plain_label("")
138
+ self.parent_links: list[QLabel] = []
139
+ self.child_links: list[QLabel] = []
140
+ self.relation_content = QWidget()
141
+ self.relation_layout = QVBoxLayout(self.relation_content)
142
+ self.relation_layout.setContentsMargins(0, 0, 0, 0)
143
+ self.relation_layout.setSpacing(LAYOUT_SPACING)
144
+ self.relation_layout.addWidget(self.parent_label)
145
+ self.relation_layout.addWidget(self.child_label)
146
+ layout.addWidget(self.state_label)
147
+ header_layout = QGridLayout()
148
+ header_layout.setVerticalSpacing(0)
149
+ header_layout.setHorizontalSpacing(SPACING_MD)
150
+ header_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
151
+ avatar_column, label_column, value_column = range(3)
152
+ first_row = 0
153
+ self.metadata_values = [self.author_label, self.date_label, self.committer_date_label, self.full_hash_button, self.short_hash_button]
154
+ self.metadata_labels: list[QLabel] = []
155
+ titles = (CommitPanelText.AUTHOR.value, CommitPanelText.AUTHOR_DATE.value, CommitPanelText.COMMITTER_DATE.value, "Full hash", "Short hash")
156
+ for row, (title, value) in enumerate(zip(titles, self.metadata_values, strict=True)):
157
+ label = QLabel(title)
158
+ self.metadata_labels.append(label)
159
+ header_layout.addWidget(label, row, label_column)
160
+ header_layout.addWidget(value, row, value_column)
161
+ header_layout.addWidget(self.avatar_label, first_row, avatar_column, len(self.metadata_values), 1, Qt.AlignmentFlag.AlignTop)
162
+ header_layout.setColumnStretch(value_column, 1)
163
+ layout.addLayout(header_layout)
164
+ layout.addWidget(self.retry_button)
165
+ layout.addWidget(self.relation_content)
166
+ layout.addWidget(self.message_state_label)
167
+ layout.addWidget(self.message_panel, 1)
168
+ self.setWidget(content)
169
+ self.refresh_metrics()
170
+ self.clear_selection()
171
+
172
+ def refresh_metrics(self) -> None:
173
+ """Fit compact metadata rows to system text and size the avatar to their combined height."""
174
+ body_font = QApplication.font()
175
+ self.message_edit.setFont(body_font)
176
+ fixed_font = body_sized_fixed_font()
177
+ fixed_font.setUnderline(True)
178
+ for button in (self.full_hash_button, self.short_hash_button):
179
+ button.setFont(fixed_font)
180
+ self.apply_link_color()
181
+ self.avatar_label.setFont(body_font)
182
+ row_height = max(self.author_label.fontMetrics().height(), self.full_hash_button.fontMetrics().height()) + SPACING_XS
183
+ for control in (*self.metadata_labels, *self.metadata_values):
184
+ control.setMinimumHeight(row_height)
185
+ # Flat copy links use text-height rows; native form-button padding would spread all five lines.
186
+ for button in (self.full_hash_button, self.short_hash_button):
187
+ button.setFixedHeight(row_height)
188
+ row_count = len(self.metadata_values)
189
+ avatar_side = row_count * row_height
190
+ if self.avatar_label.width() != avatar_side:
191
+ self.avatar_label.setFixedSize(avatar_side, avatar_side)
192
+ self.refresh_avatar()
193
+
194
+ def apply_link_color(self) -> None:
195
+ """Draw the copyable hashes in the link color of the panel's palette, the same whether the window is active or not.
196
+
197
+ The color is copied into each hash button, so it is copied again whenever the panel's palette changes: a switch
198
+ between light and dark changes the link color, and the light blue is hard to read on the dark panel. The active
199
+ color is taken by name, since the palette's current group follows whether the window is active at that moment.
200
+ """
201
+ link_color = self.palette().color(QPalette.ColorGroup.Active, QPalette.ColorRole.Link)
202
+ for button in (self.full_hash_button, self.short_hash_button):
203
+ palette = button.palette()
204
+ palette.setColor(QPalette.ColorRole.ButtonText, link_color)
205
+ button.setPalette(palette)
206
+
207
+ def changeEvent(self, event: QEvent) -> None:
208
+ """Give the hashes the new link color when the panel's palette changes."""
209
+ super().changeEvent(event)
210
+ if event.type() == QEvent.Type.PaletteChange:
211
+ self.apply_link_color()
212
+
213
+ def clear_selection(self) -> None:
214
+ """Remove every actionable value when no commit is selected."""
215
+ self.cancel_avatar()
216
+ self.commit = None
217
+ self.oid = None
218
+ self.state_label.show()
219
+ self.state_label.setText(CommitPanelText.DETAIL_EMPTY.value)
220
+ self.avatar_label.setAutoFillBackground(False)
221
+ for label in (self.author_label, self.date_label, self.committer_date_label, self.avatar_label, self.message_state_label, self.parent_label, self.child_label):
222
+ label.clear()
223
+ self.parent_label.show()
224
+ self.child_label.show()
225
+ for button in (self.full_hash_button, self.short_hash_button):
226
+ button.setText("")
227
+ button.setEnabled(False)
228
+ self.message_edit.clear()
229
+ self.retry_button.hide()
230
+ self.clear_relation()
231
+
232
+ def clear_relation(self) -> None:
233
+ """Disable former relative links immediately when the active commit changes."""
234
+ for link in (*self.parent_links, *self.child_links):
235
+ self.relation_layout.removeWidget(link)
236
+ link.setEnabled(False)
237
+ link.hide()
238
+ link.deleteLater()
239
+ self.parent_links.clear()
240
+ self.child_links.clear()
241
+
242
+ def show_relation(self, relation: CommitRelation) -> None:
243
+ """Show known relatives without confusing a partial index with no children."""
244
+ if relation.oid != self.oid or self.commit is None:
245
+ return
246
+ self.clear_relation()
247
+ self.parent_label.setText(CommitPanelText.PARENT_EMPTY.value)
248
+ self.parent_label.setVisible(not relation.parents)
249
+ self.child_label.setText(CommitPanelText.CHILD_EMPTY.value if relation.children_complete else SharedText.CHILD_LOADING.value)
250
+ self.child_label.setVisible(not relation.children_complete or not relation.child_links)
251
+ for relative in relation.parents:
252
+ self.parent_links.append(self._add_link(relative, "Parent"))
253
+ for relative in relation.child_links:
254
+ self.child_links.append(self._add_link(relative, "Child"))
255
+ previous = self.short_hash_button
256
+ for link in (*self.parent_links, *self.child_links):
257
+ QWidget.setTabOrder(previous, link)
258
+ previous = link
259
+ QWidget.setTabOrder(previous, self.message_edit)
260
+
261
+ def _add_link(self, relative: CommitLink, kind: str) -> QLabel:
262
+ """Escape Git text inside a controlled local navigation link, never an external URL."""
263
+ label = RelativeLabel()
264
+ label.setWordWrap(True)
265
+ label.setTextFormat(Qt.TextFormat.RichText)
266
+ label.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
267
+ label.setOpenExternalLinks(False)
268
+ label.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
269
+ text = f"{relative.short_oid or relative.oid} — {relative.subject or 'Empty message'}"
270
+ label.setText(f'{kind} : <a href="{escape(relative.oid)}">{escape(text)}</a>')
271
+ label.setAccessibleName(f"{kind} : {text}")
272
+ label.setProperty("oid", relative.oid)
273
+ label.linkActivated.connect(lambda _target, oid=relative.oid: self.navigation_requested.emit(oid))
274
+ if kind == "Parent":
275
+ self.relation_layout.insertWidget(self.relation_layout.indexOf(self.child_label), label)
276
+ else:
277
+ self.relation_layout.addWidget(label)
278
+ label.show()
279
+ return label
280
+
281
+ def show_loading(self, oid: str, short_oid: str = "") -> None:
282
+ """Retain only the current known hashes while fresh metadata is read."""
283
+ self.clear_selection()
284
+ self.oid = oid
285
+ self.full_hash_button.setText(oid)
286
+ self.full_hash_button.setEnabled(bool(oid))
287
+ self.short_hash_button.setText(short_oid)
288
+ self.short_hash_button.setEnabled(bool(short_oid))
289
+ self.state_label.setText(f"Loading commit {short_oid or oid}…")
290
+ self.child_label.setText(SharedText.CHILD_LOADING.value)
291
+
292
+ def show_error(self, oid: str, cause: str) -> None:
293
+ """Keep the current hash available and offer an explicit retry after failure."""
294
+ if self.oid != oid:
295
+ return
296
+ short_oid = self.short_hash_button.text()
297
+ self.show_loading(oid, short_oid)
298
+ self.state_label.setText(f"Details unavailable — {cause}")
299
+ self.retry_button.show()
300
+
301
+ def show_commit(self, commit: Commit) -> None:
302
+ """Publish all fields together only for the selection currently being read."""
303
+ if self.oid != commit.oid:
304
+ return
305
+ if commit.detail_error:
306
+ self.show_error(commit.oid, commit.detail_error)
307
+ return
308
+ self.commit = commit
309
+ self.state_label.clear()
310
+ self.state_label.hide()
311
+ self.retry_button.hide()
312
+ self.full_hash_button.setText(commit.oid)
313
+ self.short_hash_button.setText(commit.short_oid)
314
+ self.short_hash_button.setEnabled(bool(commit.short_oid))
315
+ author = commit.author_name or CommitPanelText.AUTHOR_UNKNOWN.value
316
+ self.author_label.setText(f"{author} <{commit.author_email}>" if commit.author_email else author)
317
+ self.refresh_avatar()
318
+ self.message_edit.setPlainText(commit.message)
319
+ cursor = QTextCursor(self.message_edit.document())
320
+ cursor.select(QTextCursor.SelectionType.Document)
321
+ paragraph_format = QTextBlockFormat()
322
+ paragraph_format.setLineHeight(MESSAGE_LINE_HEIGHT_PERCENT, QTextBlockFormat.LineHeightTypes.ProportionalHeight.value)
323
+ cursor.mergeBlockFormat(paragraph_format)
324
+ self.message_state_label.setText("" if commit.message else CommitPanelText.MESSAGE_EMPTY.value)
325
+ self.message_state_label.setVisible(not commit.message)
326
+ self.refresh_date()
327
+
328
+ def set_avatar_service(self, service: AvatarService) -> None:
329
+ """Connect the shared photo service while retaining a standalone offline panel."""
330
+ self.cancel_avatar()
331
+ if self.avatar_service is not None:
332
+ self.avatar_service.image_ready.disconnect(self._image_ready)
333
+ self.avatar_service = service
334
+ service.image_ready.connect(self._image_ready)
335
+ self.refresh_avatar()
336
+
337
+ def cancel_avatar(self) -> None:
338
+ """Invalidate the current consumer before selection or window lifetime changes."""
339
+ identity = self.avatar_identity
340
+ self.avatar_identity = None
341
+ if self.avatar_service is not None and identity is not None:
342
+ self.avatar_service.cancel(identity)
343
+
344
+ def refresh_avatar(self, device_pixel_ratio: float | None = None) -> None:
345
+ """Rebuild the photo identity after author, selection or display density changes."""
346
+ self.cancel_avatar()
347
+ if self.commit is None:
348
+ return
349
+ palette = self.avatar_label.palette()
350
+ palette.setColor(QPalette.ColorRole.Window, QColor(author_color(self.commit.author_name, self.commit.author_email)))
351
+ palette.setColor(QPalette.ColorRole.WindowText, QColor("white"))
352
+ self.avatar_label.setPalette(palette)
353
+ self.avatar_label.setAutoFillBackground(True)
354
+ if self.avatar_label.pixmap().isNull():
355
+ self.avatar_label.setText(author_initials(self.commit.author_name))
356
+ self.avatar_label.setAccessibleName(f"Avatar for {self.commit.author_name or 'unknown author'}")
357
+ if self.avatar_service is None:
358
+ return
359
+ ratio = self.devicePixelRatioF() if device_pixel_ratio is None else device_pixel_ratio
360
+ avatar_side = self.avatar_label.width()
361
+ self.avatar_identity = (uuid.uuid4().hex, self.oid, self.commit.author_email, avatar_side, ratio)
362
+ self.avatar_service.request(self.commit.author_email, avatar_side, ratio, self.avatar_identity)
363
+
364
+ def _image_ready(self, identity: object, image: QImage) -> None:
365
+ """Ignore stale author and screen responses without altering surrounding text."""
366
+ if identity != self.avatar_identity or self.commit is None or image.isNull():
367
+ return
368
+ pixmap = QPixmap.fromImage(image)
369
+ pixmap.setDevicePixelRatio(max(image.width(), image.height()) / self.avatar_label.width())
370
+ self.avatar_label.setPixmap(pixmap)
371
+
372
+ def refresh_date(self) -> None:
373
+ """Update relative labels without replacing the message or changing its scroll."""
374
+ if self.commit is None:
375
+ return
376
+ now = self.clock()
377
+ self.date_label.setText(format_commit_time(self.commit, self.commit.author_timestamp, now))
378
+ self.committer_date_label.setText(format_commit_time(self.commit, self.commit.committer_timestamp, now))