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