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.
- gitpane/__init__.py +0 -0
- gitpane/account/__init__.py +0 -0
- gitpane/account/account_choice.py +297 -0
- gitpane/account/address_owner.py +67 -0
- gitpane/account/authentication.py +1811 -0
- gitpane/account/authentication_worker.py +836 -0
- gitpane/account/credential_manager.py +480 -0
- gitpane/account/credential_manager_install.py +171 -0
- gitpane/account/keyring_reader.py +135 -0
- gitpane/account/keyring_state.py +158 -0
- gitpane/account/ssh.py +312 -0
- gitpane/account/ssh_askpass.py +216 -0
- gitpane/account/ssh_prompt.py +222 -0
- gitpane/application.py +684 -0
- gitpane/commit/__init__.py +0 -0
- gitpane/commit/commit_metadata.py +88 -0
- gitpane/commit/commit_push.py +276 -0
- gitpane/commit/commit_write.py +830 -0
- gitpane/config.py +1066 -0
- gitpane/controller/__init__.py +0 -0
- gitpane/controller/account_controller.py +677 -0
- gitpane/controller/clone_controller.py +776 -0
- gitpane/controller/close_readiness.py +212 -0
- gitpane/controller/commit_timing_controller.py +566 -0
- gitpane/controller/conflict_controller.py +885 -0
- gitpane/controller/console_registry.py +38 -0
- gitpane/controller/emoji_window_controller.py +95 -0
- gitpane/controller/file_action_controller.py +479 -0
- gitpane/controller/history_read_controller.py +671 -0
- gitpane/controller/history_rewrite_controller.py +739 -0
- gitpane/controller/navigation_controller.py +658 -0
- gitpane/controller/opening_controller.py +375 -0
- gitpane/controller/operation_policy.py +76 -0
- gitpane/controller/rebase_controller.py +797 -0
- gitpane/controller/rebase_editor_controller.py +309 -0
- gitpane/controller/reference_form_controller.py +1677 -0
- gitpane/controller/reference_read_controller.py +242 -0
- gitpane/controller/refresh_controller.py +477 -0
- gitpane/controller/replay_controller.py +805 -0
- gitpane/controller/session_registry.py +337 -0
- gitpane/controller/setting_controller.py +592 -0
- gitpane/controller/shutdown_controller.py +345 -0
- gitpane/controller/staging_controller.py +199 -0
- gitpane/controller/staging_session_registry.py +47 -0
- gitpane/controller/stash_controller.py +367 -0
- gitpane/controller/transport_controller.py +813 -0
- gitpane/controller/welcome_controller.py +102 -0
- gitpane/controller/window_activation.py +116 -0
- gitpane/controller/worktree_state_controller.py +169 -0
- gitpane/emoji/__init__.py +0 -0
- gitpane/emoji/emoji_catalogue.py +156 -0
- gitpane/git_config/__init__.py +0 -0
- gitpane/git_config/git_setting.py +441 -0
- gitpane/history/__init__.py +0 -0
- gitpane/history/avatar.py +241 -0
- gitpane/history/file_history.py +359 -0
- gitpane/history/graph.py +145 -0
- gitpane/history/history_filter.py +31 -0
- gitpane/history/history_parse.py +605 -0
- gitpane/history/repository.py +1600 -0
- gitpane/history/repository_value.py +341 -0
- gitpane/history/revision.py +180 -0
- gitpane/history/revision_file.py +182 -0
- gitpane/history_rewrite/__init__.py +0 -0
- gitpane/history_rewrite/graph.py +210 -0
- gitpane/history_rewrite/guard.py +288 -0
- gitpane/history_rewrite/index.py +125 -0
- gitpane/history_rewrite/prepare.py +378 -0
- gitpane/history_rewrite/protocol.py +320 -0
- gitpane/history_rewrite/publish.py +444 -0
- gitpane/history_rewrite/recovery.py +481 -0
- gitpane/history_rewrite/value.py +229 -0
- gitpane/operation/__init__.py +0 -0
- gitpane/operation/console_line.py +313 -0
- gitpane/operation/console_tone.py +76 -0
- gitpane/operation/diagnostic.py +954 -0
- gitpane/operation/launch_signature.py +254 -0
- gitpane/operation/operation_status.py +87 -0
- gitpane/operation/operation_trace.py +766 -0
- gitpane/operation/repository_operation.py +781 -0
- gitpane/preference/__init__.py +0 -0
- gitpane/preference/preference_location.py +40 -0
- gitpane/preference/preference_store.py +590 -0
- gitpane/process/__init__.py +0 -0
- gitpane/process/exact_arguments.py +77 -0
- gitpane/process/git_output_text.py +20 -0
- gitpane/process/process_group.py +851 -0
- gitpane/process/process_log.py +114 -0
- gitpane/process/process_runner.py +1033 -0
- gitpane/process/released_child_watch.py +138 -0
- gitpane/recent/__init__.py +0 -0
- gitpane/recent/mount_table.py +422 -0
- gitpane/recent/recent_availability.py +414 -0
- gitpane/recent/recent_name.py +11 -0
- gitpane/recent/recent_search.py +114 -0
- gitpane/reference/__init__.py +0 -0
- gitpane/reference/reference_edit.py +2030 -0
- gitpane/reference/reference_snapshot.py +196 -0
- gitpane/reference/stash.py +730 -0
- gitpane/reference/stash_list.py +164 -0
- gitpane/sequencer/__init__.py +0 -0
- gitpane/sequencer/commit_message_rule.py +154 -0
- gitpane/sequencer/conflict.py +506 -0
- gitpane/sequencer/merge_recovery.py +163 -0
- gitpane/sequencer/merge_state.py +166 -0
- gitpane/sequencer/mergetool.py +856 -0
- gitpane/sequencer/rebase.py +750 -0
- gitpane/sequencer/rebase_edit_file.py +224 -0
- gitpane/sequencer/rebase_state.py +523 -0
- gitpane/sequencer/replay.py +1435 -0
- gitpane/sequencer/replay_legacy.py +253 -0
- gitpane/sequencer/replay_state.py +747 -0
- gitpane/submodule/__init__.py +0 -0
- gitpane/submodule/checkout_refusal.py +102 -0
- gitpane/submodule/checkout_refusal_notice.py +111 -0
- gitpane/submodule/cloned_without_checkout.py +65 -0
- gitpane/submodule/submodule_discovery.py +1180 -0
- gitpane/submodule/submodule_loss.py +218 -0
- gitpane/system/__init__.py +0 -0
- gitpane/system/appearance_portal.py +125 -0
- gitpane/system/application_identity.py +48 -0
- gitpane/system/command_line.py +36 -0
- gitpane/system/desktop_icon_fact.py +128 -0
- gitpane/system/desktop_icon_support.py +270 -0
- gitpane/system/desktop_shortcut.py +536 -0
- gitpane/system/icns_image.py +35 -0
- gitpane/system/installed_launcher.py +136 -0
- gitpane/system/linux_distribution.py +151 -0
- gitpane/system/network_folder.py +46 -0
- gitpane/system/path_identity.py +95 -0
- gitpane/system/private_tree_removal.py +47 -0
- gitpane/system/process_program.py +63 -0
- gitpane/system/qt_folder.py +38 -0
- gitpane/system/ready_channel_watch.py +67 -0
- gitpane/system/startup_activation.py +65 -0
- gitpane/system/terminal_detach.py +384 -0
- gitpane/system/xdg_session.py +85 -0
- gitpane/text/__init__.py +0 -0
- gitpane/text/text_query.py +97 -0
- gitpane/text/typed_path.py +75 -0
- gitpane/text/typed_text.py +17 -0
- gitpane/thread/__init__.py +0 -0
- gitpane/thread/weak_signal.py +93 -0
- gitpane/tool/__init__.py +0 -0
- gitpane/tool/app_bundle.py +164 -0
- gitpane/tool/desktop_text_editor.py +156 -0
- gitpane/tool/difftool.py +429 -0
- gitpane/tool/editor.py +445 -0
- gitpane/tool/editor_bridge.py +193 -0
- gitpane/tool/editor_recipe.py +481 -0
- gitpane/tool/file_reveal.py +94 -0
- gitpane/tool/git_executable.py +264 -0
- gitpane/tool/git_version.py +62 -0
- gitpane/tool/kdiff3.py +41 -0
- gitpane/tool/kdiff3_install.py +162 -0
- gitpane/tool/launch_services_text_editor.py +104 -0
- gitpane/tool/mergetool_recipe.py +84 -0
- gitpane/tool/system_editor_default.py +80 -0
- gitpane/tool/system_text_editor.py +44 -0
- gitpane/tool/system_tool.py +816 -0
- gitpane/tool/terminal_recipe.py +390 -0
- gitpane/transport/__init__.py +0 -0
- gitpane/transport/branch_exchange.py +1690 -0
- gitpane/transport/clone.py +1273 -0
- gitpane/transport/credential_read_retry.py +35 -0
- gitpane/transport/git_refusal.py +207 -0
- gitpane/transport/push_tracking.py +121 -0
- gitpane/transport/refusal_notice.py +225 -0
- gitpane/transport/remote.py +747 -0
- gitpane/transport/remote_config.py +544 -0
- gitpane/ui/__init__.py +0 -0
- gitpane/ui/console/__init__.py +0 -0
- gitpane/ui/console/error_dialog.py +332 -0
- gitpane/ui/console/operation_dialog.py +408 -0
- gitpane/ui/console/operation_output.py +91 -0
- gitpane/ui/console/operation_output_view.py +373 -0
- gitpane/ui/emoji/__init__.py +0 -0
- gitpane/ui/emoji/emoji_font.py +257 -0
- gitpane/ui/emoji/emoji_grid.py +125 -0
- gitpane/ui/emoji/emoji_window.py +154 -0
- gitpane/ui/emoji/file_action_icon.py +131 -0
- gitpane/ui/emoji/opentype_family_name.py +182 -0
- gitpane/ui/history_rewrite/__init__.py +0 -0
- gitpane/ui/history_rewrite/details.py +78 -0
- gitpane/ui/history_rewrite/preview.py +565 -0
- gitpane/ui/history_rewrite/preview_model.py +291 -0
- gitpane/ui/history_rewrite/question.py +87 -0
- gitpane/ui/history_rewrite/recovery_dialog.py +262 -0
- gitpane/ui/look/__init__.py +0 -0
- gitpane/ui/look/action_icon.py +504 -0
- gitpane/ui/look/application_icon.py +78 -0
- gitpane/ui/look/application_style.py +317 -0
- gitpane/ui/look/avatar_label.py +53 -0
- gitpane/ui/look/color_contrast.py +43 -0
- gitpane/ui/look/desktop_appearance_follower.py +118 -0
- gitpane/ui/look/fixed_font.py +21 -0
- gitpane/ui/look/gtk_settings.py +116 -0
- gitpane/ui/look/icon_density.py +16 -0
- gitpane/ui/look/reference_font.py +19 -0
- gitpane/ui/look/theme_color.py +63 -0
- gitpane/ui/look/theme_look.py +95 -0
- gitpane/ui/look/theme_palette.py +173 -0
- gitpane/ui/look/warning_icon.py +86 -0
- gitpane/ui/look/windows_control_look.py +250 -0
- gitpane/ui/reader/__init__.py +0 -0
- gitpane/ui/reader/commit_message.py +129 -0
- gitpane/ui/reader/commit_panel.py +378 -0
- gitpane/ui/reader/diff_panel.py +626 -0
- gitpane/ui/reader/file_action.py +138 -0
- gitpane/ui/reader/file_tree_panel.py +235 -0
- gitpane/ui/reader/reader_toolbar.py +56 -0
- gitpane/ui/reader/revision_tree.py +80 -0
- gitpane/ui/reader/search_result.py +165 -0
- gitpane/ui/reader/source_text.py +134 -0
- gitpane/ui/reader/source_view.py +151 -0
- gitpane/ui/reader/syntax_span.py +67 -0
- gitpane/ui/reference/__init__.py +0 -0
- gitpane/ui/reference/branch_selector.py +106 -0
- gitpane/ui/reference/checkout_dialog.py +419 -0
- gitpane/ui/reference/checkout_revision_dialog.py +182 -0
- gitpane/ui/reference/commit_summary.py +339 -0
- gitpane/ui/reference/create_branch_dialog.py +346 -0
- gitpane/ui/reference/create_tag_dialog.py +283 -0
- gitpane/ui/reference/delete_local_branch_dialog.py +323 -0
- gitpane/ui/reference/delete_tag_dialog.py +394 -0
- gitpane/ui/reference/form_opening.py +53 -0
- gitpane/ui/reference/reference_dialog.py +417 -0
- gitpane/ui/reference/reference_feedback.py +62 -0
- gitpane/ui/reference/reference_form.py +139 -0
- gitpane/ui/reference/rename_branch_dialog.py +179 -0
- gitpane/ui/reference/reset_branch_dialog.py +430 -0
- gitpane/ui/reference/reset_dialog.py +263 -0
- gitpane/ui/reference/revision_dialog.py +121 -0
- gitpane/ui/reference/revision_picker.py +227 -0
- gitpane/ui/reference/select_branches_dialog.py +199 -0
- gitpane/ui/reference/stash_dialog.py +171 -0
- gitpane/ui/repository/__init__.py +0 -0
- gitpane/ui/repository/graph_delegate.py +298 -0
- gitpane/ui/repository/history_avatar.py +130 -0
- gitpane/ui/repository/history_model.py +179 -0
- gitpane/ui/repository/repository_list_popup.py +319 -0
- gitpane/ui/repository/repository_window.py +2580 -0
- gitpane/ui/resource/ArrowDown.png +0 -0
- gitpane/ui/resource/ArrowUp.png +0 -0
- gitpane/ui/resource/BranchCheckout.png +0 -0
- gitpane/ui/resource/BranchCreate.png +0 -0
- gitpane/ui/resource/BranchDelete.png +0 -0
- gitpane/ui/resource/CopyToClipboard.png +0 -0
- gitpane/ui/resource/GitPane-16.png +0 -0
- gitpane/ui/resource/GitPane-24.png +0 -0
- gitpane/ui/resource/GitPane-256.png +0 -0
- gitpane/ui/resource/GitPane-32.png +0 -0
- gitpane/ui/resource/GitPane-48.png +0 -0
- gitpane/ui/resource/GitPane-64.png +0 -0
- gitpane/ui/resource/GitPane.ico +0 -0
- gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane/ui/resource/NotoColorEmoji_WindowsCompatible.ttf +0 -0
- gitpane/ui/resource/Renamed.png +0 -0
- gitpane/ui/resource/ResetAnotherBranchToHere.png +0 -0
- gitpane/ui/resource/ResetCurrentBranchToHere.png +0 -0
- gitpane/ui/resource/Stash.png +0 -0
- gitpane/ui/resource/Tag.png +0 -0
- gitpane/ui/resource/TagCreate.png +0 -0
- gitpane/ui/resource/TagDelete.png +0 -0
- gitpane/ui/resource/Unmerged.png +0 -0
- gitpane/ui/resource/checkout.png +0 -0
- gitpane/ui/sequencer/__init__.py +0 -0
- gitpane/ui/sequencer/conflict_dialog.py +532 -0
- gitpane/ui/sequencer/merge_recovery_dialog.py +67 -0
- gitpane/ui/sequencer/rebase_dialog.py +461 -0
- gitpane/ui/sequencer/rebase_editor_window.py +220 -0
- gitpane/ui/sequencer/replay_conflict_prompt.py +105 -0
- gitpane/ui/sequencer/replay_dialog.py +501 -0
- gitpane/ui/setting/__init__.py +0 -0
- gitpane/ui/setting/account_page.py +724 -0
- gitpane/ui/setting/add_account_dialog.py +1005 -0
- gitpane/ui/setting/credential_manager_dialog.py +151 -0
- gitpane/ui/setting/git_setting_widget.py +388 -0
- gitpane/ui/setting/setting_block.py +19 -0
- gitpane/ui/setting/setting_dialog.py +750 -0
- gitpane/ui/setting/ssh_widget.py +232 -0
- gitpane/ui/setting/utility_page.py +240 -0
- gitpane/ui/staging/__init__.py +0 -0
- gitpane/ui/staging/staging_action.py +356 -0
- gitpane/ui/staging/staging_window.py +745 -0
- gitpane/ui/transport/__init__.py +0 -0
- gitpane/ui/transport/clone_dialog.py +879 -0
- gitpane/ui/transport/push_dialog.py +580 -0
- gitpane/ui/transport/push_rejection_prompt.py +100 -0
- gitpane/ui/transport/push_tracking_question.py +88 -0
- gitpane/ui/transport/remote_dialog.py +943 -0
- gitpane/ui/welcome/__init__.py +0 -0
- gitpane/ui/welcome/invalid_repository_prompt.py +114 -0
- gitpane/ui/welcome/welcome_window.py +1137 -0
- gitpane/ui/widget/__init__.py +0 -0
- gitpane/ui/widget/busy_indicator.py +106 -0
- gitpane/ui/widget/commit_date_text.py +55 -0
- gitpane/ui/widget/content_growth.py +262 -0
- gitpane/ui/widget/content_measure.py +99 -0
- gitpane/ui/widget/dialog_layout.py +668 -0
- gitpane/ui/widget/drop_down_wheel_filter.py +32 -0
- gitpane/ui/widget/flow_layout.py +110 -0
- gitpane/ui/widget/long_text.py +542 -0
- gitpane/ui/widget/mnemonic.py +13 -0
- gitpane/ui/widget/numbered_text_edit.py +127 -0
- gitpane/ui/widget/plain_label.py +15 -0
- gitpane/ui/widget/plain_tooltip.py +19 -0
- gitpane/ui/widget/refresh_advice.py +25 -0
- gitpane/ui/widget/result_label.py +227 -0
- gitpane/ui/widget/result_status_bar.py +53 -0
- gitpane/ui/widget/scrolled_text.py +69 -0
- gitpane/ui/widget/shortcut_text.py +31 -0
- gitpane/ui/widget/standard_icon_label.py +55 -0
- gitpane/ui/widget/system_shortcut.py +75 -0
- gitpane/ui/widget/unavailable_reason.py +29 -0
- gitpane/ui/widget/wheel_safe_drop_down.py +24 -0
- gitpane/ui/window_control/__init__.py +0 -0
- gitpane/ui/window_control/button_focus.py +42 -0
- gitpane/ui/window_control/compositor_answer.py +152 -0
- gitpane/ui/window_control/compositor_growth_cap.py +73 -0
- gitpane/ui/window_control/desktop_activation.py +203 -0
- gitpane/ui/window_control/dialog_modality.py +122 -0
- gitpane/ui/window_control/item_activation.py +42 -0
- gitpane/ui/window_control/modal_focus_relay.py +124 -0
- gitpane/ui/window_control/wayland_activation.py +695 -0
- gitpane/ui/window_control/window_placement.py +305 -0
- gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- gitpane/vendor/w32.exe +0 -0
- gitpane/vendor/w64-arm.exe +0 -0
- gitpane/vendor/w64.exe +0 -0
- gitpane/worktree/__init__.py +0 -0
- gitpane/worktree/command_sequence.py +110 -0
- gitpane/worktree/file_action_target.py +61 -0
- gitpane/worktree/file_menu_action.py +52 -0
- gitpane/worktree/historical_restore.py +361 -0
- gitpane/worktree/ignore_rule.py +266 -0
- gitpane/worktree/mutation.py +1539 -0
- gitpane/worktree/partial_patch.py +435 -0
- gitpane/worktree/staged_reset.py +447 -0
- gitpane/worktree/staging.py +1418 -0
- gitpane/worktree/trash.py +302 -0
- gitpane/worktree/worktree_file.py +406 -0
- gitpane/worktree/worktree_service.py +584 -0
- gitpane/worktree/worktree_snapshot.py +630 -0
- gitpane-1.0.0.dist-info/METADATA +141 -0
- gitpane-1.0.0.dist-info/RECORD +353 -0
- gitpane-1.0.0.dist-info/WHEEL +5 -0
- gitpane-1.0.0.dist-info/entry_points.txt +2 -0
- gitpane-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitpane-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +109 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/ui/resource/NotoColorEmoji-OFL.txt +92 -0
- gitpane-1.0.0.dist-info/licenses/src/gitpane/vendor/DISTLIB-LICENSE.txt +284 -0
- 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)
|