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,138 @@
|
|
|
1
|
+
"""Share typed historical-file intentions across the two repository file lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Signal
|
|
8
|
+
from PySide6.QtWidgets import QMenu
|
|
9
|
+
|
|
10
|
+
from gitpane.history.repository_value import FilePanel, RevisionEntry
|
|
11
|
+
from gitpane.ui.emoji.file_action_icon import file_action_icon
|
|
12
|
+
from gitpane.worktree.file_action_target import FileActionTargetKind
|
|
13
|
+
from gitpane.worktree.file_menu_action import FileAction
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from PySide6.QtWidgets import QWidget
|
|
19
|
+
|
|
20
|
+
from gitpane.worktree.file_action_target import FileActionSelection, FileRestoreTarget
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FileActionMenu(QMenu):
|
|
24
|
+
"""Validate captured selection again before emitting a shared file intention."""
|
|
25
|
+
|
|
26
|
+
history_requested = Signal(object)
|
|
27
|
+
compare_requested = Signal(object)
|
|
28
|
+
filename_requested = Signal(object)
|
|
29
|
+
absolute_filename_requested = Signal(object)
|
|
30
|
+
folder_requested = Signal(object)
|
|
31
|
+
text_editor_requested = Signal(object)
|
|
32
|
+
save_requested = Signal(object)
|
|
33
|
+
restore_requested = Signal(object, object)
|
|
34
|
+
remove_history_requested = Signal(object)
|
|
35
|
+
|
|
36
|
+
def __init__(self, selection: FileActionSelection, validate: Callable[[FileActionSelection], bool], parent: QWidget,
|
|
37
|
+
restore_targets: list[FileRestoreTarget] | None = None, text_editor_reason: Callable[[FileActionSelection], str] | None = None,
|
|
38
|
+
remove_history_reason: Callable[[FileActionSelection], str] | None = None) -> None:
|
|
39
|
+
"""Share validated file actions and offer external comparison for a Diff entry.
|
|
40
|
+
|
|
41
|
+
The text editor reason comes from the file action controller, which knows the preferences and the disk; a non-empty
|
|
42
|
+
reason greys the entry and shows why.
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(parent)
|
|
45
|
+
self.selection = selection
|
|
46
|
+
self.validate = validate
|
|
47
|
+
self.remove_history_reason = remove_history_reason
|
|
48
|
+
self.history_removal_requested = False
|
|
49
|
+
if selection.kind == FileActionTargetKind.FOLDER:
|
|
50
|
+
self.restore_menu = None
|
|
51
|
+
self.addAction(file_action_icon(FileAction.COPY_BASENAME, self), FileAction.COPY_BASENAME.label, lambda: self.request(self.filename_requested))
|
|
52
|
+
self.addAction(file_action_icon(FileAction.COPY_ABSOLUTE, self), FileAction.COPY_ABSOLUTE.label, lambda: self.request(self.absolute_filename_requested))
|
|
53
|
+
self._add_history_removal()
|
|
54
|
+
return
|
|
55
|
+
self.restore_menu = self.addMenu(FileAction.RESET_TO.label) if restore_targets is not None else None
|
|
56
|
+
self.set_restore_targets(restore_targets or [])
|
|
57
|
+
self.addAction(file_action_icon(FileAction.DISPLAY_HISTORY, self), FileAction.DISPLAY_HISTORY.label, lambda: self.request(self.history_requested))
|
|
58
|
+
if selection.panel_kind == FilePanel.DIFF:
|
|
59
|
+
self.addAction(file_action_icon(FileAction.COMPARE, self), FileAction.COMPARE.label, lambda: self.request(self.compare_requested))
|
|
60
|
+
self.addAction(file_action_icon(FileAction.FOLDER, self), FileAction.FOLDER.label, lambda: self.request(self.folder_requested))
|
|
61
|
+
text_editor_action = self.addAction(file_action_icon(FileAction.TEXT_EDITOR, self), FileAction.TEXT_EDITOR.label, lambda: self.request(self.text_editor_requested))
|
|
62
|
+
text_editor_unavailable = text_editor_reason(selection) if text_editor_reason is not None else ""
|
|
63
|
+
if text_editor_unavailable:
|
|
64
|
+
text_editor_action.setEnabled(False)
|
|
65
|
+
text_editor_action.setToolTip(text_editor_unavailable)
|
|
66
|
+
self.setToolTipsVisible(True)
|
|
67
|
+
self.addAction(file_action_icon(FileAction.COPY_BASENAME, self), FileAction.COPY_BASENAME.label, lambda: self.request(self.filename_requested))
|
|
68
|
+
self.addAction(file_action_icon(FileAction.COPY_ABSOLUTE, self), FileAction.COPY_ABSOLUTE.label, lambda: self.request(self.absolute_filename_requested))
|
|
69
|
+
action = self.addAction(file_action_icon(FileAction.SAVE_AS, self), FileAction.SAVE_AS.label, lambda: self.request(self.save_requested))
|
|
70
|
+
entry = selection.entry
|
|
71
|
+
mode = (entry.new_mode if entry.new_blob_oid else entry.old_mode) if isinstance(entry, RevisionEntry) else entry.mode
|
|
72
|
+
if mode == "160000":
|
|
73
|
+
action.setEnabled(False)
|
|
74
|
+
action.setToolTip("A submodule points to a commit, not a file blob.")
|
|
75
|
+
self.setToolTipsVisible(True)
|
|
76
|
+
self._add_history_removal()
|
|
77
|
+
|
|
78
|
+
def _add_history_removal(self) -> None:
|
|
79
|
+
"""Keep the history mutation last, with a reason when the repository is unavailable."""
|
|
80
|
+
self.addSeparator()
|
|
81
|
+
self.remove_history_action = self.addAction(file_action_icon(FileAction.REMOVE_FROM_HISTORY, self), FileAction.REMOVE_FROM_HISTORY.label, self.request_remove_history)
|
|
82
|
+
reason = self.remove_history_reason(self.selection) if self.remove_history_reason is not None else ""
|
|
83
|
+
self.remove_history_action.setEnabled(not reason)
|
|
84
|
+
self.remove_history_action.setToolTip(reason)
|
|
85
|
+
self.setToolTipsVisible(True)
|
|
86
|
+
for action in self.actions():
|
|
87
|
+
action.setIconVisibleInMenu(True)
|
|
88
|
+
|
|
89
|
+
def request(self, signal: Signal) -> None:
|
|
90
|
+
"""Ignore commands from menus whose owning selection has been invalidated."""
|
|
91
|
+
if self.validate(self.selection):
|
|
92
|
+
signal.emit(self.selection)
|
|
93
|
+
|
|
94
|
+
def request_remove_history(self) -> None:
|
|
95
|
+
"""Recheck availability at activation and emit at most one removal per menu."""
|
|
96
|
+
if self.history_removal_requested or not self.validate(self.selection):
|
|
97
|
+
return
|
|
98
|
+
reason = self.remove_history_reason(self.selection) if self.remove_history_reason is not None else ""
|
|
99
|
+
if reason:
|
|
100
|
+
self.remove_history_action.setEnabled(False)
|
|
101
|
+
self.remove_history_action.setToolTip(reason)
|
|
102
|
+
return
|
|
103
|
+
self.history_removal_requested = True
|
|
104
|
+
self.remove_history_action.setEnabled(False)
|
|
105
|
+
self.request(self.remove_history_requested)
|
|
106
|
+
|
|
107
|
+
def set_restore_targets(self, targets: list[FileRestoreTarget]) -> None:
|
|
108
|
+
"""Update parent metadata in place so an open menu retains its actions and order."""
|
|
109
|
+
if self.restore_menu is None:
|
|
110
|
+
return
|
|
111
|
+
icon = file_action_icon(FileAction.RESET_TO, self.restore_menu)
|
|
112
|
+
icon_size = next(iter(icon.availableSizes()))
|
|
113
|
+
normal_pixmap_key = icon.pixmap(icon_size).cacheKey()
|
|
114
|
+
menu_action = self.restore_menu.menuAction()
|
|
115
|
+
if menu_action.icon().pixmap(icon_size).cacheKey() != normal_pixmap_key:
|
|
116
|
+
menu_action.setIcon(icon)
|
|
117
|
+
menu_action.setIconVisibleInMenu(True)
|
|
118
|
+
actions = self.restore_menu.actions()
|
|
119
|
+
if len(actions) != len(targets):
|
|
120
|
+
self.restore_menu.clear()
|
|
121
|
+
for target in targets:
|
|
122
|
+
action = self.restore_menu.addAction(target.label)
|
|
123
|
+
action.triggered.connect(lambda checked=False, action=action: self.request_restore(action.data()))
|
|
124
|
+
actions = self.restore_menu.actions()
|
|
125
|
+
for action, target in zip(actions, targets, strict=True):
|
|
126
|
+
if action.icon().pixmap(icon_size).cacheKey() != normal_pixmap_key:
|
|
127
|
+
action.setIcon(icon)
|
|
128
|
+
action.setIconVisibleInMenu(True)
|
|
129
|
+
action.setText(target.label.replace("&", "&&"))
|
|
130
|
+
action.setData(target)
|
|
131
|
+
action.setToolTip(target.unavailable or target.message)
|
|
132
|
+
action.setEnabled(not target.unavailable)
|
|
133
|
+
self.restore_menu.setToolTipsVisible(True)
|
|
134
|
+
|
|
135
|
+
def request_restore(self, target: FileRestoreTarget) -> None:
|
|
136
|
+
"""Emit the selected source only while its captured file selection remains current."""
|
|
137
|
+
if not target.unavailable and self.validate(self.selection):
|
|
138
|
+
self.restore_requested.emit(self.selection, target)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Browse historical Git paths beside a literal, searchable content reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from time import monotonic
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QEvent, QSize, Qt, QTimer, Signal
|
|
9
|
+
from PySide6.QtGui import QFontDatabase, QTextCursor
|
|
10
|
+
from PySide6.QtWidgets import QPlainTextEdit, QSplitter, QStyle, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator, QVBoxLayout, QWidget
|
|
11
|
+
|
|
12
|
+
from gitpane.config import DIFF_FILE_STRETCH, DIFF_TEXT_STRETCH, FILE_PARSE_BUDGET_MS, FILE_TEXT_BATCH_CHARS, LAYOUT_SPACING
|
|
13
|
+
from gitpane.ui.reader.commit_message import CommitMessage
|
|
14
|
+
from gitpane.ui.reader.reader_toolbar import ReaderToolbar
|
|
15
|
+
from gitpane.ui.reader.revision_tree import FOLDER_PATH_ROLE
|
|
16
|
+
from gitpane.ui.reader.source_view import SourceFileView
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from gitpane.history.repository_value import BlobContent, FilePanel, TreeEntry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileTreePanel(QWidget):
|
|
23
|
+
"""Keep revision objects and their original identity beside read-only text."""
|
|
24
|
+
|
|
25
|
+
selection_requested = Signal(object)
|
|
26
|
+
retry_requested = Signal(object)
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
"""Build a compact tree and reuse the literal reader's local search controls."""
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.entries: list[TreeEntry] = []
|
|
32
|
+
self.item_by_entry: dict[TreeEntry, QTreeWidgetItem] = {}
|
|
33
|
+
self.selected_entry: TreeEntry | None = None
|
|
34
|
+
self.content: BlobContent | None = None
|
|
35
|
+
self.render_generation = 0
|
|
36
|
+
self.tree_generation = 0
|
|
37
|
+
self.action_generation = 0
|
|
38
|
+
self.retry_kind: FilePanel | None = None
|
|
39
|
+
layout = QVBoxLayout(self)
|
|
40
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
41
|
+
layout.setSpacing(LAYOUT_SPACING)
|
|
42
|
+
self.reader_toolbar = ReaderToolbar("Find in historical file")
|
|
43
|
+
self.status_label = self.reader_toolbar.status_label
|
|
44
|
+
self.status_label.setText("Select a commit")
|
|
45
|
+
self.retry_button = self.reader_toolbar.retry_button
|
|
46
|
+
self.retry_button.clicked.connect(lambda: self.retry_requested.emit(self.retry_kind))
|
|
47
|
+
self.file_tree = QTreeWidget()
|
|
48
|
+
self.file_tree.setHeaderHidden(True)
|
|
49
|
+
self.file_tree.setUniformRowHeights(True)
|
|
50
|
+
self.file_tree.setAccessibleName("Revision files")
|
|
51
|
+
self.reader = CommitMessage(SourceFileView())
|
|
52
|
+
self.reader.find_edit.setPlaceholderText("Find in this file")
|
|
53
|
+
self.reader.find_edit.setAccessibleName("Find in historical file")
|
|
54
|
+
self.text_edit = self.reader.text_edit
|
|
55
|
+
self.text_edit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|
56
|
+
self.text_edit.setAccessibleName("Historical file content")
|
|
57
|
+
self.text_edit.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
|
|
58
|
+
self.reader_toolbar.find_button.clicked.connect(self.open_search)
|
|
59
|
+
self.reader_container = QWidget()
|
|
60
|
+
reader_layout = QVBoxLayout(self.reader_container)
|
|
61
|
+
reader_layout.setContentsMargins(0, 0, 0, 0)
|
|
62
|
+
reader_layout.setSpacing(LAYOUT_SPACING)
|
|
63
|
+
reader_layout.addWidget(self.reader_toolbar)
|
|
64
|
+
reader_layout.addWidget(self.reader, 1)
|
|
65
|
+
self.splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
66
|
+
self.splitter.setChildrenCollapsible(False)
|
|
67
|
+
self.splitter.addWidget(self.file_tree)
|
|
68
|
+
self.splitter.addWidget(self.reader_container)
|
|
69
|
+
file_index, text_index = 0, 1
|
|
70
|
+
self.splitter.setStretchFactor(file_index, DIFF_FILE_STRETCH)
|
|
71
|
+
self.splitter.setStretchFactor(text_index, DIFF_TEXT_STRETCH)
|
|
72
|
+
layout.addWidget(self.splitter, 1)
|
|
73
|
+
self.file_tree.currentItemChanged.connect(self._select_item)
|
|
74
|
+
self.file_tree.currentItemChanged.connect(self._invalidate_action)
|
|
75
|
+
self.refresh_metrics()
|
|
76
|
+
|
|
77
|
+
def refresh_metrics(self) -> None:
|
|
78
|
+
"""Scale native file and folder symbols with the tree's current font."""
|
|
79
|
+
side = max(self.file_tree.fontMetrics().height(), self.style().pixelMetric(QStyle.PixelMetric.PM_SmallIconSize))
|
|
80
|
+
self.file_tree.setIconSize(QSize(side, side))
|
|
81
|
+
self.file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon)
|
|
82
|
+
self.folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)
|
|
83
|
+
iterator = QTreeWidgetItemIterator(self.file_tree)
|
|
84
|
+
while iterator.value() is not None:
|
|
85
|
+
item = iterator.value()
|
|
86
|
+
item.setIcon(0, self.file_icon if item.data(0, Qt.ItemDataRole.UserRole) is not None else self.folder_icon)
|
|
87
|
+
iterator += 1
|
|
88
|
+
|
|
89
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
90
|
+
"""Follow system font, palette and style changes without rebuilding file identities."""
|
|
91
|
+
super().changeEvent(event)
|
|
92
|
+
if event.type() in (QEvent.Type.FontChange, QEvent.Type.ApplicationFontChange, QEvent.Type.PaletteChange, QEvent.Type.StyleChange) and hasattr(self, "file_tree"):
|
|
93
|
+
self.refresh_metrics()
|
|
94
|
+
|
|
95
|
+
def _invalidate_action(self) -> None:
|
|
96
|
+
self.action_generation += 1
|
|
97
|
+
|
|
98
|
+
def clear_selection(self, message: str = "Select a commit") -> None:
|
|
99
|
+
"""Remove old paths and text immediately when the commit changes."""
|
|
100
|
+
self.reader_toolbar.finish_loading()
|
|
101
|
+
self.selected_entry = None
|
|
102
|
+
self.retry_button.hide()
|
|
103
|
+
self.retry_kind = None
|
|
104
|
+
self.action_generation += 1
|
|
105
|
+
self.render_generation += 1
|
|
106
|
+
self.tree_generation += 1
|
|
107
|
+
self.content = None
|
|
108
|
+
self.entries.clear()
|
|
109
|
+
self.item_by_entry.clear()
|
|
110
|
+
self.file_tree.clear()
|
|
111
|
+
self.text_edit.clear()
|
|
112
|
+
self.status_label.setText(message)
|
|
113
|
+
self.status_label.setVisible(bool(message))
|
|
114
|
+
|
|
115
|
+
def show_tree(self, entries: list[TreeEntry]) -> None:
|
|
116
|
+
"""Build explicitly sorted folders without using displayed names as identity."""
|
|
117
|
+
self.clear_selection("Select a file" if entries else "Empty tree")
|
|
118
|
+
self.entries = list(entries)
|
|
119
|
+
item_by_path: dict[bytes, QTreeWidgetItem] = {}
|
|
120
|
+
pending = iter(sorted(entries, key=lambda entry: entry.path))
|
|
121
|
+
generation = self.tree_generation
|
|
122
|
+
|
|
123
|
+
def append_entries() -> None:
|
|
124
|
+
"""Limit Qt row creation per turn and stop a superseded tree projection."""
|
|
125
|
+
if generation != self.tree_generation:
|
|
126
|
+
return
|
|
127
|
+
# PySide does not invalidate this native root when its tree is deleted; the recursive callback must not capture its wrapper.
|
|
128
|
+
root = self.file_tree.invisibleRootItem()
|
|
129
|
+
deadline = monotonic() + FILE_PARSE_BUDGET_MS / 1000
|
|
130
|
+
while monotonic() < deadline:
|
|
131
|
+
entry = next(pending, None)
|
|
132
|
+
if entry is None:
|
|
133
|
+
for parent in (root, *item_by_path.values()):
|
|
134
|
+
children = parent.takeChildren()
|
|
135
|
+
children.sort(key=lambda item: (item.data(0, Qt.ItemDataRole.UserRole) is not None, item.text(0).casefold(), item.text(0)))
|
|
136
|
+
parent.addChildren(children)
|
|
137
|
+
return
|
|
138
|
+
components = entry.path.split(b"/")
|
|
139
|
+
basename = components.pop()
|
|
140
|
+
parent = root
|
|
141
|
+
directory_parts = []
|
|
142
|
+
for component in components:
|
|
143
|
+
directory_parts.append(component)
|
|
144
|
+
path = b"/".join(directory_parts)
|
|
145
|
+
item = item_by_path.get(path)
|
|
146
|
+
if item is None:
|
|
147
|
+
item = QTreeWidgetItem([component.decode("utf-8", "replace")])
|
|
148
|
+
item.setData(0, FOLDER_PATH_ROLE, path)
|
|
149
|
+
item.setToolTip(0, path.decode("utf-8", "replace"))
|
|
150
|
+
item.setIcon(0, self.folder_icon)
|
|
151
|
+
parent.addChild(item)
|
|
152
|
+
item_by_path[path] = item
|
|
153
|
+
parent = item
|
|
154
|
+
item = QTreeWidgetItem([basename.decode("utf-8", "replace")])
|
|
155
|
+
item.setIcon(0, self.file_icon)
|
|
156
|
+
item.setToolTip(0, entry.path.decode("utf-8", "replace"))
|
|
157
|
+
item.setData(0, Qt.ItemDataRole.UserRole, entry)
|
|
158
|
+
parent.addChild(item)
|
|
159
|
+
self.item_by_entry[entry] = item
|
|
160
|
+
QTimer.singleShot(0, append_entries)
|
|
161
|
+
|
|
162
|
+
append_entries()
|
|
163
|
+
|
|
164
|
+
def show_blob(self, content: BlobContent) -> None:
|
|
165
|
+
"""Present the display representation while retaining exact bytes separately."""
|
|
166
|
+
self.reader_toolbar.finish_loading()
|
|
167
|
+
self.retry_button.hide()
|
|
168
|
+
self.retry_kind = None
|
|
169
|
+
self.render_generation += 1
|
|
170
|
+
generation = self.render_generation
|
|
171
|
+
self.content = content
|
|
172
|
+
self.text_edit.clear()
|
|
173
|
+
self.text_edit.set_filename(self.selected_entry.path.decode("utf-8", "replace") if self.selected_entry is not None else "")
|
|
174
|
+
diagnostic = content.diagnostic
|
|
175
|
+
if self.selected_entry is not None and self.selected_entry.mode == "120000":
|
|
176
|
+
diagnostic = "Symbolic link — historical target" + (f"; {diagnostic}" if diagnostic else "")
|
|
177
|
+
self.status_label.setText(diagnostic)
|
|
178
|
+
self.status_label.setVisible(bool(diagnostic))
|
|
179
|
+
offset = 0
|
|
180
|
+
|
|
181
|
+
def append_text() -> None:
|
|
182
|
+
"""Yield during document insertion and abandon any superseded render."""
|
|
183
|
+
nonlocal offset
|
|
184
|
+
if generation != self.render_generation:
|
|
185
|
+
return
|
|
186
|
+
deadline = monotonic() + FILE_PARSE_BUDGET_MS / 1000
|
|
187
|
+
while offset < len(content.display_text) and monotonic() < deadline:
|
|
188
|
+
end = offset + FILE_TEXT_BATCH_CHARS
|
|
189
|
+
if content.display_text[end - 1:end + 1] == "\r\n":
|
|
190
|
+
end += 1
|
|
191
|
+
self.text_edit.append_source_text(content.display_text[offset:end])
|
|
192
|
+
offset = end
|
|
193
|
+
if offset < len(content.display_text):
|
|
194
|
+
QTimer.singleShot(0, append_text)
|
|
195
|
+
return
|
|
196
|
+
self.text_edit.moveCursor(QTextCursor.MoveOperation.Start)
|
|
197
|
+
self.text_edit.refresh_syntax()
|
|
198
|
+
|
|
199
|
+
append_text()
|
|
200
|
+
|
|
201
|
+
def show_submodule(self, oid: str) -> None:
|
|
202
|
+
"""Describe a gitlink without trying to resolve its object in the parent repository."""
|
|
203
|
+
self.show_error("Submodule commit")
|
|
204
|
+
self.text_edit.setPlainText(oid)
|
|
205
|
+
|
|
206
|
+
def show_error(self, cause: str, retry_kind: FilePanel | None = None) -> None:
|
|
207
|
+
"""Clear misleading text and keep an unavailable object diagnostic visible."""
|
|
208
|
+
self.reader_toolbar.finish_loading()
|
|
209
|
+
self.retry_kind = retry_kind
|
|
210
|
+
self.retry_button.setVisible(retry_kind is not None)
|
|
211
|
+
self.content = None
|
|
212
|
+
self.render_generation += 1
|
|
213
|
+
self.text_edit.clear()
|
|
214
|
+
self.status_label.setText(cause)
|
|
215
|
+
self.status_label.show()
|
|
216
|
+
|
|
217
|
+
def begin_loading(self) -> None:
|
|
218
|
+
"""Discard old file text immediately while keeping fast reads visually quiet."""
|
|
219
|
+
self.content = None
|
|
220
|
+
self.render_generation += 1
|
|
221
|
+
self.text_edit.clear()
|
|
222
|
+
self.retry_kind = None
|
|
223
|
+
self.reader_toolbar.start_loading("Loading file…")
|
|
224
|
+
|
|
225
|
+
def open_search(self) -> None:
|
|
226
|
+
"""Focus search in the displayed historical file."""
|
|
227
|
+
self.reader.open_search()
|
|
228
|
+
|
|
229
|
+
def _select_item(self, item: QTreeWidgetItem | None, previous: QTreeWidgetItem | None) -> None:
|
|
230
|
+
"""Emit the original object value, never a path reconstructed from labels."""
|
|
231
|
+
if item is None:
|
|
232
|
+
return
|
|
233
|
+
entry = item.data(0, Qt.ItemDataRole.UserRole)
|
|
234
|
+
if entry is not None:
|
|
235
|
+
self.selection_requested.emit(entry)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Keep the file actions of a text reader above it, with a loading notice only for slow reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt, QTimer
|
|
6
|
+
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QWidget
|
|
7
|
+
|
|
8
|
+
from gitpane.config import FIND_SHORTCUT, LAYOUT_SPACING
|
|
9
|
+
from gitpane.ui.widget.shortcut_text import with_shortcut
|
|
10
|
+
|
|
11
|
+
READER_LOADING_DELAY_MS = 200
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReaderToolbar(QWidget):
|
|
15
|
+
"""Reserve one stable control row, showing progress only for slower reads."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, search_description: str) -> None:
|
|
18
|
+
"""Place contextual feedback before right-aligned reader commands."""
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.controls = QHBoxLayout(self)
|
|
21
|
+
self.controls.setContentsMargins(0, 0, 0, 0)
|
|
22
|
+
self.controls.setSpacing(LAYOUT_SPACING)
|
|
23
|
+
self.status_label = QLabel()
|
|
24
|
+
self.status_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
25
|
+
self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
|
26
|
+
self.controls.addWidget(self.status_label, 1)
|
|
27
|
+
self.controls.addStretch(1)
|
|
28
|
+
self.retry_button = QPushButton("Retry reading")
|
|
29
|
+
self.retry_button.hide()
|
|
30
|
+
self.controls.addWidget(self.retry_button)
|
|
31
|
+
self.find_button = QPushButton("Find")
|
|
32
|
+
self.find_button.setAccessibleName(search_description)
|
|
33
|
+
self.find_button.setToolTip(with_shortcut(search_description, FIND_SHORTCUT))
|
|
34
|
+
self.controls.addWidget(self.find_button)
|
|
35
|
+
self.loading_message = ""
|
|
36
|
+
self.loading_timer = QTimer(self)
|
|
37
|
+
self.loading_timer.setSingleShot(True)
|
|
38
|
+
self.loading_timer.setInterval(READER_LOADING_DELAY_MS)
|
|
39
|
+
self.loading_timer.timeout.connect(self._show_loading)
|
|
40
|
+
|
|
41
|
+
def start_loading(self, message: str) -> None:
|
|
42
|
+
"""Delay transient progress without moving the reader or showing stale errors."""
|
|
43
|
+
self.finish_loading()
|
|
44
|
+
self.status_label.clear()
|
|
45
|
+
self.retry_button.hide()
|
|
46
|
+
self.loading_message = message
|
|
47
|
+
self.loading_timer.start()
|
|
48
|
+
|
|
49
|
+
def finish_loading(self) -> None:
|
|
50
|
+
"""Cancel progress publication when a result or a newer selection takes over."""
|
|
51
|
+
self.loading_timer.stop()
|
|
52
|
+
self.loading_message = ""
|
|
53
|
+
|
|
54
|
+
def _show_loading(self) -> None:
|
|
55
|
+
self.status_label.setText(self.loading_message)
|
|
56
|
+
self.status_label.show()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Display compact revision paths in a tree drawn with native disclosure controls over dotted guides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt
|
|
8
|
+
from PySide6.QtGui import QPalette, QPen
|
|
9
|
+
from PySide6.QtWidgets import QTreeWidget
|
|
10
|
+
|
|
11
|
+
from gitpane.ui.look.theme_color import is_dark_palette
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from PySide6.QtCore import QModelIndex, QRect
|
|
15
|
+
from PySide6.QtGui import QPainter
|
|
16
|
+
from PySide6.QtWidgets import QTreeWidgetItem
|
|
17
|
+
|
|
18
|
+
BRANCH_DISCLOSURE_GAP = 5
|
|
19
|
+
FOLDER_PATH_ROLE = Qt.ItemDataRole.UserRole + 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RevisionTree(QTreeWidget):
|
|
23
|
+
"""Keep file-to-folder relationships visible across native platform themes."""
|
|
24
|
+
|
|
25
|
+
def drawBranches(self, painter: QPainter, rectangle: QRect, index: QModelIndex) -> None:
|
|
26
|
+
"""Draw dotted ancestor guides while retaining Qt's expand and collapse arrows.
|
|
27
|
+
|
|
28
|
+
A light theme draws them in the palette's mid tone. A dark one draws them in the view's text color: the dark mid
|
|
29
|
+
tone is barely darker or lighter than the dark background of a view, and the guides would not show.
|
|
30
|
+
"""
|
|
31
|
+
palette = self.palette()
|
|
32
|
+
guide_role = QPalette.ColorRole.Text if is_dark_palette(palette) else QPalette.ColorRole.Mid
|
|
33
|
+
painter.save()
|
|
34
|
+
painter.setPen(QPen(palette.color(guide_role), 1, Qt.PenStyle.DotLine))
|
|
35
|
+
indentation = self.indentation()
|
|
36
|
+
center_x = rectangle.right() - indentation // 2
|
|
37
|
+
center_y = rectangle.center().y()
|
|
38
|
+
gap = BRANCH_DISCLOSURE_GAP if self.model().hasChildren(index) else 0
|
|
39
|
+
painter.drawLine(center_x + gap, center_y, rectangle.right(), center_y)
|
|
40
|
+
if index.row() > 0 or index.parent().isValid():
|
|
41
|
+
painter.drawLine(center_x, rectangle.top(), center_x, center_y - gap)
|
|
42
|
+
if index.row() + 1 < self.model().rowCount(index.parent()):
|
|
43
|
+
painter.drawLine(center_x, center_y + gap, center_x, rectangle.bottom())
|
|
44
|
+
ancestor = index.parent()
|
|
45
|
+
while ancestor.isValid():
|
|
46
|
+
center_x -= indentation
|
|
47
|
+
if ancestor.row() + 1 < self.model().rowCount(ancestor.parent()):
|
|
48
|
+
painter.drawLine(center_x, rectangle.top(), center_x, rectangle.bottom())
|
|
49
|
+
ancestor = ancestor.parent()
|
|
50
|
+
painter.restore()
|
|
51
|
+
super().drawBranches(painter, rectangle, index)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compact_directory_tree(group: QTreeWidgetItem) -> None:
|
|
55
|
+
"""Join single-directory chains with slashes while leaving their last folder separate."""
|
|
56
|
+
first_child_index = 0
|
|
57
|
+
pending_items = group.takeChildren()
|
|
58
|
+
group.addChildren(pending_items)
|
|
59
|
+
while pending_items:
|
|
60
|
+
item = pending_items.pop()
|
|
61
|
+
if item.data(first_child_index, Qt.ItemDataRole.UserRole) is not None:
|
|
62
|
+
continue
|
|
63
|
+
while item.childCount() == 1:
|
|
64
|
+
child = item.child(first_child_index)
|
|
65
|
+
if child.data(first_child_index, Qt.ItemDataRole.UserRole) is not None:
|
|
66
|
+
break
|
|
67
|
+
has_directory = False
|
|
68
|
+
for child_index in range(child.childCount()):
|
|
69
|
+
if child.child(child_index).data(first_child_index, Qt.ItemDataRole.UserRole) is None:
|
|
70
|
+
has_directory = True
|
|
71
|
+
break
|
|
72
|
+
if not has_directory:
|
|
73
|
+
break
|
|
74
|
+
child = item.takeChild(first_child_index)
|
|
75
|
+
item.setText(first_child_index, item.text(first_child_index) + "/" + child.text(first_child_index))
|
|
76
|
+
item.setToolTip(first_child_index, child.toolTip(first_child_index))
|
|
77
|
+
item.setData(first_child_index, FOLDER_PATH_ROLE, child.data(first_child_index, FOLDER_PATH_ROLE))
|
|
78
|
+
item.addChildren(child.takeChildren())
|
|
79
|
+
for child_index in range(item.childCount()):
|
|
80
|
+
pending_items.append(item.child(child_index))
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Show the wildcard matches of a reader, counted then highlighted, without rescanning unchanged text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_left
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QEvent, QObject
|
|
9
|
+
from PySide6.QtGui import QColor, QPalette, QTextCursor
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QLabel, QTextEdit
|
|
11
|
+
|
|
12
|
+
from gitpane.history.revision_file import utf16_length
|
|
13
|
+
from gitpane.text.text_query import TextQuery
|
|
14
|
+
from gitpane.ui.reader.source_text import SourceTextEdit
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from PySide6.QtGui import QTextDocument
|
|
18
|
+
|
|
19
|
+
SEARCH_MATCH_BACKGROUND = "#fff176"
|
|
20
|
+
SEARCH_MATCH_FOREGROUND = "#202020"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The class whose palette the commit graph and the other lists paint their selected rows with. Under macOS it holds the
|
|
24
|
+
# accent blue (#0640c6 in dark), where text controls get a muted text selection blue (#314f78); elsewhere no class
|
|
25
|
+
# palette exists and Qt answers the application palette, the one text controls already use.
|
|
26
|
+
LIST_SELECTION_PALETTE_CLASS = "QAbstractItemView"
|
|
27
|
+
SELECTION_ROLES = (QPalette.ColorRole.Highlight, QPalette.ColorRole.HighlightedText)
|
|
28
|
+
SELECTION_GROUPS = (QPalette.ColorGroup.Active, QPalette.ColorGroup.Inactive)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def use_list_selection_colors(text_edit: QTextEdit | SourceTextEdit) -> bool:
|
|
32
|
+
"""Paint a reader's selection, the current match, like a selected row of the commit graph, focused or not; answer whether the palette had to change.
|
|
33
|
+
|
|
34
|
+
Starts from the reader's own palette, which pins only the colors set here, so a later light/dark switch still
|
|
35
|
+
reaches its other colors. A palette that already has them is left alone, so no palette change is raised for nothing.
|
|
36
|
+
"""
|
|
37
|
+
list_palette = QApplication.palette(LIST_SELECTION_PALETTE_CLASS)
|
|
38
|
+
palette = text_edit.palette()
|
|
39
|
+
changed = False
|
|
40
|
+
for role in SELECTION_ROLES:
|
|
41
|
+
list_color = list_palette.color(QPalette.ColorGroup.Active, role)
|
|
42
|
+
for group in SELECTION_GROUPS:
|
|
43
|
+
if palette.color(group, role) == list_color and palette.isBrushSet(group, role):
|
|
44
|
+
continue
|
|
45
|
+
palette.setColor(group, role, list_color)
|
|
46
|
+
changed = True
|
|
47
|
+
if changed:
|
|
48
|
+
text_edit.setPalette(palette)
|
|
49
|
+
return changed
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ActiveSelectionKeeper(QObject):
|
|
53
|
+
"""Keep a searched reader's selection, the current match, in the selected-row blue of the lists, also after a light/dark switch.
|
|
54
|
+
|
|
55
|
+
The focus stays in the find field while the user goes from match to match, so the reader would paint its selection
|
|
56
|
+
with the colors of an inactive widget, grey in a dark Fusion theme, over the current match; and the text selection
|
|
57
|
+
blue of macOS is too dull next to the yellow of the other matches. The reader takes the selection colors of the
|
|
58
|
+
lists for both groups, and takes them again each time its palette changes.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, text_edit: QTextEdit | SourceTextEdit) -> None:
|
|
62
|
+
"""Belong to the reader and follow its palette notices."""
|
|
63
|
+
super().__init__(text_edit)
|
|
64
|
+
use_list_selection_colors(text_edit)
|
|
65
|
+
text_edit.installEventFilter(self)
|
|
66
|
+
|
|
67
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
68
|
+
"""Set the selection colors again when the reader's palette changes.
|
|
69
|
+
|
|
70
|
+
When they had to change, setting them raised a palette notice of its own, already delivered with the final
|
|
71
|
+
colors; this older notice is stopped so the reader does not lay its look twice.
|
|
72
|
+
"""
|
|
73
|
+
if event.type() != QEvent.Type.PaletteChange:
|
|
74
|
+
return False
|
|
75
|
+
return use_list_selection_colors(watched)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SearchResultLabel(QLabel):
|
|
79
|
+
"""Share match totals and yellow highlights between commit, diff and file readers."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
"""Invalidate the count whenever its query or document content changes."""
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.document: QTextDocument | None = None
|
|
85
|
+
self.revision = -1
|
|
86
|
+
self.query = ""
|
|
87
|
+
self.match_count = 0
|
|
88
|
+
self.match_ranges: list[tuple[int, int]] = []
|
|
89
|
+
self.match_selections: list[QTextEdit.ExtraSelection] = []
|
|
90
|
+
|
|
91
|
+
def show_result(self, text_edit: QTextEdit | SourceTextEdit, query: str) -> None:
|
|
92
|
+
"""Count local matches without letting wildcard gaps span whitespace or line breaks."""
|
|
93
|
+
if not query:
|
|
94
|
+
self.query = ""
|
|
95
|
+
self.match_count = 0
|
|
96
|
+
self.match_ranges.clear()
|
|
97
|
+
self.match_selections.clear()
|
|
98
|
+
self.clear()
|
|
99
|
+
return
|
|
100
|
+
document = text_edit.document()
|
|
101
|
+
revision = document.revision()
|
|
102
|
+
if self.document is not document or self.revision != revision or self.query != query:
|
|
103
|
+
self.document = document
|
|
104
|
+
self.revision = revision
|
|
105
|
+
self.query = query
|
|
106
|
+
self.match_ranges.clear()
|
|
107
|
+
self.match_selections.clear()
|
|
108
|
+
if isinstance(text_edit, SourceTextEdit):
|
|
109
|
+
self.match_ranges.extend(text_edit.find_match_range(query, wildcard=True, wildcard_whitespace=False))
|
|
110
|
+
else:
|
|
111
|
+
text = text_edit.toPlainText()
|
|
112
|
+
previous = 0
|
|
113
|
+
position = 0
|
|
114
|
+
for begin, end in TextQuery(query).find_match(text, wildcard_whitespace=False):
|
|
115
|
+
position += utf16_length(text[previous:begin])
|
|
116
|
+
self.match_ranges.append((position, position + utf16_length(text[begin:end])))
|
|
117
|
+
previous = begin
|
|
118
|
+
self.match_count = len(self.match_ranges)
|
|
119
|
+
if not self.match_count:
|
|
120
|
+
self.setText("No matches")
|
|
121
|
+
return
|
|
122
|
+
noun = "match" if self.match_count == 1 else "matches"
|
|
123
|
+
self.setText(f"{self.match_count} {noun} found")
|
|
124
|
+
|
|
125
|
+
def find_match(self, text_edit: QTextEdit | SourceTextEdit, query: str, start: int, backward: bool = False) -> QTextCursor:
|
|
126
|
+
"""Navigate the same cached spans used by the total and yellow highlights."""
|
|
127
|
+
self.show_result(text_edit, query)
|
|
128
|
+
index = bisect_left(self.match_ranges, (start,))
|
|
129
|
+
if backward:
|
|
130
|
+
index -= 1
|
|
131
|
+
if index >= 0:
|
|
132
|
+
_begin, end = self.match_ranges[index]
|
|
133
|
+
if end > start:
|
|
134
|
+
index -= 1
|
|
135
|
+
if not 0 <= index < len(self.match_ranges):
|
|
136
|
+
return QTextCursor()
|
|
137
|
+
begin, end = self.match_ranges[index]
|
|
138
|
+
cursor = QTextCursor(text_edit.document())
|
|
139
|
+
cursor.setPosition(begin)
|
|
140
|
+
cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
|
141
|
+
return cursor
|
|
142
|
+
|
|
143
|
+
def highlight_selections(self, text_edit: QTextEdit | SourceTextEdit, query: str, current: QTextCursor | None = None) -> list[QTextEdit.ExtraSelection]:
|
|
144
|
+
"""Mark all matches in yellow and keep the active result in the native selection color."""
|
|
145
|
+
if not query:
|
|
146
|
+
return []
|
|
147
|
+
self.show_result(text_edit, query)
|
|
148
|
+
if not self.match_selections:
|
|
149
|
+
for begin, end in self.match_ranges:
|
|
150
|
+
selection = QTextEdit.ExtraSelection()
|
|
151
|
+
selection.cursor = QTextCursor(text_edit.document())
|
|
152
|
+
selection.cursor.setPosition(begin)
|
|
153
|
+
selection.cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
|
154
|
+
selection.format.setBackground(QColor(SEARCH_MATCH_BACKGROUND))
|
|
155
|
+
selection.format.setForeground(QColor(SEARCH_MATCH_FOREGROUND))
|
|
156
|
+
self.match_selections.append(selection)
|
|
157
|
+
selections = list(self.match_selections)
|
|
158
|
+
if current is not None and current.hasSelection():
|
|
159
|
+
selection = QTextEdit.ExtraSelection()
|
|
160
|
+
selection.cursor = QTextCursor(current)
|
|
161
|
+
palette = text_edit.palette()
|
|
162
|
+
selection.format.setBackground(palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Highlight))
|
|
163
|
+
selection.format.setForeground(palette.color(QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText))
|
|
164
|
+
selections.append(selection)
|
|
165
|
+
return selections
|