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