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,2580 @@
1
+ """Show the main window of one open repository, from its commit history to its file readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import os
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from enum import Enum
11
+ from itertools import pairwise
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QModelIndex, QPoint, QRect, QSignalBlocker, QSize, Qt, QTimer, Signal
15
+ from PySide6.QtGui import QAction, QActionGroup, QContextMenuEvent, QIcon, QKeySequence, QPainter, QPalette, QPixmap, QPolygon
16
+ from PySide6.QtWidgets import (
17
+ QAbstractItemView,
18
+ QApplication,
19
+ QFrame,
20
+ QHBoxLayout,
21
+ QHeaderView,
22
+ QLabel,
23
+ QLayout,
24
+ QLineEdit,
25
+ QMainWindow,
26
+ QMenu,
27
+ QMessageBox,
28
+ QPushButton,
29
+ QSizePolicy,
30
+ QSplitter,
31
+ QStyle,
32
+ QStyleOptionToolButton,
33
+ QTableView,
34
+ QTabWidget,
35
+ QToolButton,
36
+ QTreeWidget,
37
+ QTreeWidgetItem,
38
+ QVBoxLayout,
39
+ QWidget,
40
+ )
41
+
42
+ from gitpane.config import (
43
+ COPY_CONFIRMATION_MS,
44
+ DETAIL_MIN_HEIGHT,
45
+ DETAIL_STRETCH,
46
+ DIFF_FILE_STRETCH,
47
+ DIFF_TEXT_STRETCH,
48
+ EMOJI_KEYBOARD_TITLE,
49
+ FILTER_FIELD_MIN_WIDTH,
50
+ FIND_SHORTCUT,
51
+ FIRST_ROW,
52
+ GRAPH_LANE_WIDTH,
53
+ HASH_FIELD_MIN_WIDTH,
54
+ HISTORY_MIN_HEIGHT,
55
+ HISTORY_STRETCH,
56
+ LAYOUT_SPACING,
57
+ MENU_ARROW_AREA_WIDTH,
58
+ MENU_ARROW_HEIGHT,
59
+ MENU_ARROW_TEXT_PADDING,
60
+ MENU_ARROW_WIDTH,
61
+ MENU_SEPARATOR_WIDTH,
62
+ NAVIGATION_MIN_WIDTH,
63
+ NAVIGATION_WIDTH,
64
+ RECENT_LIMIT,
65
+ RELATIVE_DATE_REFRESH_MS,
66
+ REPOSITORY_BUTTON_MAX_WIDTH,
67
+ REPOSITORY_BUTTON_MIN_TEXT_WIDTH,
68
+ REPOSITORY_HEIGHT,
69
+ REPOSITORY_MIN_HEIGHT,
70
+ REPOSITORY_MIN_WIDTH,
71
+ REPOSITORY_WIDTH,
72
+ SEARCH_DEBOUNCE_MS,
73
+ SPACING_MD,
74
+ SPACING_XS,
75
+ TERMINAL_CONTROL_HEIGHT,
76
+ TOP_BAR_FIELD_STRETCH,
77
+ SharedText,
78
+ control_height,
79
+ fit_window,
80
+ history_row_height,
81
+ keep_selection_visible,
82
+ )
83
+ from gitpane.history.file_history import WINDOW_CAPABILITY_BY_MODE, WindowCapability
84
+ from gitpane.history.history_filter import HISTORY_FILTER_INITIAL_MODE, HistoryFilterMode
85
+ from gitpane.history.repository_value import FilePanel, ReferenceKind
86
+ from gitpane.operation.diagnostic import Diagnostic
87
+ from gitpane.preference.preference_store import FETCH_DEFAULT_PREFERENCE_KEY, FETCH_INITIAL_MODE, FetchMode, fetch_mode_preference, saved_recent_dir, saved_recent_identity
88
+ from gitpane.recent.recent_name import repository_display_name
89
+ from gitpane.reference.stash import DROP_OPERATION_LABEL
90
+ from gitpane.reference.stash_list import stash_base_commit, stash_selector
91
+ from gitpane.sequencer.conflict import ConflictState, conflict_count_text, conflict_path_label
92
+ from gitpane.sequencer.merge_state import ABORT_ACTION, CONTINUE_ACTION, MERGE_IN_PROGRESS_TEXT, merge_action_reason
93
+ from gitpane.sequencer.replay_state import ReplayAction, ReplayKind, replay_action_diagnostic, replay_may_be_present
94
+ from gitpane.submodule.submodule_discovery import SubmoduleSnapshot
95
+ from gitpane.system.path_identity import canonical_path
96
+ from gitpane.text.typed_text import typed_value
97
+ from gitpane.tool.system_tool import worktree_filename
98
+ from gitpane.tool.terminal_recipe import TerminalRegistry
99
+ from gitpane.ui.console.error_dialog import ErrorDialog
100
+ from gitpane.ui.look.action_icon import action_icon
101
+ from gitpane.ui.look.theme_color import is_dark_palette, theme_control_color, theme_line_color
102
+ from gitpane.ui.look.theme_look import follow_theme
103
+ from gitpane.ui.reader.commit_panel import CommitPanel, CopiedHash
104
+ from gitpane.ui.reader.diff_panel import DiffPanel
105
+ from gitpane.ui.reader.file_action import FileActionMenu
106
+ from gitpane.ui.reader.file_tree_panel import FileTreePanel
107
+ from gitpane.ui.reader.revision_tree import FOLDER_PATH_ROLE
108
+ from gitpane.ui.reference.branch_selector import BranchSelector
109
+ from gitpane.ui.repository.graph_delegate import AuthorDelegate, GraphDelegate, reference_label
110
+ from gitpane.ui.repository.history_avatar import HistoryAvatar
111
+ from gitpane.ui.repository.history_model import AUTHOR_COLUMN, COLUMN_WIDTHS, MESSAGE_COLUMN, OID_ROLE, HistoryModel
112
+ from gitpane.ui.repository.repository_list_popup import RepositoryListPopup
113
+ from gitpane.ui.widget.commit_date_text import format_commit_date
114
+ from gitpane.ui.widget.long_text import ELLIPSIS, WrappingLabel
115
+ from gitpane.ui.widget.mnemonic import escape_mnemonic
116
+ from gitpane.ui.widget.plain_tooltip import plain_text_tooltip
117
+ from gitpane.ui.widget.result_status_bar import ResultStatusBar
118
+ from gitpane.ui.widget.system_shortcut import refresh_shortcuts
119
+ from gitpane.ui.widget.unavailable_reason import unavailable_reason_text
120
+ from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
121
+ from gitpane.ui.window_control.item_activation import activate_current_row
122
+ from gitpane.ui.window_control.window_placement import REPOSITORY_ANCHOR_RANK, centre_on_placement_anchor, mark_placement_anchor
123
+ from gitpane.worktree.file_action_target import FileActionSelection, FileActionTargetKind, FileRestoreTarget
124
+ from gitpane.worktree.file_menu_action import FileAction
125
+ from gitpane.worktree.historical_restore import RESTORE_SELECTED_VERSION_TITLE
126
+ from gitpane.worktree.worktree_snapshot import SUBMODULE_DIRTY_DESCRIPTION, WorktreeColorState, WorktreeKey
127
+
128
+ if TYPE_CHECKING:
129
+ from collections.abc import Callable
130
+
131
+ from PySide6.QtGui import QClipboard, QCloseEvent, QKeyEvent, QMouseEvent, QPaintEvent, QResizeEvent, QShowEvent
132
+
133
+ from gitpane.history.avatar import AvatarService
134
+ from gitpane.history.file_history import FileHistoryResult
135
+ from gitpane.history.repository_value import Commit, CommitRelation, HistorySnapshot, RepositoryContext
136
+ from gitpane.reference.stash_list import StashEntry, StashList
137
+ from gitpane.sequencer.merge_state import MergeBannerState
138
+ from gitpane.sequencer.rebase_state import RebaseSnapshot
139
+ from gitpane.sequencer.replay_state import ReplaySnapshot
140
+ from gitpane.worktree.historical_restore import HistoricalRestoreRequest
141
+
142
+ NAVIGATION_COLUMN = 0
143
+ REPOSITORY_ROLE = int(Qt.ItemDataRole.UserRole) + 100
144
+ MINIMUM_COMMIT_PREFIX_LENGTH = 4
145
+ HISTORICAL_SUBJECT_LIMIT = 80
146
+ HISTORICAL_PATH_LABEL_LIMIT = 120
147
+ CONFLICT_BANNER_PATH_LIMIT = 3
148
+ # The background of the conflict banner while a merge waits for its commit: a light blue close to Git Extensions'. Like
149
+ # the orange of the other banners it is fixed and carries black text, so it reads the same in the light and dark themes.
150
+ MERGE_BANNER_COLOR = "#9fcdf5"
151
+ CONFLICT_BANNER_STYLE = "QWidget#conflict_banner { background-color: orange; } QLabel { color: black; }"
152
+ MERGE_BANNER_STYLE = "QWidget#conflict_banner { background-color: " + MERGE_BANNER_COLOR + "; } QLabel { color: black; }"
153
+ # Separates the parts of one status line, such as the history line and a notice kept after it.
154
+ STATUS_PART_SEPARATOR = " — "
155
+ # The kinds of notices kept at the end of the history status line, in the order they are shown: one notice per kind,
156
+ # so a new notice of one kind never hides a notice of another.
157
+ LEGACY_BACKUP_NOTICE_KIND = "legacy_backup"
158
+ COMMIT_NOTICE_KIND = "commit"
159
+ STATUS_NOTICE_KINDS = (LEGACY_BACKUP_NOTICE_KIND, COMMIT_NOTICE_KIND)
160
+ STASH_GROUP_LABEL = "Stashes"
161
+ STASH_UNAVAILABLE_PREFIX = "Stash list unavailable: "
162
+ STASH_CREATED_PREFIX = "Created: "
163
+ STASH_BASE_COMMIT_PREFIX = "Created from: "
164
+ STASH_COMMIT_PREFIX = "Stash commit: "
165
+ REPOSITORY_BUTTON_ACCESSIBLE_NAME = "Current repository"
166
+ # The Commit tab is the first one added to the detail tabs; a window starts on it.
167
+ COMMIT_TAB_INDEX = 0
168
+ # Text of the Commit action until the working tree of the displayed repository has been counted.
169
+ COMMIT_UNKNOWN_COUNT_LABEL = "Commit(?)"
170
+ # Horizontal and vertical scroll offsets of the submodule tree before the user scrolls it: the top left corner.
171
+ SUBMODULE_SCROLL_START = (0, 0)
172
+ # Reference groups of the navigation tree, in the order the tree shows them, each titled with the label of its kind.
173
+ BRANCH_TREE_GROUP_KINDS = (ReferenceKind.HEADS, ReferenceKind.REMOTES, ReferenceKind.TAGS, ReferenceKind.HEAD)
174
+
175
+
176
+ def _create_top_bar_separator() -> QFrame:
177
+ """Separate toolbar groups with the same decorative native frame."""
178
+ separator = QFrame()
179
+ separator.setFrameShape(QFrame.Shape.VLine)
180
+ separator.setFrameShadow(QFrame.Shadow.Sunken)
181
+ separator.setFocusPolicy(Qt.FocusPolicy.NoFocus)
182
+ return separator
183
+
184
+
185
+ def repository_window_title(repository_dir: str) -> str:
186
+ """Return the title of a repository window, so opening and changing repository name the window the same way."""
187
+ return f"{repository_display_name(repository_dir)} — {repository_dir} — GitPane"
188
+
189
+
190
+ def repository_menu_dirs(current_dir: str, recent_dirs: list[str]) -> list[str]:
191
+ """Return the repositories the switch list offers: the current one first, then the history in its order.
192
+
193
+ A repository already listed is skipped, compared by the key of the recent history, and the list stops at the history
194
+ limit. Only the open repository is resolved on disk, into the form the history saves. The history is compared as
195
+ text, since the preference store already saves it resolved: resolving each entry again would touch the disk, and a
196
+ disconnected network drive would freeze the window.
197
+ """
198
+ menu_dirs = [current_dir]
199
+ listed_directory_set = {saved_recent_identity(saved_recent_dir(current_dir))}
200
+ for recent_dir in recent_dirs:
201
+ if len(menu_dirs) >= RECENT_LIMIT:
202
+ break
203
+ directory = saved_recent_identity(recent_dir)
204
+ if directory in listed_directory_set:
205
+ continue
206
+ listed_directory_set.add(directory)
207
+ menu_dirs.append(recent_dir)
208
+ return menu_dirs
209
+
210
+
211
+ class RepositoryWindowText(Enum):
212
+ """Texts the window of an open repository shows, one member per text."""
213
+
214
+ OPEN_ANOTHER = "Open or clone…"
215
+ EXPLORER = "Open in file explorer"
216
+ TERMINAL = "Open terminal"
217
+ TERMINAL_DETECT = "Detect terminals"
218
+ TERMINAL_EMPTY = "No terminals available"
219
+ FILTER_SEARCH_HELP = "Case-insensitive search; * matches any text. Other characters are literal."
220
+ FILTER_CLEAR = "Clear filter"
221
+ FILTER_PREVIOUS = "Previous results — searching"
222
+ HISTORY_LOADING = "Loading history…"
223
+ REPOSITORY_EMPTY = "No commits in this repository"
224
+
225
+
226
+ # Side in pixels of the transparent icon that makes the text-only filter mode button lay its text out beside an icon.
227
+ SPACER_ICON_SIDE = 1
228
+ # Pixels Fusion puts between a tool button's icon and its text.
229
+ FUSION_ICON_TEXT_SPACING = 4
230
+ # Space advances QToolButton adds after its text when it measures it.
231
+ TOOL_BUTTON_TEXT_SPACE_COUNT = 2
232
+
233
+
234
+ def terminal_button_style_sheet(palette: QPalette) -> str:
235
+ """Return the style sheet of the tool bar buttons that have a menu part: Fetch, Open terminal, the repository button and the filter mode button.
236
+
237
+ A light theme keeps the palette's mid tone for the hover separator and its mid-light tone for the hover background.
238
+ In a dark theme those two tones are almost the tool bar's own color and the hover could not be seen, so they take
239
+ the theme's line grey and control grey, worked out from ``palette``.
240
+ """
241
+ separator_color, hover_color = "palette(mid)", "palette(midlight)"
242
+ if is_dark_palette(palette):
243
+ separator_color, hover_color = theme_line_color(palette).name(), theme_control_color(palette).name()
244
+ # Workaround: Fusion draws its own arrow in the menu area and ignores the size asked for it, which shows as a
245
+ # small grey dot. The style arrow is therefore hidden and paintEvent paints a filled triangle at the measured size.
246
+ # The menu area keeps a transparent left border when not hovered so that it has the same width in both states
247
+ # and the triangle does not move when the hover separator appears.
248
+ # A native button border makes Qt ignore the reserved content padding on Windows, hence "border: none".
249
+ return (f"QToolButton {{ border: none; padding: {SPACING_XS}px; padding-right: {MENU_ARROW_TEXT_PADDING}px; }} "
250
+ f"QToolButton::menu-button {{ border: none; border-left: {MENU_SEPARATOR_WIDTH}px solid transparent; width: {MENU_ARROW_AREA_WIDTH}px; }} "
251
+ f"QToolButton::menu-button:hover {{ border-left: {MENU_SEPARATOR_WIDTH}px solid {separator_color}; }} "
252
+ "QToolButton::menu-arrow { image: none; } "
253
+ f"QToolButton:hover {{ background: {hover_color}; }}")
254
+
255
+
256
+ class TerminalButton(QToolButton):
257
+ """Two-zone toolbar button: the text runs the action, the arrow opens the menu.
258
+
259
+ Used by Fetch and Open terminal, and by the filter mode button, whose text opens the menu too. The menu stays
260
+ reachable even while the main action is unavailable.
261
+ """
262
+
263
+ def __init__(self) -> None:
264
+ """Reserve an independent arrow target and retain native keyboard menu handling."""
265
+ super().__init__()
266
+ self.main_enabled = False
267
+ self.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
268
+ self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
269
+ self.setMinimumHeight(TERMINAL_CONTROL_HEIGHT)
270
+ # Workaround: the style sheet hides Fusion's arrow and keeps the menu area's width in both states; see
271
+ # terminal_button_style_sheet, which the repository window lays again at each theme switch.
272
+ self.setStyleSheet(terminal_button_style_sheet(QApplication.palette()))
273
+
274
+ def menu_area(self) -> QRect:
275
+ """Return the menu part of the button: where the arrow is painted and where a click opens the menu."""
276
+ option = QStyleOptionToolButton()
277
+ self.initStyleOption(option)
278
+ return self.style().subControlRect(QStyle.ComplexControl.CC_ToolButton, option, QStyle.SubControl.SC_ToolButtonMenu, self)
279
+
280
+ def paintEvent(self, event: QPaintEvent) -> None:
281
+ """Paint the native button, then a filled down triangle centred in the menu area in the button text colour."""
282
+ super().paintEvent(event)
283
+ menu_rect = self.menu_area()
284
+ color_group = QPalette.ColorGroup.Normal if self.isEnabled() else QPalette.ColorGroup.Disabled
285
+ left = menu_rect.left() + (menu_rect.width() - MENU_ARROW_WIDTH) // 2
286
+ top = menu_rect.top() + (menu_rect.height() - MENU_ARROW_HEIGHT) // 2
287
+ painter = QPainter(self)
288
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
289
+ painter.setPen(Qt.PenStyle.NoPen)
290
+ painter.setBrush(self.palette().color(color_group, QPalette.ColorRole.ButtonText))
291
+ painter.drawPolygon(QPolygon([QPoint(left, top), QPoint(left + MENU_ARROW_WIDTH, top), QPoint(left + MENU_ARROW_WIDTH // 2, top + MENU_ARROW_HEIGHT)]))
292
+ painter.end()
293
+
294
+ def mousePressEvent(self, event: QMouseEvent) -> None:
295
+ """Block only the primary region, preserving the menu when no tool can start."""
296
+ if not self.main_enabled and not self.menu_area().contains(event.position().toPoint()):
297
+ event.ignore()
298
+ return
299
+ super().mousePressEvent(event)
300
+
301
+ def keyPressEvent(self, event: QKeyEvent) -> None:
302
+ """Allow the menu shortcut even when Enter and Space cannot launch a tool."""
303
+ if event.key() == Qt.Key.Key_Down and event.modifiers() & Qt.KeyboardModifier.AltModifier:
304
+ self.showMenu()
305
+ event.accept()
306
+ return
307
+ if not self.main_enabled and event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Return, Qt.Key.Key_Enter):
308
+ event.ignore()
309
+ return
310
+ super().keyPressEvent(event)
311
+
312
+
313
+ class FilterModeButton(TerminalButton):
314
+ """Two-zone button of the top bar that shows what the filter searches; its text and its arrow both open the menu of modes."""
315
+
316
+ def __init__(self) -> None:
317
+ """Open the menu from the text too, since choosing a mode is the button's only use."""
318
+ super().__init__()
319
+ self.main_enabled = True
320
+ self.clicked.connect(self.showMenu)
321
+ # Workaround: a tool button without an icon centres its text over the whole button, menu area included, so the
322
+ # text ran under the painted arrow. A transparent icon one pixel wide makes Fusion draw the text beside an
323
+ # icon, left-aligned, as on Fetch and Open terminal.
324
+ pixmap = QPixmap(QSize(SPACER_ICON_SIDE, SPACER_ICON_SIDE))
325
+ pixmap.fill(Qt.GlobalColor.transparent)
326
+ self.setIcon(QIcon(pixmap))
327
+ self.setIconSize(QSize(SPACER_ICON_SIDE, SPACER_ICON_SIDE))
328
+
329
+ def sizeHint(self) -> QSize:
330
+ """Ask for the room of the left padding, the spacer icon, the text and its two trailing spaces, the right padding and the menu area.
331
+
332
+ Workaround: outside a tool bar, Qt's size hint of this button leaves out most of the menu area, so the arrow
333
+ covered the end of the text. The width is the layout the menu buttons of the tool bar get, written out here.
334
+ """
335
+ native_size = QToolButton.sizeHint(self)
336
+ text_width = self.fontMetrics().size(Qt.TextFlag.TextShowMnemonic, self.text()).width() + TOOL_BUTTON_TEXT_SPACE_COUNT * self.fontMetrics().horizontalAdvance(" ")
337
+ menu_area_width = MENU_SEPARATOR_WIDTH + MENU_ARROW_AREA_WIDTH
338
+ width = SPACING_XS + SPACER_ICON_SIDE + FUSION_ICON_TEXT_SPACING + text_width + MENU_ARROW_TEXT_PADDING + menu_area_width
339
+ return QSize(max(width, native_size.width()), native_size.height())
340
+
341
+
342
+ class RepositorySwitchButton(TerminalButton):
343
+ """Button showing the current repository, like the working directory button of Git Extensions.
344
+
345
+ Both its text and its arrow open the list of recently opened repositories; choosing one opens it in the same window.
346
+ The button only asks for the list: the window owns it, since the window knows the history.
347
+ """
348
+
349
+ list_requested = Signal()
350
+
351
+ # Declared on the class because the base constructor sets a style sheet, whose change event elides the path
352
+ # before this constructor runs.
353
+ repository_dir: str = ""
354
+
355
+ def __init__(self) -> None:
356
+ """Make the text zone open the list too, since the button has no action of its own; Space opens it as well."""
357
+ super().__init__()
358
+ self.main_enabled = True
359
+ self.setAccessibleName(REPOSITORY_BUTTON_ACCESSIBLE_NAME)
360
+ self.clicked.connect(self.list_requested)
361
+
362
+ def mousePressEvent(self, event: QMouseEvent) -> None:
363
+ """Ask for the list on a left press anywhere on the button, text or arrow.
364
+
365
+ The native press is not run: the button has no Qt menu, and the native press on the arrow would try to open one
366
+ and do nothing.
367
+ """
368
+ if event.button() != Qt.MouseButton.LeftButton:
369
+ super().mousePressEvent(event)
370
+ return
371
+ event.accept()
372
+ self.list_requested.emit()
373
+
374
+ def showMenu(self) -> None:
375
+ """Ask for the list where Qt or the keyboard handling would open the menu of the button, as Alt+Down does."""
376
+ self.list_requested.emit()
377
+
378
+ def show_path(self, repository_dir: str) -> None:
379
+ """Show the path of the repository the window displays: in full in the tooltip and the accessible description,
380
+ and in the text as far as the button width allows."""
381
+ self.repository_dir = repository_dir
382
+ self.setToolTip(plain_text_tooltip([repository_dir]))
383
+ self.setAccessibleDescription(repository_dir)
384
+ self._elide_text()
385
+ self.updateGeometry()
386
+
387
+ def _text_width(self, text: str) -> int:
388
+ """Return the width the button draws for a text, measured as Qt measures a text with mnemonics."""
389
+ return self.fontMetrics().size(Qt.TextFlag.TextShowMnemonic, text).width()
390
+
391
+ def _chrome_width(self) -> int:
392
+ """Return the width the button takes around its text: padding, icon and menu area, as the native button measures them."""
393
+ return QToolButton.sizeHint(self).width() - self._text_width(self.text())
394
+
395
+ def _elide_text(self) -> None:
396
+ """Write the path elided in the middle for the current width, so the start of the path and the repository name stay readable.
397
+
398
+ The path is elided first and each "&" doubled afterwards, so the elision measures the text the user sees.
399
+ """
400
+ available_width = max(self.width() - self._chrome_width(), 0)
401
+ elided_dir = self.fontMetrics().elidedText(self.repository_dir, Qt.TextElideMode.ElideMiddle, available_width)
402
+ # Qt returns an empty text when not even the ellipsis fits; the button keeps the ellipsis so that its width
403
+ # around the text, measured from its current text, stays right.
404
+ text = escape_mnemonic(elided_dir) if elided_dir or not self.repository_dir else ELLIPSIS
405
+ if text != self.text():
406
+ self.setText(text)
407
+
408
+ def sizeHint(self) -> QSize:
409
+ """Ask for the width of the whole path, up to the maximum width of the button, so the fields beside it keep room.
410
+
411
+ The size depends only on the repository path, never on the elided text, so eliding does not start a new layout pass.
412
+ """
413
+ native_size = QToolButton.sizeHint(self)
414
+ full_width = self._chrome_width() + self._text_width(escape_mnemonic(self.repository_dir))
415
+ return QSize(min(full_width, REPOSITORY_BUTTON_MAX_WIDTH), native_size.height())
416
+
417
+ def minimumSizeHint(self) -> QSize:
418
+ """Allow the button to shrink to a few characters of the path, so the fields beside it keep their minimum width."""
419
+ native_size = QToolButton.sizeHint(self)
420
+ return QSize(self._chrome_width() + REPOSITORY_BUTTON_MIN_TEXT_WIDTH, native_size.height())
421
+
422
+ def resizeEvent(self, event: QResizeEvent) -> None:
423
+ """Elide the path again for the new width."""
424
+ super().resizeEvent(event)
425
+ self._elide_text()
426
+
427
+ def changeEvent(self, event: QEvent) -> None:
428
+ """Elide the path again when the font or the style changes, since both change the width the text takes."""
429
+ super().changeEvent(event)
430
+ if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange):
431
+ self._elide_text()
432
+
433
+
434
+ class HistoryView(QTableView):
435
+ """Provide whole-row keyboard selection over the native history table."""
436
+
437
+ menu_requested = Signal(object, object)
438
+
439
+ def __init__(self) -> None:
440
+ """Use available width for subjects while retaining the user's chosen minimum width."""
441
+ super().__init__()
442
+ self.subject_width = COLUMN_WIDTHS[MESSAGE_COLUMN]
443
+ self.graph_width = 0
444
+ self.resizing_subject = False
445
+ self.horizontalHeader().sectionResized.connect(self._column_resized)
446
+
447
+ def resizeEvent(self, event: QResizeEvent) -> None:
448
+ """Grow the subject column on wide windows and keep horizontal scrolling on narrow ones."""
449
+ super().resizeEvent(event)
450
+ self.resize_subject()
451
+
452
+ def resize_subject(self) -> None:
453
+ """Fill spare viewport space without changing fixed metadata widths."""
454
+ if self.model() is None or self.resizing_subject:
455
+ return
456
+ remaining = self.viewport().width()
457
+ for column in range(self.model().columnCount()):
458
+ if column != MESSAGE_COLUMN:
459
+ remaining -= self.columnWidth(column)
460
+ self.resizing_subject = True
461
+ try:
462
+ self.setColumnWidth(MESSAGE_COLUMN, max(self.subject_width, self.graph_width + LAYOUT_SPACING, remaining))
463
+ finally:
464
+ self.resizing_subject = False
465
+
466
+ def _column_resized(self, column: int, previous_width: int, width: int) -> None:
467
+ """Preserve an explicit subject resize and compensate when other columns change."""
468
+ if self.resizing_subject:
469
+ return
470
+ if column == MESSAGE_COLUMN:
471
+ self.subject_width = width
472
+ self.resize_subject()
473
+
474
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
475
+ """Bind a mouse menu to the clicked row and a keyboard menu to the selection."""
476
+ if event.reason() == event.Reason.Keyboard:
477
+ index = self.currentIndex()
478
+ else:
479
+ index = self.indexAt(event.pos())
480
+ self.menu_requested.emit(index, event.globalPos())
481
+ event.accept()
482
+
483
+ def keyPressEvent(self, event: QKeyEvent) -> None:
484
+ """Open the same menu for Menu and Shift+F10 on every desktop platform."""
485
+ if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_End) and self.model().rowCount():
486
+ row = FIRST_ROW if event.key() == Qt.Key.Key_Home else self.model().rowCount() - 1
487
+ self.setCurrentRow(row)
488
+ event.accept()
489
+ return
490
+ if event.key() == Qt.Key.Key_Menu or (event.key() == Qt.Key.Key_F10 and event.modifiers() & Qt.KeyboardModifier.ShiftModifier):
491
+ index = self.currentIndex()
492
+ self.menu_requested.emit(index, self.viewport().mapToGlobal(self.visualRect(index).center()))
493
+ event.accept()
494
+ return
495
+ super().keyPressEvent(event)
496
+
497
+ def setCurrentRow(self, row: int) -> None:
498
+ """Select a complete row and reveal it for programmatic navigation."""
499
+ self.selectionModel().setCurrentIndex(self.model().index(row, MESSAGE_COLUMN), QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
500
+
501
+ def count(self) -> int:
502
+ """Return selectable commits using the same convention as the opening list."""
503
+ return self.model().rowCount()
504
+
505
+
506
+ @dataclass(frozen=True)
507
+ class ViewAnchor:
508
+ """Remember commit identity and pixel position independently of inserted rows."""
509
+
510
+ selected_oid: str | None
511
+ selected_visible_index: int
512
+ top_oid: str | None
513
+ top_visible_index: int
514
+ top_pixel_offset: int
515
+ horizontal_offset: int
516
+ filter_text: str
517
+ filter_mode: HistoryFilterMode
518
+
519
+
520
+ class RepositoryWindow(QMainWindow):
521
+ """Show a real selected commit and its complete text in a resizable layout."""
522
+
523
+ closed = Signal()
524
+ open_requested = Signal()
525
+ refresh_requested = Signal()
526
+ setting_requested = Signal()
527
+ commit_selected = Signal(str)
528
+ navigation_requested = Signal(str)
529
+ repository_activated = Signal(str)
530
+ repository_switch_requested = Signal(str)
531
+ filter_changed = Signal(str)
532
+ search_requested = Signal()
533
+ detail_retry_requested = Signal(str)
534
+ explorer_requested = Signal()
535
+ terminal_requested = Signal(object)
536
+ file_history_requested = Signal(object)
537
+ file_folder_requested = Signal(object)
538
+ file_text_editor_requested = Signal(object)
539
+ file_save_requested = Signal(object)
540
+ difftool_requested = Signal()
541
+ file_history_resume_requested = Signal()
542
+ reading_preference_requested = Signal(object)
543
+ commit_requested = Signal()
544
+ file_restore_requested = Signal(object, object)
545
+ remove_history_requested = Signal(object)
546
+ checkout_requested = Signal(str)
547
+ checkout_commit_requested = Signal(str, object)
548
+ checkout_branch_requested = Signal(str, str, object)
549
+ create_branch_requested = Signal(str, object)
550
+ create_tag_requested = Signal(str, object)
551
+ remote_requested = Signal()
552
+ emoji_keyboard_requested = Signal()
553
+ fetch_requested = Signal()
554
+ stash_requested = Signal()
555
+ resolve_requested = Signal(object)
556
+ merge_continue_requested = Signal()
557
+ merge_abort_requested = Signal()
558
+ rebase_requested = Signal()
559
+ replay_refresh_requested = Signal()
560
+ replay_resolve_requested = Signal(object)
561
+ replay_continue_requested = Signal(object)
562
+ replay_abort_requested = Signal(object)
563
+ replay_requested = Signal(str, object, object)
564
+ rebase_start_requested = Signal(str, object)
565
+ merge_recovery_requested = Signal()
566
+ stash_pop_requested = Signal()
567
+ stash_drop_requested = Signal(object)
568
+ push_requested = Signal()
569
+ fetch_menu_requested = Signal()
570
+ fetch_selection_requested = Signal(object)
571
+ fetch_default_requested = Signal(object)
572
+ reset_requested = Signal(str, object)
573
+ move_branch_requested = Signal(str, object)
574
+ rename_branch_requested = Signal(str, str, object)
575
+ delete_branch_requested = Signal(str, str, object)
576
+ delete_tag_requested = Signal(str, str, object)
577
+
578
+ def __init__(self, context: RepositoryContext, commit: Commit | None, clipboard: QClipboard | None = None, mode: str = "repository",
579
+ placement_anchor: QWidget | None = None, *, clock: Callable[[], datetime] | None = None) -> None:
580
+ """Present repository data without knowing how Git produced it.
581
+
582
+ ``placement_anchor`` is the GitPane window this one was opened from (Open repository, Clone repository, the
583
+ repository window or Prepare changes asking for a file history); the window opens centred on it, on its screen.
584
+ ``clock`` gives the current time the history rows and the commit details write their relative dates against; it
585
+ is injected so those dates can be reproduced, as the window gallery needs, and reads the system time when absent.
586
+ """
587
+ super().__init__()
588
+ # Set before any message, so every message of the window goes through the bar that can colour a failure.
589
+ self.setStatusBar(ResultStatusBar(self))
590
+ mark_placement_anchor(self, REPOSITORY_ANCHOR_RANK)
591
+ self.placement_anchor = placement_anchor
592
+ self.centred = False
593
+ self.context = context
594
+ self.mode = mode
595
+ self.is_closed = False
596
+ self.capabilities = WINDOW_CAPABILITY_BY_MODE[mode]
597
+ self.file_menu: FileActionMenu | None = None
598
+ # Set by the file action controller, which reads the preferences and the disk; the window only shows the reason it returns.
599
+ self.text_editor_reason: Callable[[FileActionSelection], str] | None = None
600
+ self.remove_history_reason: Callable[[FileActionSelection], str] | None = None
601
+ self.file_history_result: FileHistoryResult | None = None
602
+ self.clipboard = clipboard
603
+ self.copy_dialog: ErrorDialog | None = None
604
+ self.history_menu: QMenu | None = None
605
+ self.navigation_menu: QMenu | None = None
606
+ self.commit = commit
607
+ self.commit_relation: CommitRelation | None = None
608
+ self.pending_oid: str | None = None
609
+ # The commit Go to commit hash asked for, drawn in the middle of the graph once its row is revealed. Only that
610
+ # path centers: centering always scrolls, even for a row already visible, so the graph would jump at every
611
+ # branch click or Refresh.
612
+ self.centered_oid: str | None = None
613
+ self.anchor_oid: str | None = None
614
+ self.snapshot: HistorySnapshot | None = None
615
+ self.submodule_snapshot: SubmoduleSnapshot | None = None
616
+ self.submodule_item_by_key: dict[str, QTreeWidgetItem] = {}
617
+ self.submodule_expanded_by_key: dict[str, bool] = {}
618
+ self.submodule_selected_key: str | None = None
619
+ self.submodule_scroll = SUBMODULE_SCROLL_START
620
+ # The stashes last read with the references, and the left-panel group showing them.
621
+ self.stash_list: StashList | None = None
622
+ # The repository the stash list was read from: a window that moves to a submodule or back to its parent shows
623
+ # another repository before that repository's stashes are read.
624
+ self.stash_list_context: RepositoryContext | None = None
625
+ self.stash_group: QTreeWidgetItem | None = None
626
+ # The expanded state the user gave the Stashes group by hand in this window, or None while they have not touched
627
+ # it: the group is then expanded exactly when it holds a stash. Kept here rather than read back from the group,
628
+ # because the group is rebuilt with every reference read and its label changes with the count.
629
+ self.stash_group_user_expanded: bool | None = None
630
+ # True while the window itself expands or collapses the Stashes group. Qt reports those changes through the same
631
+ # itemExpanded and itemCollapsed signals as a click on the arrow, and they must not be taken for the user's choice.
632
+ self.applying_stash_expansion = False
633
+ # The stashes written in the group, in the order of its lines, so their tooltips can be rewritten.
634
+ self.shown_stash_entries: tuple[StashEntry, ...] = ()
635
+ self.graph_index_complete = False
636
+ self.filter_text = ""
637
+ self.search_pending = False
638
+ # Notices kept at the end of the history status line until the window closes, each for its own working tree:
639
+ # the window can navigate to a submodule and back, and a notice only belongs to the repository it names.
640
+ self.status_notice_by_kind_by_worktree: dict[WorktreeKey, dict[str, str]] = {}
641
+ self.terminal_registry: TerminalRegistry | None = None
642
+ self.terminal_busy = False
643
+ self.terminal_dialog: ErrorDialog | None = None
644
+ self.setWindowTitle(repository_window_title(context.repository_dir))
645
+ self.resize(REPOSITORY_WIDTH, REPOSITORY_HEIGHT)
646
+ file_menu = self.menuBar().addMenu("File")
647
+ self.open_action = QAction(RepositoryWindowText.OPEN_ANOTHER.value, self)
648
+ self.open_action.triggered.connect(self.open_requested.emit)
649
+ self.open_action.setShortcut("Ctrl+O")
650
+ self.setting_action = QAction(SharedText.SETTING.value, self)
651
+ self.setting_action.triggered.connect(self.setting_requested.emit)
652
+ self.close_action = QAction("Close", self)
653
+ self.close_action.triggered.connect(self.close)
654
+ self.close_action.setShortcut("Ctrl+W")
655
+ file_menu.addAction(self.open_action)
656
+ toolbar = self.addToolBar("Repository")
657
+ self.action_toolbar = toolbar
658
+ toolbar.setMovable(False)
659
+ toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
660
+ toolbar.addAction(self.open_action)
661
+ self.refresh_action = toolbar.addAction(SharedText.REFRESH.value, self.refresh_requested.emit)
662
+ self.refresh_action.setIcon(action_icon("refresh", self.devicePixelRatioF()))
663
+ self.branch_selector = BranchSelector(self)
664
+ self.branch_selector_action = toolbar.addWidget(self.branch_selector)
665
+ self.branch_selector.activated.connect(self._branch_activated)
666
+ self.fetch_mode = FETCH_INITIAL_MODE
667
+ self.fetch_busy = False
668
+ self.fetch_snapshot = None
669
+ self.fetch_remote_name = ""
670
+ self.fetch_button = TerminalButton()
671
+ self.fetch_button.setText("Fetch")
672
+ self.fetch_button.setAccessibleName("Fetch")
673
+ self.fetch_button.main_enabled = WindowCapability.MANAGE in self.capabilities
674
+ self.fetch_button.clicked.connect(self._request_fetch)
675
+ self.fetch_menu = QMenu("Fetch", self)
676
+ self.fetch_menu.setToolTipsVisible(True)
677
+ self.fetch_menu.setStyleSheet("QMenu { menu-scrollable: 1; }")
678
+ self.fetch_button.setMenu(self.fetch_menu)
679
+ self.fetch_choice_action_by_mode: dict[FetchMode, QAction] = {}
680
+ for fetch_mode in FetchMode:
681
+ action = self.fetch_menu.addAction(fetch_mode.label)
682
+ action.setCheckable(True)
683
+ action.triggered.connect(lambda checked=False, mode=fetch_mode: self._request_fetch_choice(mode))
684
+ self.fetch_choice_action_by_mode[fetch_mode] = action
685
+ self.fetch_default_menu = self.fetch_menu.addMenu("Set as default")
686
+ self.fetch_default_action_by_mode: dict[FetchMode, QAction] = {}
687
+ for fetch_mode in FetchMode:
688
+ action = self.fetch_default_menu.addAction(fetch_mode.label)
689
+ action.setCheckable(True)
690
+ action.triggered.connect(lambda checked=False, mode=fetch_mode: self.fetch_default_requested.emit(mode))
691
+ self.fetch_default_action_by_mode[fetch_mode] = action
692
+ self.fetch_remote_menu = QMenu("Choose a remote", self.fetch_menu)
693
+ self.fetch_remote_menu.setToolTipsVisible(True)
694
+ self.fetch_remote_menu.setStyleSheet("QMenu { menu-scrollable: 1; }")
695
+ self.fetch_menu.aboutToShow.connect(self.fetch_menu_requested.emit)
696
+ self.fetch_action = toolbar.addWidget(self.fetch_button)
697
+ self.fetch_action.setText("Fetch")
698
+ self.fetch_action.triggered.connect(self._request_fetch)
699
+ self.fetch_action.setVisible(WindowCapability.MANAGE in self.capabilities)
700
+ self.fetch_action.setEnabled(WindowCapability.MANAGE in self.capabilities)
701
+ self.apply_fetch_preference({FETCH_DEFAULT_PREFERENCE_KEY: FETCH_INITIAL_MODE.value})
702
+ self.push_action = toolbar.addAction(action_icon("push", self.devicePixelRatioF()), "Push", self._request_push)
703
+ self.push_action.setVisible(WindowCapability.MANAGE in self.capabilities)
704
+ self.push_action.setEnabled(WindowCapability.MANAGE in self.capabilities)
705
+ self.commit_action = toolbar.addAction(COMMIT_UNKNOWN_COUNT_LABEL, self.commit_requested.emit)
706
+ self.commit_action.setIcon(action_icon("commit", self.devicePixelRatioF()))
707
+ self.commit_action.setVisible(WindowCapability.MANAGE in self.capabilities)
708
+ self.stash_action = toolbar.addAction(action_icon("stash", self.devicePixelRatioF()), "Stash", self._request_stash)
709
+ self.stash_action.setVisible(WindowCapability.MANAGE in self.capabilities)
710
+ self.stash_pop_action = toolbar.addAction(action_icon("stash_pop", self.devicePixelRatioF()), "Stash pop", self._request_stash_pop)
711
+ self.stash_pop_action.setVisible(WindowCapability.MANAGE in self.capabilities)
712
+ self._apply_repository_capability()
713
+ refresh_sequences: list[QKeySequence] = []
714
+ for refresh_shortcut in refresh_shortcuts():
715
+ refresh_sequences.append(QKeySequence(refresh_shortcut))
716
+ self.refresh_action.setShortcuts(refresh_sequences)
717
+ file_menu.addAction(self.refresh_action)
718
+ file_menu.addAction(self.commit_action)
719
+ self.explorer_action = toolbar.addAction(RepositoryWindowText.EXPLORER.value, self.explorer_requested.emit)
720
+ file_menu.addAction(self.explorer_action)
721
+ self.terminal_button = TerminalButton()
722
+ self.terminal_button.setText(RepositoryWindowText.TERMINAL.value)
723
+ self.terminal_button.clicked.connect(self._request_terminal)
724
+ self.terminal_menu = QMenu(SharedText.TERMINAL_CHOOSE.value, self)
725
+ self.terminal_menu.setToolTipsVisible(True)
726
+ self.terminal_menu.setStyleSheet("QMenu { menu-scrollable: 1; }")
727
+ self.terminal_button.setMenu(self.terminal_menu)
728
+ self.terminal_action = QAction(RepositoryWindowText.TERMINAL.value, self)
729
+ self.terminal_action.triggered.connect(self._request_terminal)
730
+ self.terminal_action.setEnabled(False)
731
+ self.terminal_group = QActionGroup(self)
732
+ self.terminal_group.setExclusive(True)
733
+ self.terminal_choice_actions: list[QAction] = []
734
+ self.redetect_action = QAction(RepositoryWindowText.TERMINAL_DETECT.value, self)
735
+ self.redetect_action.triggered.connect(self._redetect_terminal)
736
+ self.terminal_menu.addAction(self.redetect_action)
737
+ toolbar.addWidget(self.terminal_button)
738
+ toolbar.addAction(self.setting_action)
739
+ file_menu.addAction(self.terminal_action)
740
+ file_menu.addMenu(self.terminal_menu)
741
+ file_menu.addAction(self.setting_action)
742
+ file_menu.addAction(self.close_action)
743
+ self.addToolBarBreak()
744
+ filter_toolbar = self.addToolBar("Message filter")
745
+ filter_toolbar.setMovable(False)
746
+ # The repositories the switch list offers, as last received; the list is rebuilt from them each time it opens.
747
+ self.recent_dirs: list[str] = []
748
+ # Built on the first opening only, since many windows are never switched.
749
+ self.repository_list_popup: RepositoryListPopup | None = None
750
+ self.repository_button = RepositorySwitchButton()
751
+ self.repository_button.list_requested.connect(self._show_repository_list)
752
+ self.repository_button.show_path(context.repository_dir)
753
+ # A file history window stays on its repository: the button shows it but cannot change it.
754
+ self.repository_button.setEnabled(WindowCapability.MANAGE in self.capabilities)
755
+ follow_theme(self, self._apply_menu_button_look)
756
+ self.filter_edit = QLineEdit()
757
+ self.filter_edit.setToolTip(RepositoryWindowText.FILTER_SEARCH_HELP.value)
758
+ self.filter_edit.setMinimumWidth(FILTER_FIELD_MIN_WIDTH)
759
+ self.clear_filter_button = QPushButton(RepositoryWindowText.FILTER_CLEAR.value)
760
+ # What the filter field searches in each commit; never saved, so every opening starts on the initial mode.
761
+ self.filter_mode = HISTORY_FILTER_INITIAL_MODE
762
+ self.filter_mode_button = FilterModeButton()
763
+ self.filter_mode_menu = QMenu(self)
764
+ self.filter_mode_group = QActionGroup(self)
765
+ self.filter_mode_group.setExclusive(True)
766
+ self.filter_mode_action_by_mode: dict[HistoryFilterMode, QAction] = {}
767
+ for filter_mode in HistoryFilterMode:
768
+ action = self.filter_mode_menu.addAction(filter_mode.label)
769
+ action.setCheckable(True)
770
+ self.filter_mode_group.addAction(action)
771
+ action.triggered.connect(lambda checked=False, mode=filter_mode: self.choose_filter_mode(mode))
772
+ self.filter_mode_action_by_mode[filter_mode] = action
773
+ self.filter_mode_button.setMenu(self.filter_mode_menu)
774
+ self.show_filter_mode(HISTORY_FILTER_INITIAL_MODE)
775
+ self.commit_hash_edit = QLineEdit()
776
+ self.commit_hash_edit.setPlaceholderText("Full hash or unique prefix")
777
+ self.commit_hash_edit.setAccessibleName("Go to commit hash")
778
+ self.commit_hash_edit.setMinimumWidth(HASH_FIELD_MIN_WIDTH)
779
+ hash_label = QLabel("Go to commit hash")
780
+ hash_label.setBuddy(self.commit_hash_edit)
781
+ self.go_button = QPushButton("Go")
782
+ # Workaround: a toolbar never shrinks one of its widgets below the width it asks for, and sends the widgets that
783
+ # do not fit into its extension menu, so a long repository path pushed the hash field out of sight. The whole bar
784
+ # is therefore one row widget whose layout shares the width: the fields keep their minimum and the repository
785
+ # button elides its path in the middle.
786
+ self.top_bar_row = QWidget()
787
+ self.top_bar_row.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
788
+ top_bar_layout = QHBoxLayout(self.top_bar_row)
789
+ top_bar_layout.setContentsMargins(0, 0, 0, 0)
790
+ top_bar_layout.setSpacing(SPACING_XS)
791
+ top_bar_layout.addWidget(self.repository_button)
792
+ top_bar_layout.addWidget(_create_top_bar_separator())
793
+ top_bar_layout.addWidget(self.filter_mode_button)
794
+ top_bar_layout.addWidget(self.filter_edit, TOP_BAR_FIELD_STRETCH)
795
+ top_bar_layout.addWidget(self.clear_filter_button)
796
+ top_bar_layout.addWidget(_create_top_bar_separator())
797
+ top_bar_layout.addWidget(hash_label)
798
+ top_bar_layout.addWidget(self.commit_hash_edit, TOP_BAR_FIELD_STRETCH)
799
+ top_bar_layout.addWidget(self.go_button)
800
+ self.go_button.clicked.connect(self.go_to_commit)
801
+ self.commit_hash_edit.returnPressed.connect(self.go_to_commit)
802
+ self.pending_navigation_oid: str | None = None
803
+ self.search_timer = QTimer(self)
804
+ self.search_timer.setSingleShot(True)
805
+ self.search_timer.setInterval(SEARCH_DEBOUNCE_MS)
806
+ self.search_timer.timeout.connect(self._request_search)
807
+ self.filter_edit.textChanged.connect(self._filter_edited)
808
+ self.filter_edit.returnPressed.connect(self._request_search)
809
+ self.clear_filter_button.clicked.connect(self.clear_filter)
810
+ self.filter_edit.installEventFilter(self)
811
+ self.find_action = QAction("Find in active text", self)
812
+ self.find_action.setShortcut(FIND_SHORTCUT)
813
+ self.find_action.triggered.connect(self.find_active_content)
814
+ self.addAction(self.find_action)
815
+ file_menu.addAction(self.find_action)
816
+ self.filter_action = QAction("Filter commit history", self)
817
+ self.filter_action.setShortcut("Ctrl+Shift+F")
818
+ self.filter_action.triggered.connect(self.focus_filter)
819
+ file_menu.addAction(self.filter_action)
820
+ self.clear_filter_action = QAction(RepositoryWindowText.FILTER_CLEAR.value, self)
821
+ self.clear_filter_action.triggered.connect(self.clear_filter)
822
+ file_menu.addAction(self.clear_filter_action)
823
+ self.file_history_resume_action = QAction("Continue reading file history", self)
824
+ self.file_history_resume_action.triggered.connect(self.file_history_resume_requested.emit)
825
+ self.file_history_resume_action.setEnabled(False)
826
+ # Only a file history window can continue reading; the button closes its top bar.
827
+ self.file_history_resume_button: QToolButton | None = None
828
+ if mode == "file_history":
829
+ self.file_history_resume_button = QToolButton()
830
+ self.file_history_resume_button.setDefaultAction(self.file_history_resume_action)
831
+ top_bar_layout.addWidget(self.file_history_resume_button)
832
+ filter_toolbar.addWidget(self.top_bar_row)
833
+ outer_splitter = QSplitter(Qt.Orientation.Horizontal)
834
+ self.outer_splitter = outer_splitter
835
+ outer_splitter.setChildrenCollapsible(False)
836
+ outer_splitter.setHandleWidth(LAYOUT_SPACING)
837
+ self.navigation_tree = QTreeWidget()
838
+ self.navigation_tree.setHeaderLabel(repository_display_name(context.repository_dir))
839
+ self.navigation_tree.setAccessibleName("Branches, tags and repositories")
840
+ self.navigation_tree.itemClicked.connect(self._navigate_reference)
841
+ self.navigation_tree.itemActivated.connect(self._navigate_reference)
842
+ self.navigation_tree.itemDoubleClicked.connect(self._activate_repository)
843
+ self.navigation_tree.itemExpanded.connect(lambda item: self._remember_stash_expansion(item, True))
844
+ self.navigation_tree.itemCollapsed.connect(lambda item: self._remember_stash_expansion(item, False))
845
+ self.navigation_tree.installEventFilter(self)
846
+ self.navigation_tree.viewport().installEventFilter(self)
847
+ self.navigation_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
848
+ self.navigation_tree.customContextMenuRequested.connect(self.show_navigation_menu)
849
+ self.navigation_tree.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
850
+ self.navigation_tree.header().setStretchLastSection(False)
851
+ self.navigation_tree.header().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
852
+ outer_splitter.addWidget(self.navigation_tree)
853
+ view_menu = self.menuBar().addMenu("View")
854
+ navigation_action = view_menu.addAction("Navigation")
855
+ navigation_action.setCheckable(True)
856
+ navigation_action.setChecked(True)
857
+ navigation_action.toggled.connect(self.navigation_tree.setVisible)
858
+ self.navigation_action = navigation_action
859
+ splitter = QSplitter(Qt.Orientation.Vertical)
860
+ self.detail_splitter = splitter
861
+ splitter.setHandleWidth(LAYOUT_SPACING)
862
+ splitter.setChildrenCollapsible(False)
863
+ outer_splitter.addWidget(splitter)
864
+ self.history_model = HistoryModel(clock)
865
+ self.parent_reference_availability = ""
866
+ self.commit_list = HistoryView()
867
+ self.commit_list.setModel(self.history_model)
868
+ self.history_avatar = HistoryAvatar(self.commit_list)
869
+ self.commit_list.setItemDelegateForColumn(MESSAGE_COLUMN, GraphDelegate(self.commit_list))
870
+ self.commit_list.setItemDelegateForColumn(AUTHOR_COLUMN, AuthorDelegate(self.history_avatar))
871
+ self.commit_list.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
872
+ self.commit_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
873
+ self.commit_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
874
+ self.commit_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
875
+ self.commit_list.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
876
+ self.commit_list.setTabKeyNavigation(False)
877
+ self.commit_list.setAlternatingRowColors(True)
878
+ self.commit_list.setShowGrid(False)
879
+ self.commit_list.horizontalHeader().hide()
880
+ self.commit_list.verticalHeader().hide()
881
+ for column, width in enumerate(COLUMN_WIDTHS):
882
+ self.commit_list.setColumnWidth(column, width)
883
+ self.header_action = view_menu.addAction("Column headers")
884
+ self.header_action.setCheckable(True)
885
+ self.header_action.toggled.connect(self.commit_list.horizontalHeader().setVisible)
886
+ self.commit_list.setAccessibleName("Commit history")
887
+ self.commit_list.menu_requested.connect(self.show_history_menu)
888
+ self.commit_panel = CommitPanel(self.history_model.clock)
889
+ self.closed.connect(self.commit_panel.cancel_avatar)
890
+ self.closed.connect(self.history_avatar.close)
891
+ self.detail_edit = self.commit_panel.message_edit
892
+ self.commit_panel.navigation_requested.connect(self.navigation_requested.emit)
893
+ self.commit_panel.copy_requested.connect(self.copy_hash)
894
+ self.commit_panel.retry_requested.connect(self.detail_retry_requested.emit)
895
+ self.detail_tabs = QTabWidget()
896
+ self.detail_tabs.setAccessibleName("Commit detail tabs")
897
+ self.detail_tabs.tabBar().setAccessibleName("Commit, Diff and File tree")
898
+ self.detail_tabs.addTab(self.commit_panel, "Commit")
899
+ self.diff_panel = DiffPanel()
900
+ self.detail_tabs.addTab(self.diff_panel, "Diff")
901
+ self.file_tree_panel = FileTreePanel()
902
+ self.detail_tabs.addTab(self.file_tree_panel, "File tree")
903
+ if WindowCapability.FILE_MENU in self.capabilities:
904
+ self.diff_panel.file_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
905
+ self.diff_panel.file_tree.customContextMenuRequested.connect(self.show_file_menu)
906
+ self.file_tree_panel.file_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
907
+ self.file_tree_panel.file_tree.customContextMenuRequested.connect(lambda position: self.show_file_menu(position, FilePanel.TREE))
908
+ for tree in (self.diff_panel.file_tree, self.file_tree_panel.file_tree):
909
+ tree.installEventFilter(self)
910
+ tree.viewport().installEventFilter(self)
911
+ self.date_timer = QTimer(self)
912
+ self.date_timer.setInterval(RELATIVE_DATE_REFRESH_MS)
913
+ self.date_timer.timeout.connect(self.history_model.refresh_date)
914
+ self.date_timer.timeout.connect(self.commit_panel.refresh_date)
915
+ self.date_timer.timeout.connect(self.refresh_stash_date)
916
+ self.date_timer.start()
917
+ history_container = QWidget()
918
+ self.history_container = history_container
919
+ history_layout = QVBoxLayout(history_container)
920
+ self.parent_reference_label = WrappingLabel()
921
+ self.parent_reference_label.setTextFormat(Qt.TextFormat.PlainText)
922
+ self.parent_reference_label.setWordWrap(True)
923
+ self.parent_reference_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
924
+ self.parent_reference_label.hide()
925
+ history_layout.addWidget(self.parent_reference_label)
926
+ self.conflict_banner = QWidget()
927
+ self.conflict_banner.setObjectName("conflict_banner")
928
+ self.conflict_banner.setStyleSheet(CONFLICT_BANNER_STYLE)
929
+ conflict_layout = QHBoxLayout(self.conflict_banner)
930
+ self.conflict_label = QLabel()
931
+ self.conflict_label.setTextFormat(Qt.TextFormat.PlainText)
932
+ self.conflict_label.setWordWrap(True)
933
+ conflict_layout.addWidget(self.conflict_label)
934
+ self.conflict_state: ConflictState | None = None
935
+ self.resolve_button = QPushButton("Resolve…")
936
+ self.resolve_button.setIcon(action_icon("resolve", self.devicePixelRatioF()))
937
+ self.resolve_button.setEnabled(False)
938
+ self.resolve_button.clicked.connect(lambda: self.resolve_requested.emit(self.conflict_state))
939
+ conflict_layout.addWidget(self.resolve_button)
940
+ # The same banner turns blue while a merge waits for its commit, so a merge with conflicts never shows two banners.
941
+ self.merge_banner_state: MergeBannerState | None = None
942
+ self.merge_banner_shown = False
943
+ self.merge_continue_button = QPushButton("Continue")
944
+ self.merge_continue_button.clicked.connect(self.merge_continue_requested)
945
+ self.merge_continue_button.hide()
946
+ conflict_layout.addWidget(self.merge_continue_button)
947
+ self.merge_abort_button = QPushButton("Abort")
948
+ self.merge_abort_button.clicked.connect(self.merge_abort_requested)
949
+ self.merge_abort_button.hide()
950
+ conflict_layout.addWidget(self.merge_abort_button)
951
+ self.conflict_banner.hide()
952
+ history_layout.addWidget(self.conflict_banner)
953
+ self.rebase_banner = QWidget()
954
+ self.rebase_banner.setObjectName("rebase_banner")
955
+ self.rebase_banner.setStyleSheet("QWidget#rebase_banner { background-color: orange; } QLabel { color: black; }")
956
+ rebase_layout = QHBoxLayout(self.rebase_banner)
957
+ self.rebase_label = QLabel("Rebase in progress")
958
+ self.rebase_label.setTextFormat(Qt.TextFormat.PlainText)
959
+ self.rebase_label.setWordWrap(True)
960
+ rebase_layout.addWidget(self.rebase_label, 1)
961
+ self.rebase_button = QPushButton("Open rebase")
962
+ self.rebase_button.clicked.connect(self.rebase_requested)
963
+ rebase_layout.addWidget(self.rebase_button)
964
+ self.rebase_banner.hide()
965
+ history_layout.addWidget(self.rebase_banner)
966
+ self.replay_banner = QWidget()
967
+ self.replay_banner.setObjectName("replay_banner")
968
+ self.replay_banner.setStyleSheet("QWidget#replay_banner { background-color: orange; } QLabel { color: black; }")
969
+ replay_layout = QHBoxLayout(self.replay_banner)
970
+ self.replay_label = QLabel()
971
+ self.replay_label.setTextFormat(Qt.TextFormat.PlainText)
972
+ self.replay_label.setWordWrap(True)
973
+ replay_layout.addWidget(self.replay_label, 1)
974
+ self.replay_refresh_button = QPushButton("Refresh")
975
+ self.replay_refresh_button.clicked.connect(self.replay_refresh_requested)
976
+ replay_layout.addWidget(self.replay_refresh_button)
977
+ self.replay_resolve_button = QPushButton("Resolve")
978
+ self.replay_resolve_button.clicked.connect(lambda: self.replay_resolve_requested.emit(self.replay_state))
979
+ self.replay_continue_button = QPushButton("Continue")
980
+ self.replay_continue_button.clicked.connect(lambda: self.replay_continue_requested.emit(self.replay_state))
981
+ self.replay_abort_button = QPushButton("Abort")
982
+ self.replay_abort_button.clicked.connect(lambda: self.replay_abort_requested.emit(self.replay_state))
983
+ for button in (self.replay_resolve_button, self.replay_continue_button, self.replay_abort_button):
984
+ replay_layout.addWidget(button)
985
+ self.replay_banner.hide()
986
+ history_layout.addWidget(self.replay_banner)
987
+ self.history_state_label = QLabel("No commits in this repository\nThis repository has no commits yet. Create one in your terminal, then use Refresh.")
988
+ self.history_state_label.setWordWrap(True)
989
+ self.history_state_label.setTextFormat(Qt.TextFormat.PlainText)
990
+ self.history_state_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
991
+ self.history_state_label.setMaximumHeight(HISTORY_MIN_HEIGHT)
992
+ self.history_state_label.hide()
993
+ history_layout.addWidget(self.history_state_label)
994
+ history_layout.addWidget(self.commit_list)
995
+ splitter.addWidget(history_container)
996
+ splitter.addWidget(self.detail_tabs)
997
+ self.setCentralWidget(outer_splitter)
998
+ self.layout().setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
999
+ splitter.setStretchFactor(0, HISTORY_STRETCH)
1000
+ splitter.setStretchFactor(1, DETAIL_STRETCH)
1001
+ outer_splitter.setStretchFactor(0, 0)
1002
+ outer_splitter.setStretchFactor(1, 1)
1003
+ outer_splitter.setSizes([NAVIGATION_WIDTH, REPOSITORY_WIDTH - NAVIGATION_WIDTH])
1004
+ splitter.setSizes([HISTORY_STRETCH * HISTORY_MIN_HEIGHT, DETAIL_STRETCH * HISTORY_MIN_HEIGHT])
1005
+ self.resolve_action: QAction | None = None
1006
+ self.refresh_metrics()
1007
+ self._set_tab_order()
1008
+ if WindowCapability.MANAGE not in self.capabilities:
1009
+ for action in (self.open_action, self.refresh_action, self.explorer_action, self.setting_action, self.terminal_action, self.redetect_action):
1010
+ action.setEnabled(False)
1011
+ self.terminal_button.setEnabled(False)
1012
+ self.terminal_menu.setEnabled(False)
1013
+ self.commit_list.selectionModel().currentRowChanged.connect(self._select_commit)
1014
+ self.difftool_action: QAction | None = None
1015
+ self.emoji_keyboard_action: QAction | None = None
1016
+ if WindowCapability.MANAGE in self.capabilities:
1017
+ tool_menu = self.menuBar().addMenu("Tools")
1018
+ self.resolve_action = tool_menu.addAction(action_icon("resolve", self.devicePixelRatioF()), "Solve merge conflicts…")
1019
+ self.resolve_action.setEnabled(False)
1020
+ self.resolve_action.triggered.connect(lambda: self.resolve_requested.emit(self.conflict_state))
1021
+ self.merge_recovery_action = tool_menu.addAction("Retained merge results…")
1022
+ self.merge_recovery_action.triggered.connect(self.merge_recovery_requested.emit)
1023
+ self.remote_action = tool_menu.addAction("Manage remotes")
1024
+ self.remote_action.triggered.connect(self.remote_requested.emit)
1025
+ self.difftool_action = tool_menu.addAction(FileAction.COMPARE.label)
1026
+ self.difftool_action.setEnabled(False)
1027
+ self.difftool_action.triggered.connect(self.difftool_requested.emit)
1028
+ tool_menu.addSeparator()
1029
+ self.emoji_keyboard_action = tool_menu.addAction(EMOJI_KEYBOARD_TITLE)
1030
+ self.emoji_keyboard_action.triggered.connect(self.emoji_keyboard_requested.emit)
1031
+ tool_menu.aboutToShow.connect(self.update_difftool_action)
1032
+ self.diff_panel.file_tree.currentItemChanged.connect(self.update_difftool_action)
1033
+ self.show_commit(commit)
1034
+ self.splitter_by_key = {"diff_splitter_state": self.diff_panel.splitter, "tree_splitter_state": self.file_tree_panel.splitter}
1035
+ if self.mode == "file_history":
1036
+ self.splitter_by_key["file_history_splitter_state"] = self.detail_splitter
1037
+ self.default_sizes_by_key: dict[str, list[int]] = {}
1038
+ for key, reading_splitter in self.splitter_by_key.items():
1039
+ if reading_splitter is self.detail_splitter:
1040
+ self.default_sizes_by_key[key] = [HISTORY_STRETCH * HISTORY_MIN_HEIGHT, DETAIL_STRETCH * HISTORY_MIN_HEIGHT]
1041
+ else:
1042
+ self.default_sizes_by_key[key] = [DIFF_FILE_STRETCH * REPOSITORY_WIDTH, DIFF_TEXT_STRETCH * REPOSITORY_WIDTH]
1043
+ reading_splitter.splitterMoved.connect(lambda position, index, key=key: self._request_splitter_preference(key))
1044
+
1045
+ def _apply_menu_button_look(self) -> None:
1046
+ """Give Fetch, Open terminal, the repository button and the filter mode button the hover of the current theme, after a light/dark switch."""
1047
+ style_sheet = terminal_button_style_sheet(QApplication.palette())
1048
+ for button in (self.fetch_button, self.terminal_button, self.repository_button, self.filter_mode_button):
1049
+ button.setStyleSheet(style_sheet)
1050
+
1051
+ def show_replay_state(self, state: ReplaySnapshot, *, busy: bool = False, abort_available: bool = False) -> None:
1052
+ """Keep native replay pauses and uncertain external changes visible as literal text.
1053
+
1054
+ A conflict a successful read proved to carry no cherry-pick or revert trace is left to the conflict banner alone.
1055
+ """
1056
+ self.replay_state = state
1057
+ # A conflict proven to carry no cherry-pick or revert trace (a stash pop or stash apply) is an ordinary conflict: the
1058
+ # orange conflict banner already offers Resolve… and the toolbar Refresh, so this banner would only repeat them with
1059
+ # Continue and Abort greyed. An unreadable state loses that proof and keeps the banner.
1060
+ ordinary_conflict = state.kind != ReplayKind.ABSENT and not replay_may_be_present(state)
1061
+ visible = ((state.kind != ReplayKind.ABSENT or bool(state.diagnostic)) and not ordinary_conflict
1062
+ and WindowCapability.MANAGE in self.capabilities and not self.context.is_bare)
1063
+ label = state.kind.label
1064
+ if state.unmerged_paths:
1065
+ label += f" paused: {len(state.unmerged_paths)} conflict(s)."
1066
+ elif state.kind != ReplayKind.ABSENT:
1067
+ label += " paused."
1068
+ if state.instruction_oids:
1069
+ label += f" {len(state.instruction_oids)} instruction(s)."
1070
+ if state.diagnostic:
1071
+ label += " " + state.diagnostic
1072
+ reason_by_name: dict[str, str] = {}
1073
+ for action, button in ((ReplayAction.RESOLVE, self.replay_resolve_button), (ReplayAction.CONTINUE, self.replay_continue_button),
1074
+ (ReplayAction.ABORT, self.replay_abort_button)):
1075
+ diagnostic = replay_action_diagnostic(state, action, busy=busy, abort_available=abort_available)
1076
+ button.setEnabled(not diagnostic)
1077
+ button.setToolTip(diagnostic)
1078
+ reason_by_name[button.text()] = diagnostic
1079
+ # A greyed button never leaves the user without a visible reason: its tooltip alone is rarely seen.
1080
+ reason_text = unavailable_reason_text(reason_by_name, label)
1081
+ self.replay_label.setText(label + ("\n" + reason_text if reason_text else ""))
1082
+ self.replay_banner.setVisible(visible)
1083
+
1084
+ def show_rebase_state(self, state: RebaseSnapshot) -> None:
1085
+ """Keep present or uncertain rebases discoverable without hiding conflict access."""
1086
+ visible = state.state != "absent" and WindowCapability.MANAGE in self.capabilities and not self.context.is_bare
1087
+ label = "Rebase in progress"
1088
+ if not state.fresh or state.state == "unknown":
1089
+ label = "Rebase state is unknown — Refresh to inspect"
1090
+ self.rebase_label.setText(label)
1091
+ self.rebase_banner.setVisible(visible)
1092
+
1093
+ def show_conflict_state(self, state: ConflictState, merge_state: MergeBannerState | None = None, *, busy: bool = False) -> None:
1094
+ """Keep known conflicts visible when a refresh cannot establish current state, in one banner that turns blue during a merge.
1095
+
1096
+ A merge waiting for its commit, with no rebase or cherry-pick/revert paused beside it, shows the blue banner even
1097
+ without conflicts, with Continue and Abort; the conflicts and their Resolve… stay in that same banner, and a greyed
1098
+ button says why below the text. Any other conflict keeps the orange banner.
1099
+ """
1100
+ self.conflict_state = state
1101
+ manage = WindowCapability.MANAGE in self.capabilities
1102
+ enabled = bool(state.entries) and state.fresh and manage
1103
+ self.resolve_button.setEnabled(enabled)
1104
+ if self.resolve_action is not None:
1105
+ self.resolve_action.setEnabled(enabled)
1106
+ self.merge_banner_state = merge_state
1107
+ merge_shown = merge_state is not None and merge_state.in_progress and manage and not self.context.is_bare
1108
+ conflict_text = ""
1109
+ if state.entries:
1110
+ labels = []
1111
+ for entry in state.entries[:CONFLICT_BANNER_PATH_LIMIT]:
1112
+ labels.append(conflict_path_label(entry.path))
1113
+ conflict_text = conflict_count_text(len(state.entries)) + ": " + ", ".join(labels)
1114
+ if len(state.entries) > CONFLICT_BANNER_PATH_LIMIT:
1115
+ conflict_text += ", …"
1116
+ if not state.fresh:
1117
+ conflict_text = (conflict_text + "\n" if state.entries else "") + "Conflict state is unknown. " + state.diagnostic
1118
+ if merge_shown != self.merge_banner_shown:
1119
+ # The style sheet is only replaced when the banner changes form: every worktree read lands here.
1120
+ self.conflict_banner.setStyleSheet(MERGE_BANNER_STYLE if merge_shown else CONFLICT_BANNER_STYLE)
1121
+ self.merge_banner_shown = merge_shown
1122
+ self.merge_continue_button.setVisible(merge_shown)
1123
+ self.merge_abort_button.setVisible(merge_shown)
1124
+ if not merge_shown:
1125
+ self.resolve_button.setVisible(True)
1126
+ self.conflict_label.setText(conflict_text)
1127
+ self.conflict_banner.setVisible((bool(state.entries) or not state.fresh) and manage)
1128
+ return
1129
+ self.resolve_button.setVisible(bool(state.entries) or not state.fresh)
1130
+ reason_by_name: dict[str, str] = {}
1131
+ for action, button in ((CONTINUE_ACTION, self.merge_continue_button), (ABORT_ACTION, self.merge_abort_button)):
1132
+ reason = merge_action_reason(merge_state, action, conflict_count=len(state.entries), busy=busy)
1133
+ button.setEnabled(not reason)
1134
+ button.setToolTip(reason)
1135
+ reason_by_name[button.text()] = reason
1136
+ lines = [MERGE_IN_PROGRESS_TEXT]
1137
+ if merge_state.cause:
1138
+ lines.append(merge_state.cause)
1139
+ if conflict_text:
1140
+ lines.append(conflict_text)
1141
+ shown_text = "\n".join(lines)
1142
+ # A greyed button never leaves the user without a visible reason: its tooltip alone is rarely seen.
1143
+ reason_text = unavailable_reason_text(reason_by_name, shown_text)
1144
+ self.conflict_label.setText(shown_text + ("\n" + reason_text if reason_text else ""))
1145
+ self.conflict_banner.setVisible(True)
1146
+
1147
+ def _request_splitter_preference(self, key: str) -> None:
1148
+ """Save only the splitter the user moved, leaving unrelated window state alone."""
1149
+ state = bytes(self.splitter_by_key[key].saveState().toBase64()).decode("ascii")
1150
+ self.reading_preference_requested.emit({key: state})
1151
+
1152
+ def apply_reading_preference(self, value_by_key: dict[str, object]) -> None:
1153
+ """Restore accepted presentation choices without emitting new save intentions."""
1154
+ if "diff_show_entire_file" in value_by_key:
1155
+ checkbox = self.diff_panel.full_context_checkbox
1156
+ with QSignalBlocker(checkbox):
1157
+ checkbox.setChecked(value_by_key["diff_show_entire_file"])
1158
+ for key, splitter in self.splitter_by_key.items():
1159
+ if key not in value_by_key:
1160
+ continue
1161
+ encoded = value_by_key[key]
1162
+ orientation = splitter.orientation()
1163
+ valid = True
1164
+ with QSignalBlocker(splitter):
1165
+ try:
1166
+ if encoded:
1167
+ valid = splitter.restoreState(QByteArray(base64.b64decode(encoded, validate=True)))
1168
+ else:
1169
+ splitter.setSizes(self.default_sizes_by_key[key])
1170
+ valid = valid and splitter.orientation() == orientation and all(size > 0 for size in splitter.sizes())
1171
+ except (ValueError, TypeError, binascii.Error):
1172
+ valid = False
1173
+ if not valid:
1174
+ splitter.setSizes(self.default_sizes_by_key[key])
1175
+ splitter.setOrientation(orientation)
1176
+ splitter.setChildrenCollapsible(False)
1177
+ if not valid:
1178
+ self.statusBar().showMessage(f"Unreadable layout ignored: {key}. Default proportions restored.")
1179
+
1180
+ def update_difftool_action(self) -> None:
1181
+ """Enable comparison only for a current file in the main window's Diff panel."""
1182
+ if self.difftool_action is not None:
1183
+ entry = self.diff_panel.selected_entry
1184
+ self.difftool_action.setEnabled(not self.is_closed and entry is not None and entry in self.diff_panel.entries
1185
+ and self.commit is not None and entry.commit_oid == self.commit.oid)
1186
+
1187
+ def find_active_content(self) -> None:
1188
+ """Search the visible reader while preserving the separate history filter."""
1189
+ if self.detail_tabs.currentWidget() is self.diff_panel:
1190
+ self.diff_panel.open_search()
1191
+ return
1192
+ if self.detail_tabs.currentWidget() is self.file_tree_panel:
1193
+ self.file_tree_panel.open_search()
1194
+ return
1195
+ self.commit_panel.message_panel.open_search()
1196
+
1197
+ def show_status_failure(self, text: str) -> None:
1198
+ """Show in the status bar, bold red until the next message, a failure the user must not miss there."""
1199
+ self.statusBar().show_failure(text)
1200
+
1201
+ def show_file_menu(self, position: QPoint, panel_kind: FilePanel = FilePanel.DIFF) -> None:
1202
+ """Capture a historical file or raw directory target before offering its valid actions."""
1203
+ if WindowCapability.FILE_MENU not in self.capabilities:
1204
+ return
1205
+ panel = self.diff_panel if panel_kind == FilePanel.DIFF else self.file_tree_panel
1206
+ tree = panel.file_tree
1207
+ item = tree.itemAt(position)
1208
+ if item is None:
1209
+ return
1210
+ entry = item.data(0, Qt.ItemDataRole.UserRole)
1211
+ folder_path = item.data(0, FOLDER_PATH_ROLE)
1212
+ if entry is None and not folder_path:
1213
+ return
1214
+ tree.setCurrentItem(item)
1215
+ if self.file_menu is not None:
1216
+ self.file_menu.close()
1217
+ self.file_menu.deleteLater()
1218
+ if self.commit is None:
1219
+ return
1220
+ kind = FileActionTargetKind.FOLDER if entry is None else FileActionTargetKind.FILE
1221
+ selection = FileActionSelection(entry, self.commit.oid, panel_kind, panel.action_generation, kind, folder_path)
1222
+ context = self.context
1223
+ menu = FileActionMenu(selection, lambda chosen: self.context is context and self.file_menu is menu and self.file_action_is_current(chosen), self,
1224
+ self.file_restore_targets(selection), self.text_editor_reason,
1225
+ lambda chosen: self.remove_history_reason(chosen) if self.remove_history_reason is not None else "")
1226
+ self.file_menu = menu
1227
+ self.file_menu.history_requested.connect(lambda chosen: self.file_history_requested.emit(chosen.entry))
1228
+ self.file_menu.compare_requested.connect(lambda chosen: self.difftool_requested.emit())
1229
+ self.file_menu.filename_requested.connect(self.copy_filename)
1230
+ self.file_menu.absolute_filename_requested.connect(lambda selection: self.copy_filename(selection, absolute=True))
1231
+ self.file_menu.folder_requested.connect(self.file_folder_requested.emit)
1232
+ self.file_menu.text_editor_requested.connect(self.file_text_editor_requested.emit)
1233
+ self.file_menu.save_requested.connect(self.file_save_requested.emit)
1234
+ self.file_menu.restore_requested.connect(self.file_restore_requested.emit)
1235
+ self.file_menu.remove_history_requested.connect(self.remove_history_requested.emit)
1236
+ self.file_menu.popup(tree.viewport().mapToGlobal(position))
1237
+
1238
+ def file_restore_targets(self, selection: FileActionSelection) -> list[FileRestoreTarget] | None:
1239
+ """Always offer the viewed commit followed by its direct parents in commit-object order."""
1240
+ if WindowCapability.RESTORE not in self.capabilities or self.commit is None or selection.kind == FileActionTargetKind.FOLDER:
1241
+ return None
1242
+ entry = selection.entry
1243
+ unavailable = "This bare repository has no working tree." if self.context.is_bare else ""
1244
+ subject, _separator, _body = self.commit.message.partition("\n")
1245
+ current_unavailable = unavailable
1246
+ if getattr(entry, "new_mode", getattr(entry, "mode", "")) == "160000":
1247
+ current_unavailable = current_unavailable or "A submodule points to a commit, not a file blob."
1248
+ elif getattr(entry, "new_path", True) is None:
1249
+ current_unavailable = current_unavailable or "This file is absent from the selected commit."
1250
+ targets = [FileRestoreTarget(self.commit.oid, "This commit " + self.commit.short_oid + " " + subject[:HISTORICAL_SUBJECT_LIMIT], self.commit.message, current_unavailable)]
1251
+ parent_by_oid = {}
1252
+ if self.commit_relation is not None and self.commit_relation.oid == self.commit.oid:
1253
+ for parent in self.commit_relation.parents:
1254
+ parent_by_oid[parent.oid] = parent
1255
+ for parent_index, parent_oid in enumerate(self.commit.parent_oids):
1256
+ ordinal = parent_index
1257
+ letter = ""
1258
+ alphabet_size = 26
1259
+ first_letter_code = ord("A")
1260
+ while True:
1261
+ ordinal, remainder = divmod(ordinal, alphabet_size)
1262
+ letter = chr(first_letter_code + remainder) + letter
1263
+ if ordinal == 0:
1264
+ break
1265
+ ordinal -= 1
1266
+ label = "Parent" if len(self.commit.parent_oids) == 1 else "Parent " + letter
1267
+ parent = parent_by_oid.get(parent_oid)
1268
+ parent_unavailable = unavailable
1269
+ message = parent.subject if parent is not None else "Loading commit…"
1270
+ short_oid = parent.short_oid or parent.oid if parent is not None else parent_oid
1271
+ if parent is None or not parent.available:
1272
+ parent_unavailable = parent_unavailable or message
1273
+ if getattr(entry, "parent_oid", None) == parent_oid:
1274
+ if getattr(entry, "old_mode", "") == "160000":
1275
+ parent_unavailable = parent_unavailable or "A submodule points to a commit, not a file blob."
1276
+ elif getattr(entry, "old_path", None) is None:
1277
+ parent_unavailable = parent_unavailable or "This file is absent from the parent commit."
1278
+ targets.append(FileRestoreTarget(parent_oid, label + " " + short_oid + " " + message[:HISTORICAL_SUBJECT_LIMIT], message, parent_unavailable))
1279
+ return targets
1280
+
1281
+ def show_commit_relation(self, relation: CommitRelation) -> None:
1282
+ """Publish relatives to the commit panel and any still-current restoration menu."""
1283
+ if self.commit is None or relation.oid != self.commit.oid:
1284
+ return
1285
+ self.commit_relation = relation
1286
+ self.commit_panel.show_relation(relation)
1287
+ if self.file_menu is not None and self.file_action_is_current(self.file_menu.selection):
1288
+ self.file_menu.set_restore_targets(self.file_restore_targets(self.file_menu.selection) or [])
1289
+
1290
+ def file_action_is_current(self, selection: FileActionSelection) -> bool:
1291
+ """Reject menus from a closed window, old commit, or superseded file selection."""
1292
+ panel = self.diff_panel if selection.panel_kind == FilePanel.DIFF else self.file_tree_panel
1293
+ if (self.is_closed or not self.isVisible() or WindowCapability.FILE_MENU not in self.capabilities or self.commit is None
1294
+ or self.commit.oid != selection.commit_oid or panel.action_generation != selection.generation):
1295
+ return False
1296
+ item = panel.file_tree.currentItem()
1297
+ if selection.kind == FileActionTargetKind.FOLDER:
1298
+ return item is not None and item.data(0, Qt.ItemDataRole.UserRole) is None and item.data(0, FOLDER_PATH_ROLE) == selection.path()
1299
+ return panel.selected_entry == selection.entry
1300
+
1301
+ def confirm_historical_restore(self, request: HistoricalRestoreRequest) -> bool:
1302
+ """Name the exact source and local replacement, with cancellation as the default."""
1303
+ dialog = QMessageBox(self)
1304
+ prepare_message_box(dialog, RESTORE_SELECTED_VERSION_TITLE)
1305
+ dialog.setTextFormat(Qt.TextFormat.PlainText)
1306
+ dialog.setText("Replace the local file with the selected historical version?")
1307
+ path_label = os.fsdecode(request.path)
1308
+ if len(path_label) > HISTORICAL_PATH_LABEL_LIMIT:
1309
+ path_label = path_label[:HISTORICAL_PATH_LABEL_LIMIT] + "… (full path in details)"
1310
+ subject, _separator, _body = request.message.partition("\n")
1311
+ dialog.setInformativeText("File: " + path_label + "\nCommit: " + request.commit_oid + "\n" + subject[:HISTORICAL_SUBJECT_LIMIT]
1312
+ + "\n\nThe index and HEAD stay unchanged. The result remains Unstaged if it differs from the index. Existing local content, including an untracked file, will be replaced.")
1313
+ dialog.setDetailedText("File: " + os.fsdecode(request.path) + "\nCommit: " + request.commit_oid + "\n\n" + request.message)
1314
+ restore_button = dialog.addButton("Restore selected version", QMessageBox.ButtonRole.AcceptRole)
1315
+ cancel_button = dialog.addButton("Cancel restoration", QMessageBox.ButtonRole.RejectRole)
1316
+ dialog.setDefaultButton(cancel_button)
1317
+ dialog.setEscapeButton(cancel_button)
1318
+ dialog.exec()
1319
+ accepted = dialog.clickedButton() == restore_button
1320
+ dialog.deleteLater()
1321
+ return accepted
1322
+
1323
+ def copy_filename(self, selection: FileActionSelection, *, absolute: bool = False) -> None:
1324
+ """Copy the basename or absolute worktree filename, preserving historical spelling."""
1325
+ if not self.file_action_is_current(selection):
1326
+ return
1327
+ try:
1328
+ filename = worktree_filename(self.context, selection.path()) if absolute else selection.basename()
1329
+ clipboard = self.clipboard if self.clipboard is not None else QApplication.clipboard()
1330
+ clipboard.setText(filename)
1331
+ if clipboard.text() != filename:
1332
+ raise OSError("The clipboard did not retain the requested filename.")
1333
+ except (OSError, RuntimeError, ValueError) as error:
1334
+ diagnostic = Diagnostic("Copy filename", self.context.repository_dir, "", [], str(error)).prepare()
1335
+ self.copy_dialog = ErrorDialog(diagnostic, self)
1336
+ self.copy_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
1337
+ open_window_modal(self.copy_dialog)
1338
+ return
1339
+ self.statusBar().showMessage("Filename copied", COPY_CONFIRMATION_MS)
1340
+
1341
+ def go_to_commit(self) -> None:
1342
+ """Resolve a literal hash in the captured history and reveal its row in the middle of the graph.
1343
+
1344
+ The commit is remembered as the one to center before any reveal, whether it is already shown, still to be read
1345
+ or hidden by a filter that is cleared first. Near the top or the bottom of the history Qt stops the scroll at the
1346
+ end of the graph, which leaves the commit as close to the middle as it can be.
1347
+ """
1348
+ if self.is_closed:
1349
+ return
1350
+ if self.search_pending or self.snapshot is None or not self.snapshot.complete:
1351
+ self.statusBar().showMessage("History is still loading. Try again when reading finishes.")
1352
+ return
1353
+ prefix = typed_value(self.commit_hash_edit.text()).lower()
1354
+ if len(prefix) < MINIMUM_COMMIT_PREFIX_LENGTH or any(character not in "0123456789abcdef" for character in prefix):
1355
+ self.statusBar().showMessage(f"Enter a commit hash or at least {MINIMUM_COMMIT_PREFIX_LENGTH} hexadecimal characters.")
1356
+ return
1357
+ candidate_oid_set = set(self.snapshot.ordered_oids)
1358
+ for commit in self.snapshot.explicit_commits:
1359
+ candidate_oid_set.add(commit.oid)
1360
+ if self.mode == "file_history":
1361
+ candidate_oid_set.intersection_update(self.file_history_result.touching_oid_set if self.file_history_result is not None else set())
1362
+ matching_oids = []
1363
+ for oid in candidate_oid_set:
1364
+ if oid.startswith(prefix):
1365
+ matching_oids.append(oid)
1366
+ if not matching_oids:
1367
+ self.statusBar().showMessage("Commit not found in this file's history." if self.mode == "file_history" else "Commit not found in the captured history.")
1368
+ return
1369
+ if len(matching_oids) != 1:
1370
+ self.statusBar().showMessage("This hash prefix matches several commits. Enter more characters.")
1371
+ return
1372
+ oid = matching_oids.pop()
1373
+ self.centered_oid = oid
1374
+ if self.commit is not None and self.commit.oid == oid and oid in self.history_model.row_by_oid:
1375
+ self.reveal_commit(oid)
1376
+ return
1377
+ self.pending_oid = oid
1378
+ self.anchor_oid = None
1379
+ if self.filter_text and oid not in self.history_model.row_by_oid:
1380
+ self.pending_navigation_oid = oid
1381
+ self.clear_filter()
1382
+ return
1383
+ self.navigation_requested.emit(oid)
1384
+
1385
+ def focus_filter(self) -> None:
1386
+ """Reveal and select the search field without changing its query."""
1387
+ self.filter_edit.setFocus(Qt.FocusReason.ShortcutFocusReason)
1388
+ self.filter_edit.selectAll()
1389
+
1390
+ def eventFilter(self, watched: QWidget, event: QEvent) -> bool:
1391
+ """Activate repositories without folding their children, navigate to a reference on Enter, and handle focused search keys.
1392
+
1393
+ A repository row is checked first, so Enter on it opens the repository and never activates the row. On any other
1394
+ row, Enter goes through the shared helper, since Qt for macOS does not activate a tree row on Enter.
1395
+ """
1396
+ for panel_name, panel_kind in (("diff_panel", FilePanel.DIFF), ("file_tree_panel", FilePanel.TREE)):
1397
+ panel = getattr(self, panel_name, None)
1398
+ if panel is None:
1399
+ continue
1400
+ tree = panel.file_tree
1401
+ if watched is not tree and watched is not tree.viewport():
1402
+ continue
1403
+ keyboard_menu = event.type() == QEvent.Type.ContextMenu and event.reason() == QContextMenuEvent.Reason.Keyboard
1404
+ if event.type() == QEvent.Type.KeyPress:
1405
+ keyboard_menu = event.key() == Qt.Key.Key_Menu or (event.key() == Qt.Key.Key_F10 and event.modifiers() == Qt.KeyboardModifier.ShiftModifier)
1406
+ if keyboard_menu:
1407
+ item = tree.currentItem()
1408
+ if item is not None:
1409
+ tree.scrollToItem(item)
1410
+ self.show_file_menu(tree.visualItemRect(item).center(), panel_kind)
1411
+ return True
1412
+ if watched is self.navigation_tree.viewport() and event.type() == QEvent.Type.MouseButtonDblClick and event.button() == Qt.MouseButton.LeftButton:
1413
+ position = event.position().toPoint()
1414
+ item = self.navigation_tree.itemAt(position)
1415
+ if item is not None and item.data(NAVIGATION_COLUMN, REPOSITORY_ROLE) and self.navigation_tree.visualItemRect(item).contains(position):
1416
+ self._activate_repository(item, NAVIGATION_COLUMN)
1417
+ return True
1418
+ if watched is self.navigation_tree and event.type() == QEvent.Type.KeyPress and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
1419
+ item = self.navigation_tree.currentItem()
1420
+ if item is not None and item.data(NAVIGATION_COLUMN, REPOSITORY_ROLE):
1421
+ self._activate_repository(item, NAVIGATION_COLUMN)
1422
+ return True
1423
+ if watched is self.navigation_tree and activate_current_row(self.navigation_tree, event):
1424
+ return True
1425
+ if watched is self.filter_edit and event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Escape:
1426
+ self.clear_filter()
1427
+ return True
1428
+ return super().eventFilter(watched, event)
1429
+
1430
+ def _set_tab_order(self) -> None:
1431
+ """Put visible toolbar actions before search and the historical reading panels."""
1432
+ controls = []
1433
+ for action in self.action_toolbar.actions():
1434
+ control = self.action_toolbar.widgetForAction(action)
1435
+ if control is not None:
1436
+ control.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
1437
+ control.setAccessibleName(action.text() or control.accessibleName())
1438
+ controls.append(control)
1439
+ controls.extend((self.repository_button, self.filter_mode_button, self.filter_edit, self.clear_filter_button, self.commit_hash_edit, self.go_button, self.navigation_tree, self.commit_list,
1440
+ self.commit_panel.full_hash_button, self.commit_panel.short_hash_button, self.detail_edit,
1441
+ self.detail_tabs.tabBar(), self.diff_panel.file_tree, self.diff_panel.full_context_checkbox, self.diff_panel.reader_toolbar.find_button, self.diff_panel.diff_view,
1442
+ self.file_tree_panel.file_tree, self.file_tree_panel.reader_toolbar.find_button, self.file_tree_panel.text_edit))
1443
+ for previous, following in pairwise(controls):
1444
+ QWidget.setTabOrder(previous, following)
1445
+
1446
+ def refresh_metrics(self, available: QRect | None = None) -> None:
1447
+ """Resize logical controls without touching OIDs, filters or user splitter ratios."""
1448
+ for action, symbol in ((self.open_action, "repository"), (self.fetch_action, "fetch"), (self.push_action, "push"), (self.explorer_action, "folder"),
1449
+ (self.terminal_action, "terminal"), (self.setting_action, "settings"), (self.stash_action, "stash"),
1450
+ (self.stash_pop_action, "stash_pop")):
1451
+ action.setIcon(action_icon(symbol, self.devicePixelRatioF()))
1452
+ self.resolve_button.setIcon(action_icon("resolve", self.devicePixelRatioF()))
1453
+ if self.resolve_action is not None:
1454
+ self.resolve_action.setIcon(self.resolve_button.icon())
1455
+ self.fetch_button.setIcon(self.fetch_action.icon())
1456
+ self.terminal_button.setIcon(self.terminal_action.icon())
1457
+ # Without an icon the painted arrow overlaps the last letter of the path.
1458
+ self.repository_button.setIcon(action_icon("repository", self.devicePixelRatioF()))
1459
+ self.refresh_action.setIcon(action_icon("refresh", self.devicePixelRatioF()))
1460
+ self.commit_action.setIcon(action_icon("commit", self.devicePixelRatioF(), self.commit_action.property("change_state") or WorktreeColorState.UNKNOWN))
1461
+ row_height = history_row_height(self.commit_list.fontMetrics().height())
1462
+ keep_selection_visible(self.commit_list)
1463
+ self.commit_list.verticalHeader().setDefaultSectionSize(row_height)
1464
+ self.history_avatar.schedule_refresh()
1465
+ for control in (self.repository_button, self.filter_mode_button, self.filter_edit, self.clear_filter_button, self.commit_hash_edit, self.go_button, self.terminal_button,
1466
+ self.resolve_button, self.merge_continue_button, self.merge_abort_button, self.rebase_button):
1467
+ control.setMinimumHeight(control_height(control.fontMetrics().height()))
1468
+ self.commit_panel.refresh_metrics()
1469
+ self.diff_panel.refresh_metrics()
1470
+ self.diff_panel.diff_view.refresh_metrics()
1471
+ area = available if available is not None else self.screen().availableGeometry()
1472
+ compact = area.width() < REPOSITORY_MIN_WIDTH + 2 * SPACING_MD or area.height() < REPOSITORY_MIN_HEIGHT + 2 * SPACING_MD
1473
+ self.navigation_tree.setMinimumWidth(0 if compact else NAVIGATION_MIN_WIDTH)
1474
+ self.history_container.setMinimumHeight(0 if compact else HISTORY_MIN_HEIGHT)
1475
+ self.detail_tabs.setMinimumHeight(0 if compact else DETAIL_MIN_HEIGHT)
1476
+ fit_window(self, REPOSITORY_MIN_WIDTH, REPOSITORY_MIN_HEIGHT, area)
1477
+
1478
+ def copy_hash(self, value: str, kind: CopiedHash) -> None:
1479
+ """Copy the explicitly displayed value and report refusal without hiding the hash."""
1480
+ if not value:
1481
+ return
1482
+ try:
1483
+ clipboard = self.clipboard if self.clipboard is not None else QApplication.clipboard()
1484
+ clipboard.setText(value)
1485
+ if clipboard.text() != value:
1486
+ raise OSError("The clipboard did not retain the requested hash.")
1487
+ except (OSError, RuntimeError) as error:
1488
+ diagnostic = Diagnostic("Copy hash", self.context.repository_dir, "", [], str(error)).prepare()
1489
+ self.statusBar().showMessage("Could not copy the hash. Its value is still visible.")
1490
+ self.copy_dialog = ErrorDialog(diagnostic, self)
1491
+ self.copy_dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
1492
+ open_window_modal(self.copy_dialog)
1493
+ return
1494
+ self.statusBar().showMessage(kind.copied_message, COPY_CONFIRMATION_MS)
1495
+
1496
+ def _request_push(self) -> None:
1497
+ """Open the Push draft only from a live management window."""
1498
+ if not self.is_closed and WindowCapability.MANAGE in self.capabilities:
1499
+ self.push_requested.emit()
1500
+
1501
+ def _request_stash(self) -> None:
1502
+ """Open stash creation only from a live writable repository window."""
1503
+ if not self.is_closed and not self.context.is_bare and WindowCapability.MANAGE in self.capabilities:
1504
+ self.stash_requested.emit()
1505
+
1506
+ def _request_stash_pop(self) -> None:
1507
+ """Apply the latest stash only from a live writable repository window."""
1508
+ if not self.is_closed and not self.context.is_bare and WindowCapability.MANAGE in self.capabilities:
1509
+ self.stash_pop_requested.emit()
1510
+
1511
+ def _request_fetch(self) -> None:
1512
+ """Emit the saved primary intention only when another Fetch is not already running."""
1513
+ if not self.is_closed and WindowCapability.MANAGE in self.capabilities and not self.fetch_busy:
1514
+ self.fetch_requested.emit()
1515
+
1516
+ def _request_fetch_choice(self, mode: FetchMode) -> None:
1517
+ """Keep the menu's exact remote capture instead of retargeting from later widget labels."""
1518
+ if WindowCapability.MANAGE not in self.capabilities or self.fetch_busy:
1519
+ return
1520
+ if mode == FetchMode.FETCH and not self.fetch_remote_name:
1521
+ return
1522
+ self.apply_fetch_preference({FETCH_DEFAULT_PREFERENCE_KEY: self.fetch_mode.value})
1523
+ self.fetch_selection_requested.emit((mode, self.fetch_remote_name, self.fetch_snapshot))
1524
+
1525
+ def apply_fetch_preference(self, value_by_key: dict[str, object]) -> None:
1526
+ """Reflect only the shared default that successfully reached preference storage, given as the saved word."""
1527
+ if FETCH_DEFAULT_PREFERENCE_KEY not in value_by_key:
1528
+ return
1529
+ self.fetch_mode = fetch_mode_preference(value_by_key[FETCH_DEFAULT_PREFERENCE_KEY])
1530
+ for mode, action in self.fetch_default_action_by_mode.items():
1531
+ action.setChecked(mode == self.fetch_mode)
1532
+ self.fetch_choice_action_by_mode[mode].setChecked(mode == self.fetch_mode)
1533
+ self.fetch_button.setToolTip(self.fetch_mode.label)
1534
+ self.fetch_button.setAccessibleDescription("Default action: " + self.fetch_mode.label)
1535
+
1536
+ def set_fetch_busy(self, busy: bool) -> None:
1537
+ """Keep the independent arrow available while preventing a second primary submission."""
1538
+ self.fetch_busy = busy
1539
+ self.fetch_button.main_enabled = not busy and WindowCapability.MANAGE in self.capabilities
1540
+ for action in self.fetch_choice_action_by_mode.values():
1541
+ action.setEnabled(not busy)
1542
+ for action in self.fetch_remote_menu.actions():
1543
+ if action.data() is not None:
1544
+ action.setEnabled(not busy)
1545
+ if busy:
1546
+ self.statusBar().showMessage("Fetch in progress…")
1547
+
1548
+ def show_fetch_choice(self, snapshot: object, remote_name: str, error: str = "") -> None:
1549
+ """Present complete remote identities, including an explicit recovery for an empty list."""
1550
+ self.fetch_snapshot, self.fetch_remote_name = snapshot, remote_name
1551
+ self.fetch_remote_menu.clear()
1552
+ fetch_action = self.fetch_choice_action_by_mode[FetchMode.FETCH]
1553
+ fetch_action.setMenu(None)
1554
+ names: list[str] = []
1555
+ if snapshot is not None:
1556
+ for name in sorted(snapshot.entry_by_name):
1557
+ if snapshot.entry_by_name[name].active:
1558
+ names.append(name)
1559
+ if remote_name:
1560
+ self.statusBar().showMessage("Fetch from remote: " + remote_name)
1561
+ fetch_action.setToolTip("Fetch from remote: " + remote_name)
1562
+ fetch_action.setEnabled(not self.fetch_busy)
1563
+ return
1564
+ message = error or ("Choose a remote for Fetch." if names else "No active remotes. Use Manage remotes to add or activate a destination.")
1565
+ self.statusBar().showMessage(message)
1566
+ hint_action = self.fetch_remote_menu.addAction(message)
1567
+ hint_action.setEnabled(False)
1568
+ for name in names:
1569
+ action = self.fetch_remote_menu.addAction(name.replace("&", "&&"))
1570
+ action.setData(name)
1571
+ action.setToolTip(name)
1572
+ action.setEnabled(not self.fetch_busy)
1573
+ action.triggered.connect(lambda checked=False, name=name, capture=snapshot: self.fetch_selection_requested.emit((FetchMode.FETCH, name, capture)))
1574
+ self.fetch_remote_menu.addAction("Manage remotes", self.remote_requested.emit)
1575
+ fetch_action.setMenu(self.fetch_remote_menu)
1576
+ fetch_action.setEnabled(not self.fetch_busy)
1577
+
1578
+ def _branch_activated(self, index: int) -> None:
1579
+ """Open only a real branch choice; browsing alone never changes HEAD."""
1580
+ full_ref = self.branch_selector.itemData(index)
1581
+ if full_ref and WindowCapability.MANAGE in self.capabilities:
1582
+ self.checkout_requested.emit(full_ref)
1583
+ if self.branch_selector.snapshot is not None:
1584
+ self.branch_selector.populate(self.branch_selector.snapshot)
1585
+
1586
+ def _new_context_menu(self) -> QMenu:
1587
+ """Create an empty right-click menu owned by the window, the way every right-click menu of the window is created.
1588
+
1589
+ The graph menu and the stash menu are built here so they cannot drift apart: same owner, so the same style,
1590
+ font and colors, whatever panel they open from, like the file menus, which the window owns too.
1591
+ """
1592
+ return QMenu(self)
1593
+
1594
+ def show_history_menu(self, index: QModelIndex, global_position: QPoint) -> None:
1595
+ """Select the clicked commit before attaching exact hashes to the menu actions."""
1596
+ if self.history_menu is not None:
1597
+ self.history_menu.close()
1598
+ self.history_menu.deleteLater()
1599
+ commit = None
1600
+ if index.isValid():
1601
+ self.commit_list.setCurrentRow(index.row())
1602
+ commit = self.history_model.commits[index.row()]
1603
+ menu = self._new_context_menu()
1604
+ self.history_menu = menu
1605
+ for kind in CopiedHash:
1606
+ value = ""
1607
+ if commit is not None:
1608
+ value = commit.oid if kind == CopiedHash.FULL else commit.short_oid
1609
+ action = menu.addAction(action_icon("copy_hash", self.devicePixelRatioF()), kind.copy_label)
1610
+ action.setEnabled(bool(value))
1611
+ action.triggered.connect(lambda checked=False, value=value, kind=kind: self.copy_hash(value, kind))
1612
+ if commit is not None and WindowCapability.MANAGE in self.capabilities and not self.context.is_bare:
1613
+ oid = commit.oid
1614
+ snapshot = self.branch_selector.snapshot
1615
+ menu.addSeparator()
1616
+ cherry_pick = menu.addAction(action_icon("cherry_pick", self.devicePixelRatioF()), "Cherry-pick…")
1617
+ cherry_pick.setData(oid)
1618
+ # Replaying the current commit onto itself has nothing to apply; Revert of the current commit stays a normal use.
1619
+ cherry_pick.setEnabled(snapshot is not None and bool(snapshot.head_oid) and oid != snapshot.head_oid)
1620
+ cherry_pick.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.replay_requested.emit(oid, ReplayKind.CHERRY_PICK, snapshot))
1621
+ revert = menu.addAction(action_icon("revert", self.devicePixelRatioF()), "Revert…")
1622
+ revert.setData(oid)
1623
+ revert.setEnabled(snapshot is not None and bool(snapshot.head_oid))
1624
+ revert.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.replay_requested.emit(oid, ReplayKind.REVERT, snapshot))
1625
+ rebase = menu.addAction(action_icon("rebase", self.devicePixelRatioF()), "Rebase current branch here interactively")
1626
+ rebase.setData(oid)
1627
+ rebase.setEnabled(snapshot is not None and bool(snapshot.head_oid))
1628
+ rebase.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.rebase_start_requested.emit(oid, snapshot))
1629
+ checkout = menu.addAction(action_icon("checkout_commit", self.devicePixelRatioF()), "Checkout this commit…")
1630
+ checkout.setData(oid)
1631
+ checkout.setEnabled(snapshot is not None)
1632
+ checkout.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.checkout_commit_requested.emit(oid, snapshot))
1633
+ create = menu.addAction(action_icon("branch_create", self.devicePixelRatioF()), "Create new branch here…")
1634
+ create.setData(oid)
1635
+ create.setEnabled(snapshot is not None)
1636
+ create.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.create_branch_requested.emit(oid, snapshot))
1637
+ create_tag = menu.addAction("Create new tag here…")
1638
+ create_tag.setIcon(action_icon("tag_create", self.devicePixelRatioF()))
1639
+ create_tag.setData(oid)
1640
+ create_tag.setEnabled(snapshot is not None)
1641
+ create_tag.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.create_tag_requested.emit(oid, snapshot))
1642
+ reset = menu.addAction(action_icon("reset_current", self.devicePixelRatioF()), "Reset current branch to here…")
1643
+ reset.setData(oid)
1644
+ reset.setEnabled(snapshot is not None and bool(snapshot.head_ref) and bool(snapshot.head_oid))
1645
+ reset.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.reset_requested.emit(oid, snapshot))
1646
+ move = menu.addAction(action_icon("reset_other", self.devicePixelRatioF()), "Reset another branch to here…")
1647
+ move.setData(oid)
1648
+ move.setEnabled(snapshot is not None)
1649
+ move.triggered.connect(lambda checked=False, oid=oid, snapshot=snapshot: self.move_branch_requested.emit(oid, snapshot))
1650
+ references = self.history_model.references_by_oid.get(oid, [])
1651
+ tag_menu = menu.addMenu(action_icon("tag", self.devicePixelRatioF()), "Checkout tag…")
1652
+ tag_delete_menu = menu.addMenu(action_icon("tag_delete", self.devicePixelRatioF()), "Delete tag…")
1653
+ for reference in sorted(references, key=reference_label):
1654
+ if reference.kind != ReferenceKind.TAGS or reference.symbolic_target or not reference.target_oid:
1655
+ continue
1656
+ tag_action = tag_menu.addAction(action_icon("checkout_commit", self.devicePixelRatioF()), reference_label(reference))
1657
+ tag_action.setData((reference.full_name, reference.target_oid))
1658
+ tag_action.setToolTip("Checkout this tag at " + reference.target_oid)
1659
+ tag_action.setEnabled(snapshot is not None)
1660
+ tag_action.triggered.connect(lambda checked=False, target_oid=reference.target_oid, snapshot=snapshot: self.checkout_commit_requested.emit(target_oid, snapshot))
1661
+ object_oid = snapshot.object_oid_by_name.get(reference.full_name, "") if snapshot is not None else ""
1662
+ if not object_oid:
1663
+ continue
1664
+ tag_delete = tag_delete_menu.addAction(action_icon("tag_delete", self.devicePixelRatioF()), reference_label(reference))
1665
+ tag_delete.setData((reference.full_name, object_oid))
1666
+ tag_delete.setEnabled(bool(object_oid))
1667
+ tag_delete.triggered.connect(lambda checked=False, full_ref=reference.full_name, target_oid=object_oid, snapshot=snapshot: self.delete_tag_requested.emit(full_ref, target_oid, snapshot))
1668
+ for submenu in (tag_menu, tag_delete_menu):
1669
+ submenu.menuAction().setEnabled(snapshot is not None and bool(submenu.actions()))
1670
+ branch_menu = menu.addMenu(action_icon("branch_checkout", self.devicePixelRatioF()), "Checkout branch…")
1671
+ rename_menu = menu.addMenu(action_icon("branch_rename", self.devicePixelRatioF()), "Rename branch…")
1672
+ delete_menu = menu.addMenu(action_icon("branch_delete", self.devicePixelRatioF()), "Delete branch…")
1673
+ previous_branch_kind: ReferenceKind | None = None
1674
+ for reference in sorted(references, key=lambda reference: (reference.kind != ReferenceKind.HEADS, reference_label(reference))):
1675
+ if reference.kind not in (ReferenceKind.HEADS, ReferenceKind.REMOTES) or reference.symbolic_target or not reference.target_oid:
1676
+ continue
1677
+ full_ref = reference.full_name
1678
+ target_oid = reference.target_oid
1679
+ if reference.kind == ReferenceKind.HEADS:
1680
+ rename = rename_menu.addAction(action_icon("branch_rename", self.devicePixelRatioF()), reference_label(reference))
1681
+ rename.setData((full_ref, target_oid))
1682
+ rename.setEnabled(snapshot is not None)
1683
+ rename.triggered.connect(lambda checked=False, full_ref=full_ref, target_oid=target_oid, snapshot=snapshot: self.rename_branch_requested.emit(full_ref, target_oid, snapshot))
1684
+ if snapshot is None or full_ref != snapshot.head_ref:
1685
+ if previous_branch_kind == ReferenceKind.HEADS and reference.kind == ReferenceKind.REMOTES:
1686
+ branch_menu.addSeparator()
1687
+ delete_menu.addSeparator()
1688
+ previous_branch_kind = reference.kind
1689
+ branch_action = branch_menu.addAction(action_icon("branch_checkout", self.devicePixelRatioF()), reference_label(reference))
1690
+ branch_action.setData((full_ref, target_oid))
1691
+ branch_action.setEnabled(snapshot is not None)
1692
+ branch_action.triggered.connect(lambda checked=False, full_ref=full_ref, target_oid=target_oid, snapshot=snapshot: self.checkout_branch_requested.emit(full_ref, target_oid, snapshot))
1693
+ delete = delete_menu.addAction(action_icon("branch_delete", self.devicePixelRatioF()), reference_label(reference))
1694
+ delete.setData((full_ref, target_oid))
1695
+ delete.setEnabled(snapshot is not None)
1696
+ delete.triggered.connect(lambda checked=False, full_ref=full_ref, target_oid=target_oid, snapshot=snapshot: self.delete_branch_requested.emit(full_ref, target_oid, snapshot))
1697
+ for submenu in (branch_menu, rename_menu, delete_menu):
1698
+ submenu.menuAction().setEnabled(snapshot is not None and bool(submenu.actions()))
1699
+ menu.popup(global_position)
1700
+
1701
+ def _filter_edited(self, text: str) -> None:
1702
+ """Invalidate stale responses immediately while delaying the expensive scan."""
1703
+ if text:
1704
+ self.pending_navigation_oid = None
1705
+ self.filter_text = text
1706
+ self.filter_changed.emit(text)
1707
+ self.search_timer.start()
1708
+
1709
+ def _request_search(self) -> None:
1710
+ """Submit the latest text once for either Enter or the debounce timer."""
1711
+ self.search_timer.stop()
1712
+ self.search_requested.emit()
1713
+
1714
+ def show_filter_mode(self, mode: HistoryFilterMode) -> None:
1715
+ """Show what the filter searches, on the mode button, its menu and the field, without starting a search.
1716
+
1717
+ Used when the window is built and when a repository or a walk back puts a mode back.
1718
+ """
1719
+ self.filter_mode = mode
1720
+ self.filter_mode_button.setText(mode.label)
1721
+ self.filter_mode_action_by_mode[mode].setChecked(True)
1722
+ self.filter_edit.setPlaceholderText(mode.placeholder)
1723
+ self.filter_edit.setAccessibleName(mode.field_name)
1724
+
1725
+ def choose_filter_mode(self, mode: HistoryFilterMode) -> None:
1726
+ """Switch what the filter searches, and search the typed text again at once in the new mode.
1727
+
1728
+ With an empty field only the texts change: an empty filter shows every commit in both modes.
1729
+ """
1730
+ if mode == self.filter_mode:
1731
+ return
1732
+ self.show_filter_mode(mode)
1733
+ if not self.filter_text:
1734
+ return
1735
+ self.filter_changed.emit(self.filter_text)
1736
+ self._request_search()
1737
+
1738
+ def clear_filter(self) -> None:
1739
+ """Restore all captured commits without waiting for the typing timer."""
1740
+ self.filter_edit.clear()
1741
+ self._request_search()
1742
+
1743
+ def begin_search(self) -> None:
1744
+ """Keep old rows painted but prevent acting on a different query's results."""
1745
+ self.search_pending = True
1746
+ self.commit_list.setEnabled(False)
1747
+ self.history_state_label.setText(RepositoryWindowText.FILTER_PREVIOUS.value)
1748
+ self.history_state_label.show()
1749
+ self.show_search_progress(0, 0)
1750
+
1751
+ def capture_anchor(self) -> ViewAnchor:
1752
+ """Record the top row's partial pixel position and independent selection."""
1753
+ selected_index = self.commit_list.currentIndex()
1754
+ top_row = max(FIRST_ROW, self.commit_list.rowAt(0))
1755
+ top_index = self.history_model.index(top_row, MESSAGE_COLUMN)
1756
+ return ViewAnchor(selected_index.data(OID_ROLE), max(FIRST_ROW, selected_index.row()), top_index.data(OID_ROLE), top_row,
1757
+ self.commit_list.visualRect(top_index).top() if top_index.isValid() else 0,
1758
+ self.commit_list.horizontalScrollBar().value(), self.filter_edit.text(), self.filter_mode)
1759
+
1760
+ def clear_commit_selection(self) -> None:
1761
+ """Keep remaining rows visible without substituting details for an absent selection."""
1762
+ self.commit_list.clearSelection()
1763
+ self.commit_list.setCurrentIndex(QModelIndex())
1764
+ self.commit = None
1765
+ self.pending_oid = None
1766
+ self.commit_panel.clear_selection()
1767
+ self.diff_panel.clear_selection()
1768
+ self.file_tree_panel.clear_selection()
1769
+
1770
+ def restore_anchor(self, anchor: ViewAnchor) -> None:
1771
+ """Restore OIDs first, then nearby surviving rows and bounded pixel offsets."""
1772
+ count = self.history_model.rowCount()
1773
+ if not count:
1774
+ self.commit_list.clearSelection()
1775
+ self.commit = None
1776
+ self.commit_panel.clear_selection()
1777
+ return
1778
+ last_row = count - 1
1779
+ selected_row = self.history_model.row_by_oid.get(anchor.selected_oid, min(anchor.selected_visible_index, last_row))
1780
+ top_row = self.history_model.row_by_oid.get(anchor.top_oid, min(anchor.top_visible_index, last_row))
1781
+ self.commit_list.setCurrentRow(selected_row)
1782
+ self.commit_list.doItemsLayout()
1783
+ self.commit_list.scrollTo(self.history_model.index(top_row, MESSAGE_COLUMN), QAbstractItemView.ScrollHint.PositionAtTop)
1784
+ vertical_scrollbar = self.commit_list.verticalScrollBar()
1785
+ vertical_scrollbar.setValue(vertical_scrollbar.value() - anchor.top_pixel_offset)
1786
+ self.commit_list.horizontalScrollBar().setValue(anchor.horizontal_offset)
1787
+ if anchor.selected_oid and anchor.selected_oid not in self.history_model.row_by_oid:
1788
+ self.statusBar().showMessage(self.statusBar().currentMessage() + " — The selected commit is no longer visible.")
1789
+
1790
+ def show_search_progress(self, match_count: int, scanned_count: int) -> None:
1791
+ """Announce provisional matches without claiming an empty final result."""
1792
+ suffix = "es" if match_count != 1 else ""
1793
+ scanned_suffix = "s" if scanned_count != 1 else ""
1794
+ self.statusBar().showMessage(f"Searching… {match_count} match{suffix} found — {scanned_count} commit{scanned_suffix} scanned")
1795
+
1796
+ def show_matches(self, snapshot: HistorySnapshot, commits: list[Commit], text: str, selected_oid: str | None,
1797
+ visible_parent_oids_by_oid: dict[str, list[str]] | None = None) -> None:
1798
+ """Replace rows and true parent boundaries in one synchronous publication."""
1799
+ self.search_pending = False
1800
+ self.filter_text = text
1801
+ self.pending_oid = None
1802
+ self.anchor_oid = None
1803
+ # A new history already empties the rows and walks HEAD's ancestry in show_snapshot; doing it twice costs a
1804
+ # walk of the whole history.
1805
+ snapshot_replaced = self.snapshot is not snapshot
1806
+ self.show_snapshot(snapshot)
1807
+ if not snapshot_replaced:
1808
+ self.history_model.reset_snapshot(snapshot)
1809
+ self.history_model.visible_parent_oids_by_oid = visible_parent_oids_by_oid
1810
+ self.commit_list.graph_width = 0
1811
+ if text or visible_parent_oids_by_oid is not None:
1812
+ self.history_model.visible_oid_set = set()
1813
+ for commit in commits:
1814
+ self.history_model.visible_oid_set.add(commit.oid)
1815
+ self.pending_oid = selected_oid if any(commit.oid == selected_oid for commit in commits) else None
1816
+ self.append_history(commits)
1817
+ if self.mode == "file_history":
1818
+ for group_index in range(self.navigation_tree.topLevelItemCount()):
1819
+ group = self.navigation_tree.topLevelItem(group_index)
1820
+ for item_index in range(group.childCount()):
1821
+ item = group.child(item_index)
1822
+ item.setDisabled(item.data(NAVIGATION_COLUMN, OID_ROLE) not in self.history_model.row_by_oid)
1823
+ self.commit_list.setEnabled(True)
1824
+ if not commits:
1825
+ self.commit = None
1826
+ self.commit_panel.clear_selection()
1827
+ self.update_history_status()
1828
+ if commits and selected_oid and selected_oid not in self.history_model.row_by_oid:
1829
+ self.statusBar().showMessage(self.statusBar().currentMessage() + " — The selected commit is no longer visible.")
1830
+ if self.pending_navigation_oid is not None:
1831
+ oid = self.pending_navigation_oid
1832
+ self.pending_navigation_oid = None
1833
+ self.pending_oid = oid
1834
+ self.navigation_requested.emit(oid)
1835
+
1836
+ def set_avatar_service(self, service: AvatarService) -> None:
1837
+ """Share photos between selected details and visible history authors."""
1838
+ self.commit_panel.set_avatar_service(service)
1839
+ self.history_avatar.set_service(service)
1840
+
1841
+ def set_tool_busy(self, tool: str, busy: bool) -> None:
1842
+ """Prevent duplicate activation only while the selected external tool is starting."""
1843
+ if self.is_closed or WindowCapability.MANAGE not in self.capabilities:
1844
+ return
1845
+ if tool == "explorer":
1846
+ self.explorer_action.setEnabled(not busy)
1847
+ elif tool == "terminal":
1848
+ self.terminal_busy = busy
1849
+ self._update_terminal_action()
1850
+
1851
+ def bind_terminal_registry(self, registry: TerminalRegistry) -> None:
1852
+ """Share inventory and persisted selection without sharing a repository directory."""
1853
+ if self.is_closed or WindowCapability.MANAGE not in self.capabilities:
1854
+ return
1855
+ if self.terminal_registry is registry:
1856
+ return
1857
+ if self.terminal_registry is not None:
1858
+ self.terminal_registry.changed.disconnect(self._show_terminals)
1859
+ self.terminal_registry = registry
1860
+ registry.changed.connect(self._show_terminals)
1861
+ self._show_terminals()
1862
+
1863
+ def _show_terminals(self) -> None:
1864
+ """Rebuild exclusive choices from the shared inventory with full-path tooltips."""
1865
+ if self.terminal_registry is None:
1866
+ return
1867
+ self.terminal_menu.clear()
1868
+ for action in self.terminal_choice_actions:
1869
+ self.terminal_group.removeAction(action)
1870
+ action.deleteLater()
1871
+ self.terminal_choice_actions.clear()
1872
+ for terminal in self.terminal_registry.terminals:
1873
+ label = terminal.label + (" — Unavailable" if not terminal.available else "")
1874
+ action = QAction(label, self)
1875
+ action.setCheckable(True)
1876
+ action.setChecked(terminal.id == self.terminal_registry.selected_id)
1877
+ action.setEnabled(terminal.available)
1878
+ action.setToolTip(f"{label}\n{terminal.program_filename}")
1879
+ action.setData(terminal.id)
1880
+ action.triggered.connect(lambda _checked, terminal_id=terminal.id: self._select_terminal(terminal_id))
1881
+ self.terminal_group.addAction(action)
1882
+ self.terminal_choice_actions.append(action)
1883
+ self.terminal_menu.addAction(action)
1884
+ if not self.terminal_choice_actions:
1885
+ self.terminal_menu.addAction(RepositoryWindowText.TERMINAL_EMPTY.value).setEnabled(False)
1886
+ self.terminal_menu.addSeparator()
1887
+ self.terminal_menu.addAction(self.redetect_action)
1888
+ self._update_terminal_action()
1889
+ if self.terminal_registry.warning:
1890
+ self.statusBar().showMessage(self.terminal_registry.warning)
1891
+
1892
+ def _update_terminal_action(self) -> None:
1893
+ """Explain missing choices and disable only launch while keeping the arrow usable."""
1894
+ selected = self.terminal_registry.selected() if self.terminal_registry is not None else None
1895
+ enabled = selected is not None and not self.terminal_busy
1896
+ self.terminal_action.setEnabled(enabled)
1897
+ self.terminal_button.main_enabled = enabled
1898
+ description = RepositoryWindowText.TERMINAL_EMPTY.value
1899
+ if selected is not None:
1900
+ description = f"{selected.label}{' — Unavailable' if not selected.available else ''}\n{selected.program_filename}"
1901
+ self.terminal_button.setToolTip(description)
1902
+ self.terminal_button.setAccessibleName(f"{RepositoryWindowText.TERMINAL.value} — {description}")
1903
+ self.terminal_action.setToolTip(description)
1904
+
1905
+ def _select_terminal(self, terminal_id: str) -> None:
1906
+ """Save a menu choice without launching and restore the old check on write failure."""
1907
+ if self.is_closed or WindowCapability.MANAGE not in self.capabilities or self.terminal_registry is None:
1908
+ return
1909
+ try:
1910
+ self.terminal_registry.select(terminal_id)
1911
+ except OSError as error:
1912
+ self._show_terminals()
1913
+ self._show_terminal_error(str(error))
1914
+
1915
+ def _redetect_terminal(self) -> None:
1916
+ """Refresh shared terminal choices only from a live management window."""
1917
+ if not self.is_closed and WindowCapability.MANAGE in self.capabilities and self.terminal_registry is not None:
1918
+ self.terminal_registry.redetect()
1919
+
1920
+ def _request_terminal(self) -> None:
1921
+ """Capture the chosen descriptor at activation, leaving selection changes launch-free."""
1922
+ if self.is_closed or WindowCapability.MANAGE not in self.capabilities or self.terminal_registry is None or not self.terminal_button.main_enabled:
1923
+ return
1924
+ selected = self.terminal_registry.selected()
1925
+ if selected is None:
1926
+ return
1927
+ if not selected.available:
1928
+ self._show_terminal_error(f"Terminal {selected.label} is unavailable: executable not found ({selected.program_filename}). Choose another terminal using the button arrow.")
1929
+ return
1930
+ self.terminal_requested.emit(selected)
1931
+
1932
+ def _show_terminal_error(self, cause: str) -> None:
1933
+ """Present persistence or missing-tool errors with an explicit menu recovery action."""
1934
+ diagnostic = Diagnostic("Choose terminal", self.context.repository_dir, "", [], cause).prepare()
1935
+ dialog = ErrorDialog(diagnostic, self)
1936
+ self.terminal_dialog = dialog
1937
+ choose_button = QPushButton(SharedText.TERMINAL_CHOOSE.value, dialog)
1938
+ dialog.layout().addWidget(choose_button)
1939
+ choose_button.clicked.connect(dialog.accept)
1940
+ choose_button.clicked.connect(lambda: self.terminal_menu.popup(self.terminal_button.mapToGlobal(self.terminal_button.rect().bottomLeft())))
1941
+ dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
1942
+ open_window_modal(dialog)
1943
+
1944
+ def show_recent_repositories(self, recent_dirs: list[str]) -> None:
1945
+ """Keep the latest repository history for the switch list, which is rebuilt from it each time it opens."""
1946
+ self.recent_dirs = list(recent_dirs)
1947
+
1948
+ def _show_repository_list(self) -> None:
1949
+ """Open the switch list under the repository button: the current repository first, then the others, numbered.
1950
+
1951
+ Choosing a row asks for that repository in this window.
1952
+ """
1953
+ if self.repository_list_popup is None:
1954
+ self.repository_list_popup = RepositoryListPopup(self)
1955
+ self.repository_list_popup.repository_chosen.connect(self.repository_switch_requested.emit)
1956
+ # No folder is checked here: a missing network drive would block the window. A missing folder is reported when chosen.
1957
+ self.repository_list_popup.show_entries(repository_menu_dirs(self.context.repository_dir, self.recent_dirs), self.repository_button)
1958
+
1959
+ def show_repository(self, context: RepositoryContext, *, fresh: bool) -> None:
1960
+ """Point the window at another repository, before its history is shown.
1961
+
1962
+ fresh is true for a repository chosen in the switch list: the window then starts as if just opened, with empty
1963
+ filter and hash fields, the filter on messages, default groups in the left panel, no stash, submodule family, branch or notice of the
1964
+ previous repository, an uncounted Commit action and the Commit tab. A walk into a submodule or back to its parent keeps
1965
+ the fields and the left panel, since both belong to the same family. Either way the banners of the previous
1966
+ repository, including the wait message shown while Git finished there, are hidden.
1967
+ """
1968
+ self.search_timer.stop()
1969
+ self.context = context
1970
+ self.repository_button.show_path(context.repository_dir)
1971
+ self.show_parent_reference(None)
1972
+ self.snapshot = None
1973
+ self.commit_relation = None
1974
+ self.pending_navigation_oid = None
1975
+ self.centered_oid = None
1976
+ self.setWindowTitle(repository_window_title(context.repository_dir))
1977
+ self.navigation_tree.setHeaderLabel(repository_display_name(context.repository_dir))
1978
+ self._apply_repository_capability()
1979
+ self._hide_repository_banners()
1980
+ if not fresh:
1981
+ return
1982
+ with QSignalBlocker(self.filter_edit):
1983
+ self.filter_edit.clear()
1984
+ self.filter_text = ""
1985
+ self.show_filter_mode(HISTORY_FILTER_INITIAL_MODE)
1986
+ self.commit_hash_edit.clear()
1987
+ # show_snapshot draws the family it holds again: the previous repository's family must not reach the new one.
1988
+ self.submodule_snapshot = None
1989
+ self._clear_navigation_tree()
1990
+ self.history_state_label.hide()
1991
+ self.commit_action.setText(COMMIT_UNKNOWN_COUNT_LABEL)
1992
+ self.commit_action.setIcon(action_icon("commit", self.devicePixelRatioF()))
1993
+ # Without a state, the next count of the new working tree draws its own icon instead of keeping the old color.
1994
+ self.commit_action.setProperty("change_state", None)
1995
+ self.commit_action.setToolTip("")
1996
+ self.status_notice_by_kind_by_worktree.clear()
1997
+ # The remotes of the previous repository must never be offered, or fetched, in the new one: the Fetch menu
1998
+ # reads the remotes again when it opens.
1999
+ self.fetch_snapshot = None
2000
+ self.fetch_remote_name = ""
2001
+ self.fetch_remote_menu.clear()
2002
+ self.fetch_choice_action_by_mode[FetchMode.FETCH].setMenu(None)
2003
+ self.branch_selector.forget_references()
2004
+ self.detail_tabs.setCurrentIndex(COMMIT_TAB_INDEX)
2005
+
2006
+ def _apply_repository_capability(self) -> None:
2007
+ """Show or enable the actions allowed by the window's mode and by whether the displayed repository has a working tree.
2008
+
2009
+ A bare repository has no branch to check out, nothing to commit and nothing to stash; the window may show one
2010
+ after another repository, so the state is computed again for each repository shown.
2011
+ """
2012
+ manage = WindowCapability.MANAGE in self.capabilities
2013
+ working_tree = manage and not self.context.is_bare
2014
+ self.branch_selector_action.setVisible(working_tree)
2015
+ self.commit_action.setEnabled(working_tree)
2016
+ self.stash_action.setEnabled(working_tree)
2017
+ self.stash_pop_action.setEnabled(working_tree)
2018
+
2019
+ def _hide_repository_banners(self) -> None:
2020
+ """Hide the conflict, rebase and Cherry-pick/Revert banners, and the wait message shown in the rebase banner.
2021
+
2022
+ They describe the repository the window showed; the new one publishes its own state once it has been read.
2023
+ """
2024
+ self.conflict_banner.hide()
2025
+ self.rebase_banner.hide()
2026
+ self.replay_banner.hide()
2027
+ self.merge_continue_button.hide()
2028
+ self.merge_abort_button.hide()
2029
+ self.merge_banner_state = None
2030
+ self.conflict_state = None
2031
+ self.resolve_button.setEnabled(False)
2032
+ if self.resolve_action is not None:
2033
+ self.resolve_action.setEnabled(False)
2034
+
2035
+ def _clear_navigation_tree(self) -> None:
2036
+ """Empty the left panel and forget its expanded groups, stashes and submodule tree, as in a window just opened.
2037
+
2038
+ show_snapshot copies the expanded state of the groups it finds by label, so the groups the previous repository
2039
+ had expanded would otherwise stay expanded.
2040
+ """
2041
+ self.navigation_tree.clear()
2042
+ self.stash_group = None
2043
+ self.stash_group_user_expanded = None
2044
+ self.stash_list = None
2045
+ self.stash_list_context = None
2046
+ self.shown_stash_entries = ()
2047
+ self.submodule_item_by_key.clear()
2048
+ self.submodule_expanded_by_key.clear()
2049
+ self.submodule_selected_key = None
2050
+ self.submodule_scroll = SUBMODULE_SCROLL_START
2051
+
2052
+ def show_commit(self, commit: Commit | None) -> None:
2053
+ """Replace the visible data together so selection cannot mix two commits."""
2054
+ self.commit = commit
2055
+ state = "Bare repository — no working tree: " if self.context.is_bare else ""
2056
+ self.statusBar().showMessage(state + self.context.repository_dir)
2057
+ if commit is None:
2058
+ self.commit_panel.clear_selection()
2059
+ self.detail_edit.setPlainText("No commits in this repository\n\nThis repository has no commits yet. Create one in your terminal, then use Refresh.")
2060
+ return
2061
+ self.history_model.append_batch([commit])
2062
+ self.commit_list.setCurrentRow(FIRST_ROW)
2063
+ self.commit_panel.show_commit(commit)
2064
+
2065
+ def begin_history(self) -> None:
2066
+ """Keep the previous view readable while an updated capture is prepared."""
2067
+ self.pending_oid = self.commit.oid if self.commit is not None else None
2068
+ top_index = self.commit_list.indexAt(self.commit_list.viewport().rect().topLeft())
2069
+ self.anchor_oid = top_index.data(OID_ROLE)
2070
+ self.statusBar().showMessage(("Bare repository — " if self.context.is_bare else "") + RepositoryWindowText.HISTORY_LOADING.value)
2071
+
2072
+ def _reference_icon_by_kind(self) -> dict[ReferenceKind, QIcon]:
2073
+ """Return the icon of each reference group of the left panel, asked now of the style and of the packaged pictures.
2074
+
2075
+ Each icon is asked once, whatever the number of references. The branch icon is the icon theme's folder as Qt
2076
+ draws it at the time of the call: Qt keeps that drawing, so an icon asked before the desktop changed theme keeps
2077
+ the colors of the old theme. Breeze draws its folder in one color, dark in the light theme and light in the dark
2078
+ one, so such an icon disappears on the new background.
2079
+ """
2080
+ branch_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirLinkIcon)
2081
+ tag_icon = action_icon("tag", self.devicePixelRatioF())
2082
+ icon_by_kind: dict[ReferenceKind, QIcon] = {}
2083
+ for kind in BRANCH_TREE_GROUP_KINDS:
2084
+ icon_by_kind[kind] = tag_icon if kind == ReferenceKind.TAGS else branch_icon
2085
+ return icon_by_kind
2086
+
2087
+ def _lay_reference_icons(self) -> None:
2088
+ """Give the reference rows already in the left panel the icons of the theme in use, without building the tree again or reading Git."""
2089
+ group_by_kind: dict[ReferenceKind, QTreeWidgetItem] = {}
2090
+ for index in range(self.navigation_tree.topLevelItemCount()):
2091
+ group = self.navigation_tree.topLevelItem(index)
2092
+ for kind in BRANCH_TREE_GROUP_KINDS:
2093
+ if group.text(NAVIGATION_COLUMN) == kind.label:
2094
+ group_by_kind[kind] = group
2095
+ if not group_by_kind:
2096
+ return
2097
+ icon_by_kind = self._reference_icon_by_kind()
2098
+ for kind, group in group_by_kind.items():
2099
+ icon = icon_by_kind[kind]
2100
+ for row in range(group.childCount()):
2101
+ group.child(row).setIcon(NAVIGATION_COLUMN, icon)
2102
+
2103
+ def changeEvent(self, event: QEvent) -> None:
2104
+ """Lay the left panel's reference icons again when the window's style changes, which Qt also announces after each desktop theme change.
2105
+
2106
+ Qt turns the theme notice it gives the window (light to dark, another icon theme) into a style notice, and only
2107
+ once its icon loader follows the new theme, so the icons asked then are the new theme's.
2108
+ """
2109
+ super().changeEvent(event)
2110
+ if event.type() == QEvent.Type.StyleChange:
2111
+ self._lay_reference_icons()
2112
+
2113
+ def show_snapshot(self, snapshot: HistorySnapshot) -> None:
2114
+ """Publish navigation with the accepted history generation, never mixed refs."""
2115
+ snapshot_replaced = self.snapshot is not snapshot
2116
+ if snapshot_replaced:
2117
+ self.snapshot = snapshot
2118
+ self.graph_index_complete = False
2119
+ self.history_model.reset_snapshot(snapshot)
2120
+ self.commit_list.graph_width = 0
2121
+ self.commit_list.resize_subject()
2122
+ expanded_by_label: dict[str, bool] = {}
2123
+ for index in range(self.navigation_tree.topLevelItemCount()):
2124
+ group = self.navigation_tree.topLevelItem(index)
2125
+ expanded_by_label[group.text(NAVIGATION_COLUMN)] = group.isExpanded()
2126
+ self._capture_submodule_tree()
2127
+ # Clearing the tree destroys the Qt item behind the stash group; it is forgotten first so that nothing Qt
2128
+ # reports about it while the tree empties can be taken for a choice of the user.
2129
+ self.stash_group = None
2130
+ self.navigation_tree.clear()
2131
+ self.submodule_item_by_key.clear()
2132
+ icon_by_kind = self._reference_icon_by_kind()
2133
+ for kind in BRANCH_TREE_GROUP_KINDS:
2134
+ label = kind.label
2135
+ group = QTreeWidgetItem([label])
2136
+ self.navigation_tree.addTopLevelItem(group)
2137
+ icon = icon_by_kind[kind]
2138
+ for reference in snapshot.references:
2139
+ if reference.kind != kind:
2140
+ continue
2141
+ name = reference_label(reference)
2142
+ if reference.is_head and kind != ReferenceKind.HEAD:
2143
+ name += " — HEAD"
2144
+ item = QTreeWidgetItem([name])
2145
+ font = item.font(NAVIGATION_COLUMN)
2146
+ font.setBold(reference.is_head and kind == ReferenceKind.HEADS)
2147
+ item.setFont(NAVIGATION_COLUMN, font)
2148
+ item.setToolTip(NAVIGATION_COLUMN, reference.full_name if reference.target_oid else reference.full_name + " — target is not a commit; navigation unavailable")
2149
+ item.setIcon(NAVIGATION_COLUMN, icon)
2150
+ item.setData(NAVIGATION_COLUMN, OID_ROLE, reference.target_oid)
2151
+ group.addChild(item)
2152
+ group.setExpanded(expanded_by_label.get(label, kind not in (ReferenceKind.REMOTES, ReferenceKind.TAGS)))
2153
+ if self.submodule_snapshot is not None:
2154
+ self.show_submodules(self.submodule_snapshot)
2155
+ if WindowCapability.MANAGE in self.capabilities:
2156
+ self._add_stash_group()
2157
+ if self.pending_oid is None:
2158
+ self.pending_oid = snapshot.head_oid
2159
+ self.update_history_status()
2160
+ if snapshot.complete and not self.graph_index_complete:
2161
+ self.graph_index_complete = True
2162
+ # Replacing the rows just walked the ancestry of this complete history.
2163
+ if not snapshot_replaced:
2164
+ self.history_model.refresh_ancestry()
2165
+ self.history_model.graph_layout.finish_index(set(snapshot.parent_oids_by_oid))
2166
+ self.commit_list.viewport().update()
2167
+
2168
+ def show_submodules(self, snapshot: SubmoduleSnapshot) -> None:
2169
+ """Render real directory hierarchy with only the active repository in bold."""
2170
+ self._capture_submodule_tree()
2171
+ self.submodule_snapshot = snapshot
2172
+ group = None
2173
+ for index in range(self.navigation_tree.topLevelItemCount()):
2174
+ candidate = self.navigation_tree.topLevelItem(index)
2175
+ if candidate.text(NAVIGATION_COLUMN) == "Submodules":
2176
+ group = candidate
2177
+ break
2178
+ if group is None:
2179
+ group = QTreeWidgetItem(["Submodules"])
2180
+ # Stashes stays the last node, as in Git Extensions. Submodules goes in front of it rather than moving the
2181
+ # stash item, which would lose its expanded state.
2182
+ if self.stash_group is not None:
2183
+ self.navigation_tree.insertTopLevelItem(self.navigation_tree.indexOfTopLevelItem(self.stash_group), group)
2184
+ else:
2185
+ self.navigation_tree.addTopLevelItem(group)
2186
+ self.submodule_item_by_key["group"] = group
2187
+ for item in self.submodule_item_by_key.values():
2188
+ item.setHidden(True)
2189
+ item_by_directory: dict[str, QTreeWidgetItem] = {}
2190
+ for entry in snapshot.entries:
2191
+ parent = item_by_directory.get(canonical_path(entry.parent_dir), group) if entry.parent_dir else group
2192
+ parts = entry.path.split("/") if entry.availability != "invalid" else [entry.path]
2193
+ directory = entry.parent_dir or snapshot.root_dir
2194
+ for part in parts[:-1]:
2195
+ directory = os.path.join(directory, part)
2196
+ directory_key = canonical_path(directory)
2197
+ if directory_key not in item_by_directory:
2198
+ folder = self.submodule_item_by_key.get(directory_key)
2199
+ if folder is None:
2200
+ folder = QTreeWidgetItem(parent, [part])
2201
+ self.submodule_item_by_key[directory_key] = folder
2202
+ folder.setExpanded(True)
2203
+ folder.setHidden(False)
2204
+ folder.setIcon(NAVIGATION_COLUMN, action_icon("folder", self.devicePixelRatioF()))
2205
+ item_by_directory[directory_key] = folder
2206
+ parent = item_by_directory[directory_key]
2207
+ state = (entry.branch or "no branch") if entry.availability == "initialized" else entry.availability
2208
+ if entry.submodule.dirty:
2209
+ state += " — Uncommitted changes"
2210
+ elif entry.availability == "initialized" and entry.submodule.dirty is None:
2211
+ state += " — Internal changes unknown"
2212
+ entry_key = canonical_path(entry.repository_dir) if entry.repository_dir else "invalid:" + entry.section
2213
+ item = self.submodule_item_by_key.get(entry_key)
2214
+ if item is None:
2215
+ item = QTreeWidgetItem(parent)
2216
+ self.submodule_item_by_key[entry_key] = item
2217
+ item.setExpanded(True)
2218
+ item.setHidden(False)
2219
+ item.setText(NAVIGATION_COLUMN, f"{os.path.basename(entry.repository_dir) if entry.repository_dir else entry.path} ({state})")
2220
+ item.setToolTip(NAVIGATION_COLUMN, entry.repository_dir + ("\n" + entry.cause if entry.cause else "\nDouble-click or press Enter to open this repository."))
2221
+ if entry.submodule.dirty:
2222
+ item.setToolTip(NAVIGATION_COLUMN, item.toolTip(NAVIGATION_COLUMN) + "\n" + SUBMODULE_DIRTY_DESCRIPTION)
2223
+ item.setIcon(NAVIGATION_COLUMN, action_icon("repository", self.devicePixelRatioF()))
2224
+ item.setData(NAVIGATION_COLUMN, REPOSITORY_ROLE, entry.repository_dir or None)
2225
+ font = item.font(NAVIGATION_COLUMN)
2226
+ font.setBold(bool(entry.repository_dir) and canonical_path(entry.repository_dir) == canonical_path(self.context.repository_dir))
2227
+ item.setFont(NAVIGATION_COLUMN, font)
2228
+ if entry.repository_dir:
2229
+ item_by_directory[canonical_path(entry.repository_dir)] = item
2230
+ if snapshot.cause:
2231
+ error = self.submodule_item_by_key.get("error")
2232
+ if error is None:
2233
+ error = QTreeWidgetItem(group, ["Submodule state is unknown. Review the details and refresh the repository."])
2234
+ self.submodule_item_by_key["error"] = error
2235
+ error.setHidden(False)
2236
+ error.setToolTip(NAVIGATION_COLUMN, snapshot.cause)
2237
+ elif len(snapshot.entries) == 1:
2238
+ empty = self.submodule_item_by_key.get("empty")
2239
+ if empty is None:
2240
+ empty = QTreeWidgetItem(group, ["No submodules"])
2241
+ self.submodule_item_by_key["empty"] = empty
2242
+ empty.setHidden(False)
2243
+ empty.setDisabled(True)
2244
+ empty.setToolTip(NAVIGATION_COLUMN, "This repository has no declared submodules.")
2245
+ group.setExpanded(True)
2246
+ group.setHidden(False)
2247
+ for key, item in self.submodule_item_by_key.items():
2248
+ if key in self.submodule_expanded_by_key:
2249
+ item.setExpanded(self.submodule_expanded_by_key[key])
2250
+ if key == self.submodule_selected_key and not item.isHidden():
2251
+ self.navigation_tree.setCurrentItem(item)
2252
+ horizontal_offset, vertical_offset = self.submodule_scroll
2253
+ self.navigation_tree.doItemsLayout()
2254
+ self.navigation_tree.horizontalScrollBar().setValue(horizontal_offset)
2255
+ self.navigation_tree.verticalScrollBar().setValue(vertical_offset)
2256
+
2257
+ def show_stashes(self, stash_list: StashList) -> None:
2258
+ """Show the stashes read with the latest references, updating the Stashes group in place when it exists.
2259
+
2260
+ The group is expanded as soon as it holds a stash, unless the user expanded or collapsed it by hand in this window:
2261
+ their choice then stays, even when stashes are added or removed. The list is taken as the one of the repository the window shows now, which is the only one its references are
2262
+ published for.
2263
+ """
2264
+ self.stash_list = stash_list
2265
+ self.stash_list_context = self.context
2266
+ if self.stash_group is not None:
2267
+ self._fill_stash_group()
2268
+
2269
+ def _add_stash_group(self) -> None:
2270
+ """Create the Stashes group as the last node of the left panel, expanded when it holds a stash or as the user last left it.
2271
+
2272
+ The stashes often arrive after the group is created; filling it again then applies the same rule.
2273
+ """
2274
+ group = QTreeWidgetItem([STASH_GROUP_LABEL])
2275
+ group.setIcon(NAVIGATION_COLUMN, action_icon("stash", self.devicePixelRatioF()))
2276
+ self.navigation_tree.addTopLevelItem(group)
2277
+ self.stash_group = group
2278
+ self._fill_stash_group()
2279
+
2280
+ def _remember_stash_expansion(self, item: QTreeWidgetItem, expanded: bool) -> None:
2281
+ """Keep the expanded state the user gives the Stashes group, so that later reads of the stashes do not undo it.
2282
+
2283
+ Changes the window applies itself, and those of any other group, are ignored.
2284
+ """
2285
+ if self.applying_stash_expansion or item is not self.stash_group:
2286
+ return
2287
+ self.stash_group_user_expanded = expanded
2288
+
2289
+ def _apply_stash_expansion(self) -> None:
2290
+ """Expand the Stashes group as the user last chose in this window, or else exactly when it holds at least one stash."""
2291
+ expanded = self.stash_group_user_expanded
2292
+ if expanded is None:
2293
+ expanded = self.stash_group.childCount() > 0
2294
+ self.applying_stash_expansion = True
2295
+ try:
2296
+ self.stash_group.setExpanded(expanded)
2297
+ finally:
2298
+ self.applying_stash_expansion = False
2299
+
2300
+ def _fill_stash_group(self) -> None:
2301
+ """Write one `stash@{N}: <message>` line per stash, newest first, and the count on the group when there is any.
2302
+
2303
+ The lines carry no commit or repository, so clicking one navigates nowhere. A list that could not be read leaves
2304
+ the group empty, with the reason in its tooltip. The group is then expanded or collapsed, now that its count is known.
2305
+ """
2306
+ group = self.stash_group
2307
+ group.takeChildren()
2308
+ entries: tuple[StashEntry, ...] = ()
2309
+ cause = ""
2310
+ # After moving to a submodule or back to the parent, the tree is rebuilt for the new repository before its
2311
+ # references, and so its stashes, are read: the list still held belongs to the previous repository and is not
2312
+ # shown. The right list arrives with the reference read that follows.
2313
+ if self.stash_list is not None and self.stash_list_context == self.context:
2314
+ cause = self.stash_list.cause
2315
+ if not cause:
2316
+ entries = self.stash_list.entries
2317
+ group.setToolTip(NAVIGATION_COLUMN, plain_text_tooltip([STASH_UNAVAILABLE_PREFIX + cause]) if cause else "")
2318
+ group.setText(NAVIGATION_COLUMN, f"{STASH_GROUP_LABEL} ({len(entries)})" if entries else STASH_GROUP_LABEL)
2319
+ icon = action_icon("stash", self.devicePixelRatioF())
2320
+ for entry in entries:
2321
+ item = QTreeWidgetItem([f"{stash_selector(entry.index)}: {entry.message}"])
2322
+ item.setIcon(NAVIGATION_COLUMN, icon)
2323
+ item.setToolTip(NAVIGATION_COLUMN, self._stash_tooltip(entry))
2324
+ group.addChild(item)
2325
+ self.shown_stash_entries = entries
2326
+ self._apply_stash_expansion()
2327
+
2328
+ def shown_stash_entry(self, item: QTreeWidgetItem | None) -> StashEntry | None:
2329
+ """Answer the stash a left-panel line shows, or None for any other line, the Stashes group itself included."""
2330
+ if item is None or self.stash_group is None or item.parent() is not self.stash_group:
2331
+ return None
2332
+ position = self.stash_group.indexOfChild(item)
2333
+ if position < 0 or position >= len(self.shown_stash_entries):
2334
+ return None
2335
+ return self.shown_stash_entries[position]
2336
+
2337
+ def show_navigation_menu(self, position: QPoint) -> None:
2338
+ """Open the right-click menu of a stash line in the left panel; other lines have no menu.
2339
+
2340
+ Only a window that may write to the repository offers Drop stash. The menu carries the stash as it was read,
2341
+ so the drop can check it is still the same stash before removing it.
2342
+ """
2343
+ if self.navigation_menu is not None:
2344
+ self.navigation_menu.close()
2345
+ self.navigation_menu.deleteLater()
2346
+ self.navigation_menu = None
2347
+ entry = self.shown_stash_entry(self.navigation_tree.itemAt(position))
2348
+ if entry is None or WindowCapability.MANAGE not in self.capabilities or self.context.is_bare:
2349
+ return
2350
+ menu = self._new_context_menu()
2351
+ self.navigation_menu = menu
2352
+ # The icon gives the entry the icon column every other menu of the window has; without it the style lays the
2353
+ # text alone in a box its minimum width makes much wider than the text.
2354
+ drop = menu.addAction(action_icon("stash", self.devicePixelRatioF()), DROP_OPERATION_LABEL)
2355
+ drop.triggered.connect(lambda checked=False, entry=entry: self.stash_drop_requested.emit(entry))
2356
+ menu.popup(self.navigation_tree.viewport().mapToGlobal(position))
2357
+
2358
+ def refresh_stash_date(self) -> None:
2359
+ """Rewrite the stash tooltips so their relative creation date keeps up with the clock, like the history dates."""
2360
+ if self.stash_group is None:
2361
+ return
2362
+ for position, entry in enumerate(self.shown_stash_entries):
2363
+ self.stash_group.child(position).setToolTip(NAVIGATION_COLUMN, self._stash_tooltip(entry))
2364
+
2365
+ def _stash_tooltip(self, entry: StashEntry) -> str:
2366
+ """Describe one stash for its tooltip: full message, creation date, starting commit when known, and stash commit.
2367
+
2368
+ A stash message is free text and may look like markup, which a Qt tooltip would render; every line is escaped
2369
+ so the tooltip shows exactly what Git stored.
2370
+ """
2371
+ lines = [entry.message, STASH_CREATED_PREFIX + format_commit_date(entry.timestamp, datetime.now(UTC))]
2372
+ base_commit = stash_base_commit(entry.message)
2373
+ if base_commit:
2374
+ lines.append(STASH_BASE_COMMIT_PREFIX + base_commit)
2375
+ lines.append(STASH_COMMIT_PREFIX + entry.oid)
2376
+ return plain_text_tooltip(lines)
2377
+
2378
+ def _capture_submodule_tree(self) -> None:
2379
+ """Remember expansion and native scroll positions before rebuilding references."""
2380
+ if not self.submodule_item_by_key:
2381
+ return
2382
+ selected = self.navigation_tree.currentItem()
2383
+ self.submodule_selected_key = None
2384
+ for key, item in self.submodule_item_by_key.items():
2385
+ self.submodule_expanded_by_key[key] = item.isExpanded()
2386
+ if item is selected:
2387
+ self.submodule_selected_key = key
2388
+ self.submodule_scroll = (self.navigation_tree.horizontalScrollBar().value(), self.navigation_tree.verticalScrollBar().value())
2389
+
2390
+ def show_submodule_initializing(self, repository_dir: str) -> None:
2391
+ """Mark the target while leaving the active repository and graph untouched."""
2392
+ for item in self.submodule_item_by_key.values():
2393
+ directory = item.data(NAVIGATION_COLUMN, REPOSITORY_ROLE)
2394
+ if directory and canonical_path(directory) == canonical_path(repository_dir):
2395
+ item.setText(NAVIGATION_COLUMN, os.path.basename(directory) + " (Initializing…)")
2396
+ item.setToolTip(NAVIGATION_COLUMN, "Initializing submodule: " + directory)
2397
+ return
2398
+
2399
+ def _activate_repository(self, item: QTreeWidgetItem, column: int) -> None:
2400
+ """Request another declared repository so unavailable children can explain their refusal."""
2401
+ directory = item.data(NAVIGATION_COLUMN, REPOSITORY_ROLE)
2402
+ if self.mode != "repository" or item.isHidden() or item.isDisabled() or not directory or canonical_path(directory) == canonical_path(self.context.repository_dir):
2403
+ return
2404
+ self.repository_activated.emit(directory)
2405
+
2406
+ def append_history(self, commits: list[Commit]) -> None:
2407
+ """Append a page while preserving selection and the visible scroll anchor."""
2408
+ self.history_model.append_batch(commits)
2409
+ for commit in commits:
2410
+ row = self.history_model.graph_layout.row_by_oid[commit.oid]
2411
+ self.commit_list.graph_width = max(self.commit_list.graph_width, row.lane_count * GRAPH_LANE_WIDTH)
2412
+ self.commit_list.resize_subject()
2413
+ if self.pending_oid in self.history_model.row_by_oid:
2414
+ self.reveal_commit(self.pending_oid)
2415
+ self.pending_oid = None
2416
+ elif self.commit_list.currentIndex().isValid() is False and self.history_model.commits:
2417
+ self.commit_list.setCurrentRow(FIRST_ROW)
2418
+ if self.anchor_oid in self.history_model.row_by_oid:
2419
+ self.commit_list.scrollTo(self.history_model.index(self.history_model.row_by_oid[self.anchor_oid], MESSAGE_COLUMN), QAbstractItemView.ScrollHint.PositionAtTop)
2420
+ self.anchor_oid = None
2421
+ self.update_history_status()
2422
+
2423
+ def show_status_notice(self, worktree_key: WorktreeKey, kind: str, notice: str) -> None:
2424
+ """Keep a notice of one kind at the end of the history status line of one working tree, and show it now when that tree is displayed.
2425
+
2426
+ A plain status message would be replaced by the next history line ("Loading more commits…", then the count of
2427
+ loaded commits) as soon as it is written. Following the history line, the notice stays readable once loading
2428
+ ends; a message written outside the history line hides it only until the next history line. An empty notice
2429
+ removes the notice of that kind alone.
2430
+ """
2431
+ notice_by_kind = self.status_notice_by_kind_by_worktree.setdefault(worktree_key, {})
2432
+ if notice:
2433
+ notice_by_kind[kind] = notice
2434
+ else:
2435
+ notice_by_kind.pop(kind, None)
2436
+ if not notice_by_kind:
2437
+ del self.status_notice_by_kind_by_worktree[worktree_key]
2438
+ self.update_history_status()
2439
+
2440
+ def _show_history_status(self, text: str) -> None:
2441
+ """Write one history status line, followed by the notices kept for the displayed working tree, in their fixed order."""
2442
+ notice_by_kind = self.status_notice_by_kind_by_worktree.get(WorktreeKey.from_context(self.context), {})
2443
+ for kind in STATUS_NOTICE_KINDS:
2444
+ if kind in notice_by_kind:
2445
+ text += STATUS_PART_SEPARATOR + notice_by_kind[kind]
2446
+ self.statusBar().showMessage(text)
2447
+
2448
+ def update_history_status(self) -> None:
2449
+ """Distinguish loaded rows, a finished empty repository and incomplete history, and keep the window's notice at the end of the line."""
2450
+ self.update_parent_reference()
2451
+ snapshot = self.snapshot
2452
+ if snapshot is None:
2453
+ return
2454
+ if self.search_pending:
2455
+ return
2456
+ loaded = self.history_model.rowCount()
2457
+ suffix = "s" if loaded != 1 else ""
2458
+ if self.mode == "file_history" and self.file_history_result is not None:
2459
+ result = self.file_history_result
2460
+ text = f"{loaded} file history commit{suffix}"
2461
+ if self.filter_text:
2462
+ text += f" matching '{self.filter_text}'"
2463
+ if not result.complete:
2464
+ text += f" — Incomplete history — {result.error or 'Continue reading to resume'}"
2465
+ self.file_history_resume_action.setEnabled(not result.complete and not snapshot.error and not snapshot.shallow)
2466
+ self._show_history_status(text)
2467
+ self.history_state_label.setText("No matching file history commits. Change the filter or continue reading.")
2468
+ self.history_state_label.setVisible(loaded == 0)
2469
+ return
2470
+ if self.filter_text:
2471
+ suffix = "s" if loaded != 1 else ""
2472
+ self._show_history_status(f"{loaded} matching commit{suffix} — The filter hides commits that do not match")
2473
+ self.history_state_label.setText(f"No matching commits\n{self.filter_mode.no_match_text} '{self.filter_text}'. Change the search or clear the filter.")
2474
+ self.history_state_label.setVisible(loaded == 0)
2475
+ return
2476
+ self.history_state_label.setText("No commits in this repository\nThis repository has no commits yet. Create one in your terminal, then use Refresh.")
2477
+ self.history_state_label.setVisible(snapshot.complete and not snapshot.ordered_oids and not snapshot.error)
2478
+ if snapshot.error:
2479
+ text = f"Incomplete history — {loaded} commit{suffix} loaded — {snapshot.error}"
2480
+ elif not snapshot.complete or loaded < len(snapshot.ordered_oids):
2481
+ text = RepositoryWindowText.HISTORY_LOADING.value if loaded == 0 else f"Loading more commits… {loaded} commit{suffix} loaded"
2482
+ elif loaded == 0:
2483
+ text = RepositoryWindowText.REPOSITORY_EMPTY.value
2484
+ self.commit = None
2485
+ self.commit_panel.clear_selection()
2486
+ else:
2487
+ text = f"{loaded} commit{suffix} loaded"
2488
+ if snapshot.shallow:
2489
+ text += " — Shallow repository: parents beyond the boundary are unavailable"
2490
+ self._show_history_status(("Bare repository — " if self.context.is_bare else "") + text)
2491
+
2492
+ def show_parent_reference(self, oid: str | None, availability: str = "", cause: str = "") -> None:
2493
+ """Expose the committed parent source separately from history loading and selection."""
2494
+ self.parent_reference_availability = availability
2495
+ self.history_model.set_committed_parent(oid if availability == "Available" else None)
2496
+ self.parent_reference_label.setToolTip(cause)
2497
+ self.update_parent_reference()
2498
+
2499
+ def update_parent_reference(self) -> None:
2500
+ """Keep absent, unknown and filtered references explicit without a substitute arrow."""
2501
+ oid = self.history_model.committed_parent_oid
2502
+ text = ""
2503
+ if self.parent_reference_availability == "Unavailable":
2504
+ text = "The committed parent reference could not be read. Refresh the repository to retry."
2505
+ elif self.parent_reference_availability in ("Parent has no commit", "No committed gitlink"):
2506
+ text = "The parent repository has no committed submodule reference."
2507
+ elif oid is not None:
2508
+ text = f"Committed in parent: {oid}" if oid in self.history_model.row_by_oid else f"Parent commit reference: {oid}. This commit is not in the current view."
2509
+ self.parent_reference_label.setText(text)
2510
+ self.parent_reference_label.setAccessibleDescription(text)
2511
+ self.parent_reference_label.setVisible(bool(text))
2512
+
2513
+ def scroll_history_to_top(self) -> None:
2514
+ """Show the top of the graph, where an operation that adds commits or moves a branch puts its result; the selection stays."""
2515
+ self.anchor_oid = None
2516
+ self.commit_list.scrollToTop()
2517
+
2518
+ def reveal_commit(self, oid: str) -> None:
2519
+ """Select a known OID or remember it until its metadata page arrives.
2520
+
2521
+ The commit Go to commit hash asked for is scrolled to the middle of the graph, then forgotten; any other commit is
2522
+ only scrolled into view, so a row already on screen leaves the graph where it is.
2523
+ """
2524
+ row = self.history_model.row_by_oid.get(oid)
2525
+ if row is None:
2526
+ self.pending_oid = oid
2527
+ return
2528
+ self.pending_oid = None
2529
+ self.commit_list.setCurrentRow(row)
2530
+ scroll_hint = QAbstractItemView.ScrollHint.EnsureVisible
2531
+ if oid == self.centered_oid:
2532
+ scroll_hint = QAbstractItemView.ScrollHint.PositionAtCenter
2533
+ self.centered_oid = None
2534
+ self.commit_list.scrollTo(self.history_model.index(row, MESSAGE_COLUMN), scroll_hint)
2535
+
2536
+ def _navigate_reference(self, item: QTreeWidgetItem, column: int) -> None:
2537
+ """Emit only navigable commit targets, leaving groups and blob tags inert."""
2538
+ if self.is_closed:
2539
+ return
2540
+ oid = item.data(NAVIGATION_COLUMN, OID_ROLE)
2541
+ if self.mode == "file_history" and (self.search_pending or oid not in self.history_model.row_by_oid):
2542
+ return
2543
+ if oid:
2544
+ self.navigation_requested.emit(oid)
2545
+
2546
+ def showEvent(self, event: QShowEvent) -> None:
2547
+ """Centre the window on the GitPane window it was opened from at its first show; later shows keep the user's place.
2548
+
2549
+ The window it was opened from is let go afterwards: the Open repository window is destroyed once the repository
2550
+ opens, and a reference kept here would leave a Python object pointing at a deleted window.
2551
+ """
2552
+ super().showEvent(event)
2553
+ if self.centred:
2554
+ return
2555
+ self.centred = True
2556
+ centre_on_placement_anchor(self, self.placement_anchor)
2557
+ self.placement_anchor = None
2558
+
2559
+ def closeEvent(self, event: QCloseEvent) -> None:
2560
+ """Tell the shutdown controller to invalidate work for this window only."""
2561
+ if self.is_closed:
2562
+ event.accept()
2563
+ return
2564
+ self.is_closed = True
2565
+ self.search_timer.stop()
2566
+ self.date_timer.stop()
2567
+ if self.terminal_registry is not None:
2568
+ self.terminal_registry.changed.disconnect(self._show_terminals)
2569
+ self.terminal_registry = None
2570
+ self.closed.emit()
2571
+ super().closeEvent(event)
2572
+
2573
+ def _select_commit(self, index: QModelIndex, previous: QModelIndex | None = None) -> None:
2574
+ """Render and publish selection without performing repository work."""
2575
+ if not index.isValid():
2576
+ self.commit_panel.clear_selection()
2577
+ return
2578
+ self.commit = self.history_model.commits[index.row()]
2579
+ self.commit_panel.show_loading(self.commit.oid, self.commit.short_oid)
2580
+ self.commit_selected.emit(self.commit.oid)