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,745 @@
1
+ """Present two local change lists beside the reusable unified diff reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from PySide6.QtCore import QAbstractAnimation, QCoreApplication, QEasingCurve, QEvent, QSignalBlocker, Qt, QVariantAnimation, Signal
10
+ from PySide6.QtGui import QAction, QFontDatabase, QKeySequence, QShortcut, QTextCursor
11
+ from PySide6.QtWidgets import QAbstractItemView, QCheckBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton, QSizePolicy, QSplitter, QVBoxLayout, QWidget
12
+
13
+ from gitpane.config import (
14
+ EMOJI_GRID_ANIMATION_MS,
15
+ EMOJI_KEYBOARD_TITLE,
16
+ FIND_SHORTCUT,
17
+ ICON_SIDE,
18
+ LAYOUT_SPACING,
19
+ SPACING_MD,
20
+ STAGE_SHORTCUT,
21
+ STAGING_HEIGHT,
22
+ STAGING_MESSAGE_PROPORTION_KEY,
23
+ STAGING_MIN_HEIGHT,
24
+ STAGING_MIN_WIDTH,
25
+ STAGING_PROPORTION_BY_KEY,
26
+ STAGING_WIDTH,
27
+ SharedText,
28
+ control_height,
29
+ fit_window,
30
+ )
31
+ from gitpane.operation.repository_operation import OperationAction
32
+ from gitpane.ui.console.error_dialog import ErrorDialog
33
+ from gitpane.ui.emoji.emoji_font import emoji_capable_font
34
+ from gitpane.ui.emoji.emoji_grid import EmojiGrid
35
+ from gitpane.ui.look.action_icon import action_icon
36
+ from gitpane.ui.reader.diff_panel import DiffView, status_icon
37
+ from gitpane.ui.reader.reader_toolbar import ReaderToolbar
38
+ from gitpane.ui.staging.staging_action import FileResetMenu, StagingActionMenu
39
+ from gitpane.ui.widget.commit_date_text import format_commit_date
40
+ from gitpane.ui.widget.result_label import LineLimitedResultLabel, ResultTone
41
+ from gitpane.ui.widget.unavailable_reason import unavailable_reason_text
42
+ from gitpane.ui.window_control.dialog_modality import open_window_modal
43
+ from gitpane.worktree.worktree_snapshot import SUBMODULE_DIRTY_DESCRIPTION, SUBMODULE_NO_COMMIT_CHANGE, StagingSide
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Callable
47
+
48
+ from PySide6.QtCore import QRect
49
+ from PySide6.QtGui import QCloseEvent
50
+
51
+ from gitpane.history.repository_value import RepositoryContext
52
+ from gitpane.operation.diagnostic import PreparedDiagnostic
53
+ from gitpane.submodule.submodule_discovery import SubmoduleDifference, SubmoduleSummary
54
+ from gitpane.worktree.worktree_snapshot import LocalEntry, WorktreeSnapshot
55
+
56
+
57
+ # Order of the panes of the message splitter: the diff, the emoji keyboard (hidden until asked for), then the commit message.
58
+ DIFF_PANE_INDEX, EMOJI_PANE_INDEX, MESSAGE_PANE_INDEX = 0, 1, 2
59
+ EMOJI_KEYBOARD_TOOLTIP = "Show or hide the emoji keyboard"
60
+ # Once open, the keyboard keeps at least one row of symbols when the user drags a handle.
61
+ EMOJI_GRID_MINIMUM_ROW_COUNT = 1
62
+ # Lines the status bar shows before it cuts a long result with an ellipsis; Show details holds the whole text.
63
+ STATUS_VISIBLE_LINE_COUNT = 3
64
+
65
+
66
+ class StagingText(Enum):
67
+ """Name the local preparation messages used across view updates."""
68
+
69
+ WAITING_TO_CLOSE = "Waiting for the current write to finish. Your message is preserved."
70
+
71
+
72
+ def difference_lines(differences: tuple[SubmoduleDifference, ...]) -> list[str]:
73
+ """Render literal Git status and rename paths without allowing filenames to create extra rows."""
74
+ lines = []
75
+ for difference in differences:
76
+ path = difference.path.decode("utf-8", "replace").replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t")
77
+ if difference.old_path is not None:
78
+ path = difference.old_path.decode("utf-8", "replace").replace("\r", "\\r").replace("\n", "\\n").replace("\t", "\\t") + " → " + path
79
+ lines.append(difference.status + " " + path)
80
+ return lines
81
+
82
+
83
+ def commit_summary_lines(summary: SubmoduleSummary) -> tuple[list[str], str]:
84
+ """Describe changes between the selected immutable commits separately from local child files."""
85
+ lines = []
86
+ now = datetime.now().astimezone()
87
+ source_availability, target_availability = ((summary.reference.committed_availability, summary.reference.indexed_availability) if summary.side == StagingSide.STAGED
88
+ else (summary.reference.indexed_availability, summary.reference.checkout_availability))
89
+ for label, commit, availability in (("From", summary.source, source_availability), ("To", summary.target, target_availability)):
90
+ lines.append(f"{label}: {commit.oid or 'Unavailable'}")
91
+ if commit.oid is None:
92
+ lines.append(availability)
93
+ lines.append(format_commit_date(commit.timestamp, now) if commit.timestamp is not None else "Unavailable")
94
+ lines.append(commit.subject if commit.subject is not None else f"Commit details are unavailable for {commit.oid or 'Unavailable'}.")
95
+ lines.append("")
96
+ lines.extend(("Type: " + summary.relation, ""))
97
+ added, removed = summary.added_count, summary.removed_count
98
+ suffix = ""
99
+ if summary.relation == "Fast Forward" and added is not None and removed == 0:
100
+ lines.append(f"Commits: {added} added")
101
+ suffix = f" (+{added})"
102
+ elif summary.relation == "Rewind" and removed is not None and added == 0:
103
+ lines.append(f"Commits: {removed} removed")
104
+ suffix = f" (-{removed})"
105
+ else:
106
+ lines.append(f"Commits: {added if added is not None else 'Unavailable'} added, {removed if removed is not None else 'Unavailable'} removed")
107
+ for label, commits, count in (("Added", summary.added_commits, added), ("Removed", summary.removed_commits, removed)):
108
+ for commit in commits:
109
+ date = format_commit_date(commit.timestamp, now) if commit.timestamp is not None else "Unavailable"
110
+ lines.append(f"{label}: {commit.oid} {date} {commit.subject}")
111
+ if count is not None and count > len(commits):
112
+ lines.append(f"{label}: showing {len(commits)} of {count} commits.")
113
+ lines.extend(("", "Differences:"))
114
+ if summary.differences is None:
115
+ lines.append("Differences could not be read. Review the details and refresh the repository.")
116
+ elif not summary.differences:
117
+ lines.append("No differences between these commits.")
118
+ else:
119
+ lines.extend(difference_lines(summary.differences))
120
+ if summary.differences_truncated:
121
+ lines.append("More differences are available. The displayed list is limited.")
122
+ if summary.relation == "Unchanged":
123
+ lines.extend(("", "The submodule commit has not changed."))
124
+ return lines, suffix
125
+
126
+
127
+ class StagingWindow(QWidget):
128
+ """Emit captured file intentions while leaving index changes to injected services."""
129
+
130
+ closed = Signal()
131
+ refresh_requested = Signal()
132
+ stage_requested = Signal(object)
133
+ unstage_requested = Signal(object)
134
+ entry_selected = Signal(object)
135
+ selection_cleared = Signal()
136
+ commit_requested = Signal()
137
+ commit_push_requested = Signal()
138
+ amend_changed = Signal(bool)
139
+ preference_requested = Signal(object)
140
+
141
+ def __init__(self, context: RepositoryContext) -> None:
142
+ """Build the approved list/diff/message layout with only functional commands."""
143
+ super().__init__()
144
+ self.context = context
145
+ self.setWindowTitle("Prepare changes — " + context.repository_dir)
146
+ self.resize(STAGING_WIDTH, STAGING_HEIGHT)
147
+ self.snapshot: WorktreeSnapshot | None = None
148
+ self.diagnostic: PreparedDiagnostic | None = None
149
+ self.busy = False
150
+ self.close_requested = False
151
+ self.amend_ready = False
152
+ # A merge waits for its commit: it may be concluded with nothing staged, by keeping HEAD's side of every file.
153
+ self.merge_in_progress = False
154
+ self.reason_by_action: dict[OperationAction, str] = {}
155
+ # Set by the staging controller, which reads the preferences and the disk; the window only shows the reason it returns.
156
+ self.text_editor_reason: Callable[[LocalEntry], str] | None = None
157
+ layout = QVBoxLayout(self)
158
+ self.repository_identity = QLineEdit(context.repository_dir)
159
+ self.repository_identity.setObjectName("repository_identity")
160
+ self.repository_identity.setReadOnly(True)
161
+ self.repository_identity.setAccessibleName("Repository path")
162
+ layout.addWidget(self.repository_identity)
163
+ toolbar = QHBoxLayout()
164
+ self.filter_edit = QLineEdit()
165
+ self.filter_edit.setPlaceholderText("Filter files")
166
+ self.filter_edit.setAccessibleName("Filter files")
167
+ self.refresh_button = QPushButton("Refresh")
168
+ self.refresh_button.clicked.connect(self.refresh_requested)
169
+ toolbar.addWidget(self.filter_edit, 1)
170
+ toolbar.addWidget(self.refresh_button)
171
+ layout.addLayout(toolbar)
172
+ self.splitter = QSplitter(Qt.Orientation.Horizontal)
173
+ layout.addWidget(self.splitter, 1)
174
+ lists = QWidget()
175
+ list_layout = QVBoxLayout(lists)
176
+ list_layout.setContentsMargins(0, 0, 0, 0)
177
+ self.list_splitter = QSplitter(Qt.Orientation.Vertical)
178
+ list_layout.addWidget(self.list_splitter, 1)
179
+ unstaged = QWidget()
180
+ unstaged_layout = QVBoxLayout(unstaged)
181
+ unstaged_layout.setContentsMargins(0, 0, 0, 0)
182
+ unstaged_layout.addWidget(QLabel("Unstaged"))
183
+ self.unstaged_list = QListWidget()
184
+ self.unstaged_list.setAccessibleName("Unstaged files")
185
+ unstaged_layout.addWidget(self.unstaged_list, 1)
186
+ self.unstaged_empty_label = QLabel()
187
+ self.unstaged_empty_label.setWordWrap(True)
188
+ unstaged_layout.addWidget(self.unstaged_empty_label)
189
+ controls = QHBoxLayout()
190
+ self.stage_button = QPushButton("Stage selected")
191
+ self.stage_shortcut = QShortcut(QKeySequence(STAGE_SHORTCUT), self.unstaged_list)
192
+ self.stage_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
193
+ self.stage_shortcut.activated.connect(self.stage_button.click)
194
+ self.stage_button.clicked.connect(lambda: self.stage_requested.emit(self.selected_entries(self.unstaged_list)))
195
+ self.unstage_button = QPushButton("Unstage selected")
196
+ self.unstage_button.clicked.connect(lambda: self.unstage_requested.emit(self.selected_entries(self.staged_list)))
197
+ controls.addWidget(self.stage_button)
198
+ controls.addWidget(self.unstage_button)
199
+ unstaged_layout.addLayout(controls)
200
+ self.list_splitter.addWidget(unstaged)
201
+ staged = QWidget()
202
+ staged_layout = QVBoxLayout(staged)
203
+ staged_layout.setContentsMargins(0, 0, 0, 0)
204
+ staged_layout.addWidget(QLabel("Staged"))
205
+ self.staged_list = QListWidget()
206
+ self.staged_list.setAccessibleName("Staged files")
207
+ staged_layout.addWidget(self.staged_list, 1)
208
+ self.staged_empty_label = QLabel()
209
+ self.staged_empty_label.setWordWrap(True)
210
+ staged_layout.addWidget(self.staged_empty_label)
211
+ self.list_splitter.addWidget(staged)
212
+ self.splitter.addWidget(lists)
213
+ reader = QWidget()
214
+ reader_layout = QVBoxLayout(reader)
215
+ reader_layout.setContentsMargins(0, 0, 0, 0)
216
+ self.reader_toolbar = ReaderToolbar("Find in diff")
217
+ self.diff_view = DiffView()
218
+ self.line_menu = StagingActionMenu(self.diff_view, lambda: self.snapshot is not None and self.snapshot.fresh and not self.busy)
219
+ self.reader_toolbar.find_button.clicked.connect(self.diff_view.open_search)
220
+ self.message_splitter = QSplitter(Qt.Orientation.Vertical)
221
+ reader_layout.addWidget(self.message_splitter)
222
+ preview = QWidget()
223
+ preview_layout = QVBoxLayout(preview)
224
+ preview_layout.setContentsMargins(0, 0, 0, 0)
225
+ preview_layout.addWidget(self.reader_toolbar)
226
+ preview_layout.addWidget(self.diff_view, 1)
227
+ self.summary_view = QPlainTextEdit()
228
+ self.summary_view.setReadOnly(True)
229
+ self.summary_view.setAccessibleName("Submodule changes")
230
+ self.summary_view.hide()
231
+ preview_layout.addWidget(self.summary_view, 1)
232
+ self.message_splitter.addWidget(preview)
233
+ self.diff_pane = preview
234
+ self.diff_pane_vertical_policy = preview.sizePolicy().verticalPolicy()
235
+ self.emoji_pane = QWidget()
236
+ emoji_pane_layout = QVBoxLayout(self.emoji_pane)
237
+ emoji_pane_layout.setContentsMargins(0, 0, 0, 0)
238
+ # An ignored vertical policy lets the pane go down to a height of 0, so the keyboard can open from nothing.
239
+ self.emoji_pane.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Ignored)
240
+ self.emoji_pane.hide()
241
+ self.message_splitter.addWidget(self.emoji_pane)
242
+ self.emoji_grid: EmojiGrid | None = None
243
+ # Owned by the window so it is deleted with it; each frame moves height between the diff and the keyboard only.
244
+ self.emoji_animation = QVariantAnimation(self)
245
+ self.emoji_animation.setDuration(EMOJI_GRID_ANIMATION_MS)
246
+ self.emoji_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
247
+ self.emoji_animation.valueChanged.connect(self._step_emoji_grid)
248
+ self.emoji_animation.finished.connect(self._emoji_animation_finished)
249
+ message = QWidget()
250
+ message_layout = QVBoxLayout(message)
251
+ message_layout.setContentsMargins(0, 0, 0, 0)
252
+ message_title_layout = QHBoxLayout()
253
+ message_title_layout.addWidget(QLabel("Commit message"))
254
+ message_title_layout.addSpacing(SPACING_MD)
255
+ self.emoji_button = QPushButton(EMOJI_KEYBOARD_TITLE)
256
+ self.emoji_button.setCheckable(True)
257
+ self.emoji_button.setToolTip(EMOJI_KEYBOARD_TOOLTIP)
258
+ self.emoji_button.setAccessibleName(EMOJI_KEYBOARD_TITLE)
259
+ self.emoji_button.toggled.connect(self.set_emoji_keyboard_open)
260
+ message_title_layout.addWidget(self.emoji_button)
261
+ message_title_layout.addStretch(1)
262
+ message_layout.addLayout(message_title_layout)
263
+ self.message_edit = QPlainTextEdit()
264
+ # Only the families are set, so the message keeps following the font size of the window.
265
+ self.message_edit.setFont(emoji_capable_font())
266
+ self.message_edit.setTabChangesFocus(True)
267
+ self.message_edit.setAccessibleName("Commit message")
268
+ self.message_find_edit = QLineEdit()
269
+ self.message_find_edit.setPlaceholderText("Find in message")
270
+ self.message_find_edit.setAccessibleName("Find in message")
271
+ self.message_find_edit.hide()
272
+ self.message_find_shortcut = QShortcut(QKeySequence(FIND_SHORTCUT), self.message_edit)
273
+ self.message_find_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
274
+ self.message_find_shortcut.activated.connect(self.find_message)
275
+ self.message_find_edit.textChanged.connect(self._find_message_text)
276
+ message_layout.addWidget(self.message_find_edit)
277
+ message_layout.addWidget(self.message_edit, 1)
278
+ self.amend_checkbox = QCheckBox("Amend commit")
279
+ self.amend_checkbox.toggled.connect(self.amend_changed)
280
+ message_layout.addWidget(self.amend_checkbox)
281
+ self.commit_button = QPushButton("Commit")
282
+ self.commit_button.clicked.connect(self.commit_requested)
283
+ self.commit_button.setAccessibleDescription("Commit the prepared index in " + context.repository_dir)
284
+ self.stage_button.setAccessibleDescription("Stage selected references and files in " + context.repository_dir)
285
+ self.unstage_button.setAccessibleDescription("Unstage selected entries in " + context.repository_dir)
286
+ self.commit_push_button = QPushButton("Commit && push")
287
+ self.commit_push_button.setAccessibleName("Commit & push")
288
+ self.commit_push_button.clicked.connect(self.commit_push_requested)
289
+ self.message_edit.textChanged.connect(self._availability)
290
+ commit_actions = QHBoxLayout()
291
+ commit_actions.addWidget(self.commit_button)
292
+ commit_actions.addWidget(self.commit_push_button)
293
+ self.commit_reason_label = QLabel()
294
+ self.commit_reason_label.setWordWrap(True)
295
+ self.commit_reason_label.setTextFormat(Qt.TextFormat.PlainText)
296
+ self.commit_reason_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
297
+ self.commit_reason_label.hide()
298
+ commit_actions.addWidget(self.commit_reason_label, 1)
299
+ message_layout.addLayout(commit_actions)
300
+ self.message_splitter.addWidget(message)
301
+ self.splitter.addWidget(reader)
302
+ self.status_label = LineLimitedResultLabel("Reading changes…", STATUS_VISIBLE_LINE_COUNT)
303
+ self.status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
304
+ status_layout = QHBoxLayout()
305
+ status_layout.addWidget(self.status_label, 1)
306
+ self.detail_button = QPushButton(SharedText.RESULT_DETAILS.value)
307
+ self.detail_button.clicked.connect(self.show_diagnostic)
308
+ self.detail_button.hide()
309
+ status_layout.addWidget(self.detail_button)
310
+ layout.addLayout(status_layout)
311
+ for file_list in (self.unstaged_list, self.staged_list):
312
+ file_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
313
+ file_list.itemSelectionChanged.connect(lambda file_list=file_list: self._selection_changed(file_list))
314
+ self.filter_edit.textChanged.connect(self._filter)
315
+ self.file_menu = FileResetMenu(self)
316
+ close_action = QAction("Close", self)
317
+ close_action.setShortcut("Ctrl+W")
318
+ close_action.triggered.connect(self.close)
319
+ self.addAction(close_action)
320
+ self.splitter_by_key = dict(zip(STAGING_PROPORTION_BY_KEY, (self.splitter, self.list_splitter, self.message_splitter), strict=True))
321
+ for key, splitter in self.splitter_by_key.items():
322
+ splitter.setChildrenCollapsible(False)
323
+ splitter.setHandleWidth(LAYOUT_SPACING)
324
+ splitter.splitterMoved.connect(lambda position, index, key=key: self.preference_requested.emit({key: self.persisted_proportion(key)}))
325
+ self.refresh_metrics()
326
+ self.apply_preference(STAGING_PROPORTION_BY_KEY)
327
+
328
+ def apply_preference(self, value_by_key: dict[str, object]) -> None:
329
+ """Apply synchronized pane weights, with readable defaults for malformed storage.
330
+
331
+ While the keyboard slides, the message splitter is left to the animation, which already decides its final sizes.
332
+ """
333
+ for key, splitter in self.splitter_by_key.items():
334
+ if key not in value_by_key:
335
+ continue
336
+ if splitter is self.message_splitter and self.emoji_animation.state() == QAbstractAnimation.State.Running:
337
+ continue
338
+ sizes = value_by_key[key]
339
+ default = STAGING_PROPORTION_BY_KEY[key]
340
+ if not isinstance(sizes, (list, tuple)) or len(sizes) != len(default) or any(type(size) is not int or size <= 0 for size in sizes):
341
+ sizes = default
342
+ # The window that saved a proportion gets it back; rescaling it again would shift the panes by a few pixels.
343
+ if list(sizes) == self.persisted_proportion(key):
344
+ continue
345
+ extent = splitter.width() if splitter.orientation() == Qt.Orientation.Horizontal else splitter.height()
346
+ weights: list[int] = []
347
+ for size in sizes:
348
+ weights.append(max(1, round(extent * size / sum(sizes))))
349
+ if splitter is self.message_splitter:
350
+ weights = self._message_pane_weights(weights)
351
+ with QSignalBlocker(splitter):
352
+ splitter.setSizes(weights)
353
+ splitter.setChildrenCollapsible(False)
354
+
355
+ def persisted_proportion(self, key: str) -> list[int]:
356
+ """Return a splitter's proportion as it is saved.
357
+
358
+ The message splitter has three panes, but its preference keeps two sizes: the upper part (diff, emoji keyboard and
359
+ the keyboard's handle together) and the message, so opening or closing the keyboard never changes the saved proportion.
360
+ """
361
+ sizes = self.splitter_by_key[key].sizes()
362
+ if key != STAGING_MESSAGE_PROPORTION_KEY:
363
+ return sizes
364
+ return [sizes[DIFF_PANE_INDEX] + sizes[EMOJI_PANE_INDEX] + self._emoji_handle_height(), sizes[MESSAGE_PANE_INDEX]]
365
+
366
+ def _emoji_handle_height(self) -> int:
367
+ """Return the height taken by the handle above the keyboard, which the splitter only shows while the keyboard is shown."""
368
+ return 0 if self.emoji_pane.isHidden() else self.message_splitter.handleWidth()
369
+
370
+ def _message_pane_weights(self, weights: list[int]) -> list[int]:
371
+ """Turn a saved two-size proportion into the three message splitter panes, keeping the open keyboard's height.
372
+
373
+ The open keyboard may take the whole upper part, since the diff can shrink to nothing while it is open.
374
+ """
375
+ upper_weight, message_weight = weights
376
+ diff_weight = max(1, upper_weight - self._emoji_handle_height())
377
+ emoji_height = 0 if self.emoji_pane.isHidden() else self.emoji_pane.height()
378
+ emoji_height = min(emoji_height, diff_weight)
379
+ return [diff_weight - emoji_height, emoji_height, message_weight]
380
+
381
+ def set_emoji_keyboard_open(self, visible: bool) -> None:
382
+ """Open or close the emoji keyboard pane of this window, sliding it between the diff and the message.
383
+
384
+ The keyboard takes its height from the diff only and gives it back when closed; the message keeps its height.
385
+ It opens tall enough to show every symbol, and may hide the whole diff for that: while writing with symbols the
386
+ user does not need the diff. A click during the slide starts again from the current height, and the button's
387
+ state decides where it ends.
388
+ """
389
+ self.emoji_animation.stop()
390
+ sizes = self.message_splitter.sizes()
391
+ upper_height = sizes[DIFF_PANE_INDEX] + sizes[EMOJI_PANE_INDEX]
392
+ current_height = 0 if self.emoji_pane.isHidden() else sizes[EMOJI_PANE_INDEX]
393
+ # The one-row minimum set after opening would stop the keyboard from sliding down to nothing.
394
+ self.emoji_pane.setMinimumHeight(0)
395
+ if not visible:
396
+ self._animate_emoji_grid(current_height, 0)
397
+ return
398
+ self._ensure_emoji_grid()
399
+ self._set_diff_shrinkable(True)
400
+ if self.emoji_pane.isHidden():
401
+ # Showing the pane also shows its handle; its width is taken from the diff so the message does not shrink.
402
+ upper_height -= self.message_splitter.handleWidth()
403
+ with QSignalBlocker(self.message_splitter):
404
+ self._set_emoji_pane_shown(True)
405
+ self.message_splitter.setSizes([upper_height, 0, sizes[MESSAGE_PANE_INDEX]])
406
+ self._animate_emoji_grid(current_height, self._emoji_grid_target_height(upper_height))
407
+
408
+ def _animate_emoji_grid(self, start_height: int, end_height: int) -> None:
409
+ """Start the slide of the keyboard between two heights."""
410
+ # Changing the values of an idle animation reports a value at its old position; that frame is not a real step.
411
+ with QSignalBlocker(self.emoji_animation):
412
+ self.emoji_animation.setStartValue(start_height)
413
+ self.emoji_animation.setEndValue(end_height)
414
+ self.emoji_animation.start()
415
+
416
+ def _step_emoji_grid(self, grid_height: int) -> None:
417
+ """Apply one frame of the slide: the diff absorbs the change, the message keeps its height, nothing is saved."""
418
+ sizes = self.message_splitter.sizes()
419
+ upper_height = sizes[DIFF_PANE_INDEX] + sizes[EMOJI_PANE_INDEX]
420
+ with QSignalBlocker(self.message_splitter):
421
+ self.message_splitter.setSizes([upper_height - grid_height, grid_height, sizes[MESSAGE_PANE_INDEX]])
422
+
423
+ def _emoji_animation_finished(self) -> None:
424
+ """Settle the keyboard once it has slid: keep at least one row when open, hide it and give its space to the diff when closed.
425
+
426
+ Hiding a pane alone would share its space between the diff and the message, so the sizes are set explicitly.
427
+ """
428
+ sizes = self.message_splitter.sizes()
429
+ if self.emoji_button.isChecked():
430
+ self.emoji_pane.setMinimumHeight(min(self.emoji_grid.row_height(EMOJI_GRID_MINIMUM_ROW_COUNT), sizes[EMOJI_PANE_INDEX]))
431
+ return
432
+ # Hiding the pane also hides its handle, whose width goes back to the diff.
433
+ upper_height = sizes[DIFF_PANE_INDEX] + sizes[EMOJI_PANE_INDEX] + self.message_splitter.handleWidth()
434
+ with QSignalBlocker(self.message_splitter):
435
+ self._set_emoji_pane_shown(False)
436
+ self._set_diff_shrinkable(False)
437
+ self.message_splitter.setSizes([upper_height, 0, sizes[MESSAGE_PANE_INDEX]])
438
+
439
+ def _set_emoji_pane_shown(self, shown: bool) -> None:
440
+ """Show or hide the keyboard pane together with its handle, so the sizes set right after count the right handles."""
441
+ self.emoji_pane.setVisible(shown)
442
+ # Workaround: the splitter shows or hides the pane's handle only when it handles the layout request that the
443
+ # visibility change posts. Sizes set before that count the old handles and are spread again later, which moved
444
+ # the diff and the message by the handle's width; handling the request now keeps the sizes exact.
445
+ QCoreApplication.sendPostedEvents(self.message_splitter, QEvent.Type.LayoutRequest)
446
+
447
+ def _set_diff_shrinkable(self, shrinkable: bool) -> None:
448
+ """Let the diff shrink to nothing while the keyboard is open, and give it back its normal minimum height once closed.
449
+
450
+ An ignored vertical size policy is what lets a splitter pane go below the minimum height of its content.
451
+ """
452
+ policy = self.diff_pane.sizePolicy()
453
+ policy.setVerticalPolicy(QSizePolicy.Policy.Ignored if shrinkable else self.diff_pane_vertical_policy)
454
+ self.diff_pane.setSizePolicy(policy)
455
+
456
+ def _ensure_emoji_grid(self) -> EmojiGrid:
457
+ """Build the grid on first use only, so opening the staging window never pays for its buttons."""
458
+ if self.emoji_grid is not None:
459
+ return self.emoji_grid
460
+ self.emoji_grid = EmojiGrid()
461
+ # The scroll area itself takes no Tab stop: Tab goes straight to the symbols, and the scroll bar stays a mouse control.
462
+ self.emoji_grid.setFocusPolicy(Qt.FocusPolicy.NoFocus)
463
+ self.emoji_grid.setEnabled(not self.busy)
464
+ self.emoji_pane.layout().addWidget(self.emoji_grid)
465
+ self.emoji_grid.chosen.connect(lambda text, button: self.insert_emoji(text))
466
+ # The buttons are created after every other control, so without this Tab would reach them last. They are slotted in
467
+ # right after the submodule summary, the last control above the keyboard in the layout (Tab skips it while it is
468
+ # hidden), and right before the keyboard button, which already follows it: every other control keeps its place.
469
+ previous_widget: QWidget = self.summary_view
470
+ for widget in (*self.emoji_grid.buttons, self.emoji_button):
471
+ QWidget.setTabOrder(previous_widget, widget)
472
+ previous_widget = widget
473
+ return self.emoji_grid
474
+
475
+ def _emoji_grid_target_height(self, upper_height: int) -> int:
476
+ """Return the opening height of the keyboard: what its real layout needs to show every symbol at the current width, at most the whole upper part."""
477
+ wanted_height = self.emoji_grid.content_height_for_width(self.message_splitter.contentsRect().width())
478
+ return max(0, min(wanted_height, upper_height))
479
+
480
+ def insert_emoji(self, text: str) -> None:
481
+ """Insert a symbol where the user would have typed it.
482
+
483
+ A selection is replaced, one Undo removes exactly the symbol, and focus returns to the message with the cursor
484
+ just after it. The cursor interface ignores read-only mode, so a read-only message is left untouched here.
485
+ """
486
+ if self.message_edit.isReadOnly():
487
+ return
488
+ cursor = self.message_edit.textCursor()
489
+ cursor.beginEditBlock()
490
+ cursor.insertText(text)
491
+ cursor.endEditBlock()
492
+ self.message_edit.setTextCursor(cursor)
493
+ self.message_edit.setFocus(Qt.FocusReason.OtherFocusReason)
494
+
495
+ def load_message(self, text: str) -> None:
496
+ """Replace the displayed draft and leave the cursor at its end, where a symbol chosen before any click goes."""
497
+ self.message_edit.setPlainText(text)
498
+ self.message_edit.moveCursor(QTextCursor.MoveOperation.End)
499
+
500
+ def refresh_metrics(self, available: QRect | None = None) -> None:
501
+ """Fit the new window using native system metrics and existing screen rules."""
502
+ summary_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
503
+ summary_font.setPointSizeF(self.font().pointSizeF())
504
+ self.summary_view.setFont(summary_font)
505
+ fit_window(self, STAGING_MIN_WIDTH, STAGING_MIN_HEIGHT, available)
506
+ for button, symbol in ((self.refresh_button, "refresh"), (self.stage_button, "stage"), (self.unstage_button, "unstage")):
507
+ button.setIcon(action_icon(symbol, self.devicePixelRatioF()))
508
+ for control in (self.repository_identity, self.filter_edit, self.refresh_button, self.stage_button, self.unstage_button, self.commit_button, self.commit_push_button, self.amend_checkbox, self.message_find_edit,
509
+ self.emoji_button):
510
+ control.setMinimumHeight(control_height(control.fontMetrics().height()))
511
+ for reader in (self.unstaged_list, self.staged_list, self.diff_view, self.message_edit):
512
+ reader.setMinimumHeight(control_height(reader.fontMetrics().height()) + SPACING_MD)
513
+ # A few lines keep a long cause understandable while the main actions stay visible; the whole cause stays
514
+ # available in the copyable dialog.
515
+ status_metrics = self.status_label.fontMetrics()
516
+ self.status_label.setMaximumHeight(control_height(status_metrics.height()) + status_metrics.lineSpacing() * (STATUS_VISIBLE_LINE_COUNT - 1))
517
+ self.detail_button.setMinimumHeight(control_height(self.detail_button.fontMetrics().height()))
518
+ self.diff_view.refresh_metrics()
519
+
520
+ def show_diagnostic(self) -> None:
521
+ """Open the copyable details window of the already-filtered diagnostic, blocking Prepare changes until it is closed."""
522
+ if self.diagnostic is None:
523
+ return
524
+ dialog = ErrorDialog(self.diagnostic, self)
525
+ dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
526
+ open_window_modal(dialog)
527
+
528
+ def show_comparison(self, patch: bytes, entry: LocalEntry, *, complete: bool = True) -> None:
529
+ """Label local comparison sides without modifying the raw bytes used for line actions."""
530
+ self.clear_summary()
531
+ old_side, new_side = ("Index", "Working tree") if entry.side == StagingSide.UNSTAGED else ("HEAD", "Index")
532
+ old_path = entry.old_path if entry.old_path is not None else entry.path
533
+ new_path = entry.new_path if entry.new_path is not None else entry.path
534
+ labels = []
535
+ for side, path in ((old_side, old_path), (new_side, new_path)):
536
+ label = path.decode("utf-8", "replace").replace("\r", "\\r").replace("\n", "\\n")
537
+ labels.append(f"{side}: {label}")
538
+ self.diff_view.show_patch(patch, entry.label, comparison_labels=tuple(labels))
539
+ if complete:
540
+ self.reader_toolbar.status_label.setText("No content changes" if not patch else "")
541
+
542
+ def clear_summary(self) -> None:
543
+ """Remove the old summary before displaying another selection or patch."""
544
+ self.summary_view.clear()
545
+ self.summary_view.hide()
546
+ self.diff_view.show()
547
+ self.reader_toolbar.find_button.setEnabled(True)
548
+
549
+ def show_summary_status(self, text: str) -> None:
550
+ """Keep loading and failed reads literal and separate from file line actions."""
551
+ self.diff_view.clear()
552
+ self.diff_view.hide()
553
+ self.line_menu.bind(None)
554
+ self.reader_toolbar.find_button.setEnabled(False)
555
+ self.reader_toolbar.status_label.clear()
556
+ self.summary_view.setPlainText(text)
557
+ self.summary_view.show()
558
+
559
+ def show_summary(self, summary: SubmoduleSummary, entry: LocalEntry) -> None:
560
+ """Show staged commit differences or the actual files that keep an unchanged child dirty."""
561
+ lines = [f"Submodule {entry.path.decode('utf-8', 'replace')} Change", ""]
562
+ suffix = ""
563
+ internal_only = summary.side == StagingSide.UNSTAGED and summary.source.oid is not None and summary.source.oid == summary.target.oid and summary.reference.submodule.dirty is True
564
+ if internal_only:
565
+ commit = summary.target
566
+ lines.extend((f"Commit: {commit.oid} (dirty)",
567
+ format_commit_date(commit.timestamp, datetime.now().astimezone()) if commit.timestamp is not None else "Unavailable",
568
+ commit.subject if commit.subject is not None else f"Commit details are unavailable for {commit.oid}.", "", "Type: Dirty"))
569
+ else:
570
+ comparison_lines, suffix = commit_summary_lines(summary)
571
+ lines.extend(comparison_lines)
572
+ if summary.side == StagingSide.UNSTAGED:
573
+ if summary.reference.submodule.dirty:
574
+ lines.extend(("", "Status:"))
575
+ if summary.reference.internal_changes is not None:
576
+ lines.extend(difference_lines(summary.reference.internal_changes))
577
+ else:
578
+ lines.append("Internal file status is unavailable. Refresh the repository to retry.")
579
+ lines.extend(("", SUBMODULE_DIRTY_DESCRIPTION))
580
+ elif summary.reference.submodule.dirty is None:
581
+ lines.extend(("", "Internal changes could not be checked. Refresh the repository to retry."))
582
+ if summary.cause:
583
+ lines.extend(("", summary.cause, "Refresh the repository to retry."))
584
+ self.show_summary_status("\n".join(lines))
585
+ file_list = self.staged_list if entry.side == StagingSide.STAGED else self.unstaged_list
586
+ for row in range(file_list.count()):
587
+ item = file_list.item(row)
588
+ if item.data(Qt.ItemDataRole.UserRole) == entry:
589
+ item.setText(entry.label + suffix)
590
+ break
591
+
592
+ def selected_entries(self, file_list: QListWidget) -> tuple[LocalEntry, ...]:
593
+ """Return exact selected identities in visible row order for stable batch actions."""
594
+ entries: list[LocalEntry] = []
595
+ for row in range(file_list.count()):
596
+ item = file_list.item(row)
597
+ if item.isSelected() and not item.isHidden():
598
+ entries.append(item.data(Qt.ItemDataRole.UserRole))
599
+ return tuple(entries)
600
+
601
+ def _selection_changed(self, file_list: QListWidget) -> None:
602
+ """Make one list the active comparison without clearing the other list's data."""
603
+ entries = self.selected_entries(file_list)
604
+ if entries:
605
+ other = self.staged_list if file_list is self.unstaged_list else self.unstaged_list
606
+ with QSignalBlocker(other):
607
+ other.clearSelection()
608
+ self.entry_selected.emit(next(iter(entries)))
609
+ elif not self.selected_entries(self.unstaged_list) and not self.selected_entries(self.staged_list):
610
+ self._clear_preview()
611
+ self._availability()
612
+
613
+ def _clear_preview(self) -> None:
614
+ """Drop the displayed file and notify its reader that pending replies are obsolete."""
615
+ self.clear_summary()
616
+ self.diff_view.clear()
617
+ self.line_menu.bind(None)
618
+ self.reader_toolbar.status_label.setText("Select a file to view its changes.")
619
+ self.selection_cleared.emit()
620
+
621
+ def _availability(self) -> None:
622
+ """Explain protected entries while keeping valid whole-file actions available."""
623
+ enabled = self.snapshot is not None and self.snapshot.fresh and not self.busy
624
+ conflict = self.snapshot is not None and any(entry.conflict_stages for entry in self.snapshot.entries)
625
+ has_staging = self.snapshot is not None and any(entry.side == StagingSide.STAGED for entry in self.snapshot.entries)
626
+ amend = self.amend_checkbox.isChecked()
627
+ amend_reason = self.reason_by_action.get(OperationAction.AMEND, "")
628
+ self.amend_checkbox.setEnabled(enabled and not conflict and not amend_reason and self.snapshot.head_oid is not None)
629
+ self.amend_checkbox.setToolTip(amend_reason or ("There is no HEAD commit to amend" if self.snapshot is not None and self.snapshot.head_oid is None else "Replace the last commit using the index"))
630
+ self.commit_button.setText("Amend commit" if amend else "Commit")
631
+ commit_reason = self.reason_by_action.get(OperationAction.AMEND if amend else OperationAction.COMMIT, "")
632
+ push_reason = self.reason_by_action.get(OperationAction.COMMIT_PUSH, "")
633
+ self.commit_button.setEnabled(enabled and not conflict and not commit_reason and (self.amend_ready if amend else has_staging or self.merge_in_progress) and bool(self.message_edit.toPlainText().strip()))
634
+ self.commit_button.setToolTip(commit_reason)
635
+ self.commit_push_button.setEnabled(self.commit_button.isEnabled() and not push_reason)
636
+ self.commit_push_button.setToolTip(push_reason)
637
+ # Another operation in progress greys Commit for a reason the user cannot guess; say it next to the buttons.
638
+ reason_text = unavailable_reason_text({self.commit_button.text(): commit_reason, self.commit_push_button.accessibleName(): commit_reason or push_reason})
639
+ self.commit_reason_label.setText(reason_text)
640
+ self.commit_reason_label.setVisible(bool(reason_text))
641
+ self.message_edit.setReadOnly(self.busy)
642
+ # A symbol chosen during a write would change the message already captured by it; the button stays usable.
643
+ if self.emoji_grid is not None:
644
+ self.emoji_grid.setEnabled(not self.busy)
645
+ for button, file_list in ((self.stage_button, self.unstaged_list), (self.unstage_button, self.staged_list)):
646
+ entries = self.selected_entries(file_list)
647
+ protected = any(entry.skip_worktree or entry.conflict_stages for entry in entries)
648
+ actionable = any(entry.stageable for entry in entries) if button is self.stage_button else bool(entries)
649
+ reason = self.reason_by_action.get(OperationAction.STAGE if button is self.stage_button else OperationAction.UNSTAGE, "")
650
+ button.setEnabled(enabled and not reason and actionable and not protected)
651
+ button.setToolTip(reason or ("Resolve the selected conflict or expand its skip-worktree entry before preparing changes" if protected else SUBMODULE_NO_COMMIT_CHANGE if entries and not actionable else ""))
652
+
653
+ def show_snapshot(self, snapshot: WorktreeSnapshot | None, busy: bool, diagnostic: PreparedDiagnostic | None = None, warning: bool = False,
654
+ commit_action: OperationAction | None = None) -> None:
655
+ """Keep unchanged native rows intact and retain selection when changed lists are rebuilt.
656
+
657
+ ``warning`` says the diagnostic is a result that needs attention, such as a commit that also holds a hook's
658
+ changes: its cause is drawn bold orange so it stands out. Any other text of the status bar has the ordinary look.
659
+ """
660
+ rebuild = self.snapshot is not snapshot
661
+ self.snapshot = snapshot
662
+ self.diagnostic = diagnostic
663
+ self.detail_button.setVisible(diagnostic is not None)
664
+ self.status_label.setToolTip(diagnostic.detail if diagnostic is not None else "")
665
+ self.busy = busy
666
+ if snapshot is not None:
667
+ for side, file_list in ((StagingSide.UNSTAGED, self.unstaged_list), (StagingSide.STAGED, self.staged_list)):
668
+ if not rebuild:
669
+ continue
670
+ path_set = {entry.path for entry in self.selected_entries(file_list)}
671
+ scroll = file_list.verticalScrollBar().value()
672
+ with QSignalBlocker(file_list):
673
+ file_list.clear()
674
+ for entry in snapshot.entries:
675
+ if entry.side != side:
676
+ continue
677
+ item = QListWidgetItem(status_icon(entry.status, ICON_SIDE, self.devicePixelRatioF()), entry.label)
678
+ item.setData(Qt.ItemDataRole.UserRole, entry)
679
+ if entry.is_gitlink and entry.side == StagingSide.STAGED:
680
+ item.setToolTip("This stages the submodule commit reference. Internal file changes are not included in the parent commit.")
681
+ elif entry.is_gitlink and entry.submodule.dirty:
682
+ item.setToolTip(SUBMODULE_DIRTY_DESCRIPTION)
683
+ file_list.addItem(item)
684
+ item.setSelected(entry.path in path_set)
685
+ file_list.verticalScrollBar().setValue(scroll)
686
+ self.status_label.setText(f"{snapshot.count} changed files" if snapshot.fresh else "Changes are not up to date")
687
+ if diagnostic is not None:
688
+ self.status_label.show_result(diagnostic.cause, ResultTone.WARNING if warning else ResultTone.NEUTRAL)
689
+ self.status_label.setToolTip(diagnostic.detail)
690
+ elif busy and self.close_requested:
691
+ self.status_label.setText(StagingText.WAITING_TO_CLOSE.value)
692
+ elif commit_action is not None and commit_action.commit_progress_text is not None:
693
+ self.status_label.setText(commit_action.commit_progress_text)
694
+ elif snapshot is not None and any(entry.conflict_stages for entry in snapshot.entries):
695
+ self.status_label.setText("Resolve all conflicts before preparing changes")
696
+ elif busy:
697
+ self.status_label.setText("Preparing changes…")
698
+ self._filter()
699
+ self._availability()
700
+
701
+ def _filter(self) -> None:
702
+ """Filter decoded labels without changing stored identity or the total count."""
703
+ query = self.filter_edit.text().casefold()
704
+ for file_list in (self.unstaged_list, self.staged_list):
705
+ visible_count = 0
706
+ for row in range(file_list.count()):
707
+ item = file_list.item(row)
708
+ item.setHidden(query not in item.text().casefold())
709
+ if item.isHidden():
710
+ item.setSelected(False)
711
+ else:
712
+ visible_count += 1
713
+ label = self.unstaged_empty_label if file_list is self.unstaged_list else self.staged_empty_label
714
+ label.setVisible(visible_count == 0)
715
+ if file_list.count():
716
+ label.setText("No matching files.\nChange or clear the filter to see other changes.")
717
+ else:
718
+ label.setText("No unstaged changes." if file_list is self.unstaged_list else "No staged changes.\nSelect a changed file and use Stage selected.")
719
+ if not self.selected_entries(self.unstaged_list) and not self.selected_entries(self.staged_list):
720
+ self._clear_preview()
721
+ self._availability()
722
+
723
+ def find_message(self) -> None:
724
+ """Open literal search within the draft without changing its contents."""
725
+ self.message_find_edit.show()
726
+ self.message_find_edit.setFocus()
727
+
728
+ def _find_message_text(self, text: str) -> None:
729
+ """Select a literal match using Qt's Unicode positions and preserve all draft bytes."""
730
+ if not text:
731
+ return
732
+ cursor = self.message_edit.document().find(text)
733
+ if not cursor.isNull():
734
+ self.message_edit.setTextCursor(cursor)
735
+
736
+ def closeEvent(self, event: QCloseEvent) -> None:
737
+ """Keep an active write's fields visible until its actual outcome is reconciled."""
738
+ if self.busy:
739
+ self.close_requested = True
740
+ if self.diagnostic is None:
741
+ self.status_label.setText(StagingText.WAITING_TO_CLOSE.value)
742
+ event.ignore()
743
+ return
744
+ self.closed.emit()
745
+ event.accept()