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,151 @@
|
|
|
1
|
+
"""Tell the user which Git credential manager to install when an operation needs one, with its text to copy or its download page to open."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QEvent, QSize, Qt, QUrl, Signal
|
|
9
|
+
from PySide6.QtGui import QDesktopServices
|
|
10
|
+
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QStyle, QVBoxLayout
|
|
11
|
+
|
|
12
|
+
from gitpane.config import GCM_RELEASES_URL, GIT_FOR_WINDOWS_DOWNLOAD_URL, HOMEBREW_INSTALL_URL
|
|
13
|
+
from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
|
|
14
|
+
from gitpane.ui.widget.content_measure import LayoutContentMeasure
|
|
15
|
+
from gitpane.ui.widget.dialog_layout import MESSAGE_BOX_BODY_MARGINS, MESSAGE_BOX_BUTTON_BAND, MESSAGE_BOX_BUTTON_SIZE, MESSAGE_BOX_ICON_SIDE, MESSAGE_BOX_ICON_TO_TEXT_SPACE, BandButtonSizing, install_banded_layout
|
|
16
|
+
from gitpane.ui.widget.result_label import ResultLabel
|
|
17
|
+
from gitpane.ui.widget.standard_icon_label import StandardIconLabel
|
|
18
|
+
from gitpane.ui.window_control.button_focus import set_button_enabled
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
from PySide6.QtWidgets import QWidget
|
|
24
|
+
|
|
25
|
+
from gitpane.account.credential_manager import CredentialManagerDetection
|
|
26
|
+
|
|
27
|
+
CREDENTIAL_MANAGER_DIALOG_TITLE = "Git credential manager required"
|
|
28
|
+
CREDENTIAL_MANAGER_CLOSE_LABEL = "Close"
|
|
29
|
+
CREDENTIAL_MANAGER_CHECK_AGAIN_LABEL = "Check again"
|
|
30
|
+
# Only these constant download or install addresses become links; a path read from the PATH never does, whatever it looks like.
|
|
31
|
+
CREDENTIAL_MANAGER_LINK_URLS = (GCM_RELEASES_URL, GIT_FOR_WINDOWS_DOWNLOAD_URL, HOMEBREW_INSTALL_URL)
|
|
32
|
+
# Rich text merges consecutive spaces and drops leading ones, which would lose the space in front of each bullet.
|
|
33
|
+
INSTALL_TEXT_HTML_TEMPLATE = '<div style="white-space: pre-wrap;">{body}</div>'
|
|
34
|
+
INSTALL_LINE_BREAK_HTML = "<br>"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def install_text_html(lines: tuple[str, ...], link_urls: tuple[str, ...]) -> str:
|
|
38
|
+
"""Turn the install lines into rich text that keeps their leading spaces and makes only the known download addresses clickable.
|
|
39
|
+
|
|
40
|
+
Every line is escaped first, so a placeholder such as <version> or a path read from the PATH stays literal text and
|
|
41
|
+
can never become markup or a link.
|
|
42
|
+
"""
|
|
43
|
+
html_lines: list[str] = []
|
|
44
|
+
for line in lines:
|
|
45
|
+
escaped_line = html.escape(line)
|
|
46
|
+
for url in link_urls:
|
|
47
|
+
escaped_url = html.escape(url)
|
|
48
|
+
escaped_line = escaped_line.replace(escaped_url, '<a href="' + html.escape(url, quote=True) + '">' + escaped_url + "</a>")
|
|
49
|
+
html_lines.append(escaped_line)
|
|
50
|
+
return INSTALL_TEXT_HTML_TEMPLATE.format(body=INSTALL_LINE_BREAK_HTML.join(html_lines))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CredentialManagerDialog(ContentGrowthMixin, QDialog):
|
|
54
|
+
"""Explain which credential manager to install, as a window modal for the whole application.
|
|
55
|
+
|
|
56
|
+
It is shown with show(): QDialog's open method would make it modal for its parent window only, and exec would run a
|
|
57
|
+
nested event loop. Its text can be selected and copied from the right-click menu; only the known download addresses
|
|
58
|
+
are clickable. Close, Escape and the title-bar close all close it.
|
|
59
|
+
|
|
60
|
+
Check again asks for a new search once the user has installed the credential manager; the window only reports what
|
|
61
|
+
that search found and never starts the refused operation again, the user does. Check again rewrites the text for the
|
|
62
|
+
refusal it finds. The window's height follows its answer both ways: a long answer grows it, and the next Check again,
|
|
63
|
+
which hides the answer, brings it back to the height it had before; a longer install line widens it. Its width only
|
|
64
|
+
grows: a later refusal whose install line is shorter leaves the window as wide as before, with room to the right of
|
|
65
|
+
the text.
|
|
66
|
+
|
|
67
|
+
``blocked_by_modal`` is true while a modal window it does not belong to, opened after it, keeps it from taking any
|
|
68
|
+
click, as Qt reports it to the window.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
check_again_requested = Signal()
|
|
72
|
+
|
|
73
|
+
def __init__(self, lines: tuple[str, ...], detection: CredentialManagerDetection, parent: QWidget | None = None, *,
|
|
74
|
+
open_url: Callable[[QUrl], bool] = QDesktopServices.openUrl) -> None:
|
|
75
|
+
"""Build the window for the install ``lines`` of one refusal; ``open_url`` opens a clicked download address."""
|
|
76
|
+
super().__init__(parent)
|
|
77
|
+
self.detection = detection
|
|
78
|
+
self.blocked_by_modal = False
|
|
79
|
+
self.setWindowTitle(CREDENTIAL_MANAGER_DIALOG_TITLE)
|
|
80
|
+
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint)
|
|
81
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
82
|
+
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
|
83
|
+
layout = QVBoxLayout()
|
|
84
|
+
layout.setSpacing(0)
|
|
85
|
+
message_row = QHBoxLayout()
|
|
86
|
+
message_row.setSpacing(MESSAGE_BOX_ICON_TO_TEXT_SPACE)
|
|
87
|
+
icon_label = StandardIconLabel(QSize(MESSAGE_BOX_ICON_SIDE, MESSAGE_BOX_ICON_SIDE), QStyle.StandardPixmap.SP_MessageBoxWarning)
|
|
88
|
+
message_row.addWidget(icon_label, 0, Qt.AlignmentFlag.AlignTop)
|
|
89
|
+
# No word wrap and no fixed width: each install line stays on one line, so a copied command stays whole.
|
|
90
|
+
self.text_label = QLabel()
|
|
91
|
+
self.text_label.setTextFormat(Qt.TextFormat.RichText)
|
|
92
|
+
self.text_label.setText(install_text_html(lines, CREDENTIAL_MANAGER_LINK_URLS))
|
|
93
|
+
self.text_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.LinksAccessibleByMouse)
|
|
94
|
+
self.text_label.setOpenExternalLinks(False)
|
|
95
|
+
self.text_label.linkActivated.connect(lambda url: open_url(QUrl(url)))
|
|
96
|
+
text_column = QVBoxLayout()
|
|
97
|
+
text_column.addWidget(self.text_label)
|
|
98
|
+
# The answer of Check again, under the install text it refers to; hidden while there is none.
|
|
99
|
+
self.result_label = ResultLabel()
|
|
100
|
+
self.result_label.hide()
|
|
101
|
+
text_column.addWidget(self.result_label)
|
|
102
|
+
message_row.addLayout(text_column, 1)
|
|
103
|
+
layout.addLayout(message_row)
|
|
104
|
+
self.check_again_button = QPushButton(CREDENTIAL_MANAGER_CHECK_AGAIN_LABEL)
|
|
105
|
+
self.check_again_button.setMinimumSize(MESSAGE_BOX_BUTTON_SIZE)
|
|
106
|
+
self.check_again_button.setAutoDefault(False)
|
|
107
|
+
# Enter checks again, the natural next step once the user has installed the credential manager; Escape still closes.
|
|
108
|
+
self.check_again_button.setDefault(True)
|
|
109
|
+
self.close_button = QPushButton(CREDENTIAL_MANAGER_CLOSE_LABEL)
|
|
110
|
+
self.close_button.setMinimumSize(MESSAGE_BOX_BUTTON_SIZE)
|
|
111
|
+
self.close_button.setAutoDefault(False)
|
|
112
|
+
install_banded_layout(self, layout, [self.check_again_button, self.close_button], MESSAGE_BOX_BUTTON_BAND, MESSAGE_BOX_BODY_MARGINS, button_sizing=BandButtonSizing.WINDOW)
|
|
113
|
+
self.check_again_button.clicked.connect(self.check_again_requested.emit)
|
|
114
|
+
self.close_button.clicked.connect(self.reject)
|
|
115
|
+
# The window has no scroll area: its own layout tells the height its rows need. It opens at the height Qt gives
|
|
116
|
+
# it at its show, its layout's preferred height; Qt itself widens it to a longer install line, whose label
|
|
117
|
+
# never wraps, through the layout's minimum width.
|
|
118
|
+
self.install_content_growth(LayoutContentMeasure(self), OpeningFit.GROW_ONLY, self.sizeHint().height())
|
|
119
|
+
|
|
120
|
+
def event(self, event: QEvent) -> bool:
|
|
121
|
+
"""Remember whether Qt currently blocks this window under another modal window, so a new refusal knows whether it can offer it again."""
|
|
122
|
+
if event.type() == QEvent.Type.WindowBlocked:
|
|
123
|
+
self.blocked_by_modal = True
|
|
124
|
+
elif event.type() == QEvent.Type.WindowUnblocked:
|
|
125
|
+
self.blocked_by_modal = False
|
|
126
|
+
return super().event(event)
|
|
127
|
+
|
|
128
|
+
def show_detection(self, lines: tuple[str, ...], detection: CredentialManagerDetection) -> None:
|
|
129
|
+
"""Show the install text of the refusal a new search found, so the window, and a later short answer of Check again, always refer to the refusal shown.
|
|
130
|
+
|
|
131
|
+
A longer install line widens the window; a shorter one leaves its width as it was, since only its height follows
|
|
132
|
+
its content.
|
|
133
|
+
"""
|
|
134
|
+
self.detection = detection
|
|
135
|
+
self.text_label.setText(install_text_html(lines, CREDENTIAL_MANAGER_LINK_URLS))
|
|
136
|
+
|
|
137
|
+
def set_checking(self, checking: bool) -> None:
|
|
138
|
+
"""Disable Check again while a search runs, without moving the focus to another control, and clear the previous answer when one starts."""
|
|
139
|
+
set_button_enabled(self.check_again_button, not checking)
|
|
140
|
+
if checking:
|
|
141
|
+
self.result_label.clear()
|
|
142
|
+
self.result_label.hide()
|
|
143
|
+
|
|
144
|
+
def show_check_result(self, text: str) -> None:
|
|
145
|
+
"""Show in red, under the install text, why the new search still found no usable credential manager.
|
|
146
|
+
|
|
147
|
+
The window follows the answer both ways through the common growth rule: it grows to show the answer whole, and
|
|
148
|
+
takes back its height when the next search hides it.
|
|
149
|
+
"""
|
|
150
|
+
self.result_label.show_failure(text)
|
|
151
|
+
self.result_label.show()
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Edit scoped Git settings without losing a failed field or the captured repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Qt, Signal
|
|
9
|
+
from PySide6.QtWidgets import QCheckBox, QComboBox, QGridLayout, QGroupBox, QLabel, QLineEdit, QVBoxLayout, QWidget
|
|
10
|
+
from shiboken6 import isValid
|
|
11
|
+
|
|
12
|
+
from gitpane.config import SPACING_MD, SPACING_SM, control_height
|
|
13
|
+
from gitpane.git_config.git_setting import AUTOCRLF_DESCRIPTION_BY_VALUE, GIT_SETTING_KEYS, SETTING_LABEL_BY_KEY, GitSettingChange, GitSettingOperation, SettingView
|
|
14
|
+
from gitpane.text.typed_text import typed_value
|
|
15
|
+
from gitpane.transport.push_tracking import PushTrackingChoice
|
|
16
|
+
from gitpane.transport.remote_config import GitConfigScope
|
|
17
|
+
from gitpane.ui.setting.setting_block import GLOBAL_TITLE, REPOSITORY_TITLE, block_group
|
|
18
|
+
from gitpane.ui.widget.result_label import ResultLabel
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
from gitpane.git_config.git_setting import GitSettingService, GitSettingSnapshot, GitSettingValue
|
|
24
|
+
from gitpane.history.repository_value import RepositoryContext
|
|
25
|
+
|
|
26
|
+
NO_REPOSITORY_GIT_SETTING_TEXT = "Open a repository to change its Git settings."
|
|
27
|
+
# Separates the lines of a save result: the reason a tracking choice was not saved, and the result of each Git field saved with it.
|
|
28
|
+
RESULT_LINE_SEPARATOR = "\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitSettingText(Enum):
|
|
32
|
+
"""Texts of the Git page, one member per text so a misspelt name is an error."""
|
|
33
|
+
|
|
34
|
+
PUSH_TRACKING = "Tracking reference on first push"
|
|
35
|
+
"""The title of the list choosing whether a Push links a branch without upstream: ask, always or never."""
|
|
36
|
+
PUSH_TRACKING_NOTE = "GitPane keeps this choice for this repository; it is not written to the Git configuration."
|
|
37
|
+
"""The sentence under that list, saying where the choice is kept."""
|
|
38
|
+
PUSH_TRACKING_NOT_SAVED = "The tracking reference choice could not be saved: {cause}"
|
|
39
|
+
"""The status line when the preferences file refused the choice; the list keeps it as a draft."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GitSettingWidget(QWidget):
|
|
43
|
+
"""Keep one repository's draft separate from shared application preferences."""
|
|
44
|
+
|
|
45
|
+
draft_changed = Signal()
|
|
46
|
+
save_attention = Signal()
|
|
47
|
+
|
|
48
|
+
def __init__(self, service: GitSettingService, context: RepositoryContext | None, git_filename: str, parent: QWidget | None = None, *,
|
|
49
|
+
push_tracking_choice: PushTrackingChoice = PushTrackingChoice.ASK, save_push_tracking: Callable[[PushTrackingChoice], None] | None = None) -> None:
|
|
50
|
+
"""Build accessible global/local fields and begin a read without saving defaults.
|
|
51
|
+
|
|
52
|
+
With a repository and a ``save_push_tracking`` writer, This repository also offers the tracking choice of its
|
|
53
|
+
first Push, starting on ``push_tracking_choice``; Save hands a changed choice to that writer, which keeps it in the
|
|
54
|
+
preferences file and never in the Git configuration. Without a writer the list is not built.
|
|
55
|
+
"""
|
|
56
|
+
super().__init__(parent)
|
|
57
|
+
self.service = service
|
|
58
|
+
self.context = context
|
|
59
|
+
self.git_filename = git_filename
|
|
60
|
+
self.snapshot: GitSettingSnapshot | None = None
|
|
61
|
+
self.closed = False
|
|
62
|
+
self.saving = False
|
|
63
|
+
self.rendering = False
|
|
64
|
+
self.read_generation = 0
|
|
65
|
+
self.editor_by_target: dict[tuple[SettingView, str], QLineEdit | QComboBox] = {}
|
|
66
|
+
self.override_by_key: dict[str, QCheckBox] = {}
|
|
67
|
+
self.group_by_view: dict[SettingView, QGroupBox] = {}
|
|
68
|
+
self.label_by_target: dict[tuple[SettingView, str], QLabel] = {}
|
|
69
|
+
self.dirty_target_set: set[tuple[SettingView, str]] = set()
|
|
70
|
+
self.save_push_tracking = save_push_tracking
|
|
71
|
+
self.push_tracking_combo: QComboBox | None = None
|
|
72
|
+
self.push_tracking_baseline = push_tracking_choice
|
|
73
|
+
layout = QVBoxLayout(self)
|
|
74
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
75
|
+
layout.setSpacing(SPACING_MD)
|
|
76
|
+
self.status_label = ResultLabel("Reading Git settings…")
|
|
77
|
+
layout.addWidget(self._label("Commit identity is separate from remote authentication."))
|
|
78
|
+
views = (SettingView.GLOBAL,) if context is None else (SettingView.GLOBAL, SettingView.LOCAL)
|
|
79
|
+
for view in views:
|
|
80
|
+
title = GLOBAL_TITLE if view == SettingView.GLOBAL else REPOSITORY_TITLE
|
|
81
|
+
group = QGroupBox(title)
|
|
82
|
+
self.group_by_view[view] = group
|
|
83
|
+
group_layout = QVBoxLayout(group)
|
|
84
|
+
group_layout.setContentsMargins(SPACING_MD, SPACING_MD, SPACING_MD, SPACING_MD)
|
|
85
|
+
group_layout.setSpacing(SPACING_SM)
|
|
86
|
+
if view == SettingView.LOCAL:
|
|
87
|
+
group_layout.addWidget(self._label(context.repository_dir))
|
|
88
|
+
identity_override = QCheckBox("Override with local value for this repo only")
|
|
89
|
+
identity_override.setAccessibleName("Override commit name and email with local values for this repository only")
|
|
90
|
+
identity_keys: list[str] = []
|
|
91
|
+
for key in GIT_SETTING_KEYS:
|
|
92
|
+
if key.startswith("user."):
|
|
93
|
+
identity_keys.append(key)
|
|
94
|
+
for key in identity_keys:
|
|
95
|
+
self.override_by_key[key] = identity_override
|
|
96
|
+
identity_override.toggled.connect(lambda _checked, keys=tuple(identity_keys): self._override_changed(keys))
|
|
97
|
+
group_layout.addWidget(identity_override)
|
|
98
|
+
else:
|
|
99
|
+
group_layout.addWidget(self._label("Defaults for all repositories."))
|
|
100
|
+
field_layout = QGridLayout()
|
|
101
|
+
field_layout.setHorizontalSpacing(SPACING_MD)
|
|
102
|
+
field_layout.setVerticalSpacing(SPACING_SM)
|
|
103
|
+
label_column, editor_column = 0, 1
|
|
104
|
+
column_count = 2
|
|
105
|
+
field_layout.setColumnStretch(editor_column, 1)
|
|
106
|
+
group_layout.addLayout(field_layout)
|
|
107
|
+
for key in GIT_SETTING_KEYS:
|
|
108
|
+
target = (view, key)
|
|
109
|
+
if key == "core.autocrlf":
|
|
110
|
+
if view == SettingView.LOCAL:
|
|
111
|
+
line_ending_override = QCheckBox("Override line endings for this repo only")
|
|
112
|
+
self.override_by_key[key] = line_ending_override
|
|
113
|
+
line_ending_override.toggled.connect(lambda _checked, key=key: self._override_changed((key,)))
|
|
114
|
+
field_layout.addWidget(line_ending_override, field_layout.rowCount(), label_column, 1, column_count)
|
|
115
|
+
editor = QComboBox()
|
|
116
|
+
editor.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
|
117
|
+
editor.addItem("Not set / inherited", None)
|
|
118
|
+
for value, description in AUTOCRLF_DESCRIPTION_BY_VALUE.items():
|
|
119
|
+
editor.addItem(value + " — " + description, value)
|
|
120
|
+
editor.currentIndexChanged.connect(lambda _index, target=target: self._edited(target))
|
|
121
|
+
else:
|
|
122
|
+
editor = QLineEdit()
|
|
123
|
+
editor.textChanged.connect(lambda _text, target=target: self._edited(target))
|
|
124
|
+
editor.setAccessibleName(title + " " + SETTING_LABEL_BY_KEY[key])
|
|
125
|
+
label = self._label(SETTING_LABEL_BY_KEY[key])
|
|
126
|
+
label.setBuddy(editor)
|
|
127
|
+
row = field_layout.rowCount()
|
|
128
|
+
field_layout.addWidget(label, row, label_column)
|
|
129
|
+
field_layout.addWidget(editor, row, editor_column)
|
|
130
|
+
self.editor_by_target[target] = editor
|
|
131
|
+
detail = self._label("")
|
|
132
|
+
self.label_by_target[target] = detail
|
|
133
|
+
field_layout.addWidget(detail, field_layout.rowCount(), editor_column)
|
|
134
|
+
if view == SettingView.LOCAL and save_push_tracking is not None:
|
|
135
|
+
self._add_push_tracking_row(field_layout, label_column, editor_column)
|
|
136
|
+
layout.addWidget(group)
|
|
137
|
+
if context is None:
|
|
138
|
+
layout.addWidget(self._no_repository_group())
|
|
139
|
+
layout.addWidget(self._label("Line endings: .gitattributes still applies. Saving this setting does not rewrite existing files or the index."))
|
|
140
|
+
layout.addWidget(self.status_label)
|
|
141
|
+
self.setEnabled(False)
|
|
142
|
+
self.reload()
|
|
143
|
+
|
|
144
|
+
def _add_push_tracking_row(self, field_layout: QGridLayout, label_column: int, editor_column: int) -> None:
|
|
145
|
+
"""Offer Ask, Always add or Never add for this repository's first Push, below its Git fields, with where it is kept."""
|
|
146
|
+
combo = QComboBox()
|
|
147
|
+
combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
|
148
|
+
for choice in PushTrackingChoice:
|
|
149
|
+
combo.addItem(choice.label, choice)
|
|
150
|
+
combo.setCurrentIndex(combo.findData(self.push_tracking_baseline))
|
|
151
|
+
combo.setAccessibleName(REPOSITORY_TITLE + " " + GitSettingText.PUSH_TRACKING.value)
|
|
152
|
+
combo.currentIndexChanged.connect(lambda _index: self.draft_changed.emit())
|
|
153
|
+
label = self._label(GitSettingText.PUSH_TRACKING.value)
|
|
154
|
+
label.setBuddy(combo)
|
|
155
|
+
row = field_layout.rowCount()
|
|
156
|
+
field_layout.addWidget(label, row, label_column)
|
|
157
|
+
field_layout.addWidget(combo, row, editor_column)
|
|
158
|
+
field_layout.addWidget(self._label(GitSettingText.PUSH_TRACKING_NOTE.value), field_layout.rowCount(), editor_column)
|
|
159
|
+
self.push_tracking_combo = combo
|
|
160
|
+
|
|
161
|
+
def push_tracking_changed(self) -> bool:
|
|
162
|
+
"""Tell whether the tracking choice shown differs from the one kept for this repository."""
|
|
163
|
+
return self.push_tracking_combo is not None and self.push_tracking_combo.currentData() != self.push_tracking_baseline
|
|
164
|
+
|
|
165
|
+
def _save_push_tracking(self) -> str:
|
|
166
|
+
"""Hand a changed tracking choice to its writer and return why it was not saved, or "" when nothing failed.
|
|
167
|
+
|
|
168
|
+
A refused write keeps the choice in the list as a draft, so a second Save tries it again.
|
|
169
|
+
"""
|
|
170
|
+
if not self.push_tracking_changed():
|
|
171
|
+
return ""
|
|
172
|
+
choice = self.push_tracking_combo.currentData()
|
|
173
|
+
try:
|
|
174
|
+
self.save_push_tracking(choice)
|
|
175
|
+
except (OSError, ValueError, TypeError) as error:
|
|
176
|
+
return GitSettingText.PUSH_TRACKING_NOT_SAVED.value.format(cause=error)
|
|
177
|
+
self.push_tracking_baseline = choice
|
|
178
|
+
return ""
|
|
179
|
+
|
|
180
|
+
def _show_save_failure(self, tracking_failure: str, text: str) -> None:
|
|
181
|
+
"""Show a failed save on the status line, after the reason the tracking choice was not saved when there is one."""
|
|
182
|
+
lines = [tracking_failure] if tracking_failure else []
|
|
183
|
+
if text:
|
|
184
|
+
lines.append(text)
|
|
185
|
+
self.status_label.show_failure(RESULT_LINE_SEPARATOR.join(lines))
|
|
186
|
+
self.save_attention.emit()
|
|
187
|
+
|
|
188
|
+
def _no_repository_group(self) -> QGroupBox:
|
|
189
|
+
"""Keep the This repository block in place, greyed and explained, when no repository is open.
|
|
190
|
+
|
|
191
|
+
It holds no field, so nothing local is ever read or written without a repository.
|
|
192
|
+
"""
|
|
193
|
+
group, group_layout = block_group(REPOSITORY_TITLE)
|
|
194
|
+
self.group_by_view[SettingView.LOCAL] = group
|
|
195
|
+
group_layout.addWidget(self._label(NO_REPOSITORY_GIT_SETTING_TEXT))
|
|
196
|
+
group.setEnabled(False)
|
|
197
|
+
return group
|
|
198
|
+
|
|
199
|
+
def _label(self, text: str) -> QLabel:
|
|
200
|
+
"""Keep untrusted origin paths plain and selectable without widening the whole form."""
|
|
201
|
+
label = QLabel(text)
|
|
202
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
203
|
+
label.setWordWrap(True)
|
|
204
|
+
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)
|
|
205
|
+
return label
|
|
206
|
+
|
|
207
|
+
def reload(self) -> None:
|
|
208
|
+
"""Read asynchronously and ignore completion after the owning dialog closes."""
|
|
209
|
+
self.read_generation += 1
|
|
210
|
+
generation = self.read_generation
|
|
211
|
+
def complete(snapshot: GitSettingSnapshot | None, error: Exception | None) -> None:
|
|
212
|
+
"""Publish only into a live form and retain any existing failed draft."""
|
|
213
|
+
if self.closed or not isValid(self) or generation != self.read_generation:
|
|
214
|
+
return
|
|
215
|
+
if error is not None:
|
|
216
|
+
self.status_label.show_failure("Git settings could not be read. Check the Git executable and configuration.")
|
|
217
|
+
return
|
|
218
|
+
self.snapshot = snapshot
|
|
219
|
+
self._render()
|
|
220
|
+
self.setEnabled(True)
|
|
221
|
+
self.status_label.setText("")
|
|
222
|
+
self.read_sequence = self.service.read(self.context, self.git_filename, complete, lambda: not self.closed and isValid(self) and generation == self.read_generation)
|
|
223
|
+
|
|
224
|
+
def stop_read(self) -> None:
|
|
225
|
+
"""Abandon an opening read without interrupting an already requested save."""
|
|
226
|
+
self.closed = True
|
|
227
|
+
self.read_generation += 1
|
|
228
|
+
self.service.cancel_read(self.read_sequence)
|
|
229
|
+
|
|
230
|
+
def _render(self) -> None:
|
|
231
|
+
"""Refresh actual values and origins without replacing unsaved fields."""
|
|
232
|
+
if self.snapshot is None:
|
|
233
|
+
return
|
|
234
|
+
self.rendering = True
|
|
235
|
+
for override in set(self.override_by_key.values()):
|
|
236
|
+
keys: list[str] = []
|
|
237
|
+
for key, control in self.override_by_key.items():
|
|
238
|
+
if control is override:
|
|
239
|
+
keys.append(key)
|
|
240
|
+
if not any((SettingView.LOCAL, candidate) in self.dirty_target_set for candidate in keys):
|
|
241
|
+
override.setChecked(any(self.snapshot.value_by_target[(SettingView.DIRECT, candidate)].state != "absent" for candidate in keys))
|
|
242
|
+
for target, editor in self.editor_by_target.items():
|
|
243
|
+
view, key = target
|
|
244
|
+
value = self.snapshot.value_by_target[target]
|
|
245
|
+
effective = self.snapshot.value_by_target[(SettingView.EFFECTIVE, key)]
|
|
246
|
+
if target not in self.dirty_target_set:
|
|
247
|
+
shown = effective if view == SettingView.LOCAL and value.state == "absent" else value
|
|
248
|
+
if key == "core.autocrlf":
|
|
249
|
+
index = editor.findData(shown.value)
|
|
250
|
+
if index < 0:
|
|
251
|
+
editor.addItem("Existing invalid value: " + shown.value, shown.value)
|
|
252
|
+
index = editor.findData(shown.value)
|
|
253
|
+
editor.setCurrentIndex(index)
|
|
254
|
+
else:
|
|
255
|
+
editor.setText(shown.value or "")
|
|
256
|
+
editor.setEnabled(view != SettingView.LOCAL or self.override_by_key[key].isChecked())
|
|
257
|
+
label = self.label_by_target[target]
|
|
258
|
+
detail = f"Source: {value.origin or self._display(value)}\nEffective value: {self._display(effective)}"
|
|
259
|
+
if value.origin and value.state != "present":
|
|
260
|
+
detail = self._display(value) + "\n" + detail
|
|
261
|
+
if effective.origin and (effective.origin != value.origin or effective.scope != view.scope):
|
|
262
|
+
detail += "\nFrom " + effective.scope.value + ": " + effective.origin
|
|
263
|
+
label.setText(detail)
|
|
264
|
+
label.setAccessibleName(label.text())
|
|
265
|
+
self.rendering = False
|
|
266
|
+
self.refresh_metrics()
|
|
267
|
+
|
|
268
|
+
def _display(self, value: GitSettingValue) -> str:
|
|
269
|
+
"""Keep empty, absent and failed reads visibly distinct."""
|
|
270
|
+
if value.state == "absent":
|
|
271
|
+
return "Not set"
|
|
272
|
+
if value.state == "empty":
|
|
273
|
+
return "Empty value"
|
|
274
|
+
if value.state == "error":
|
|
275
|
+
return "Could not read this setting"
|
|
276
|
+
if value.state == "invalid":
|
|
277
|
+
return "Invalid value: " + value.value
|
|
278
|
+
return value.value
|
|
279
|
+
|
|
280
|
+
def _edited(self, target: tuple[SettingView, str]) -> None:
|
|
281
|
+
"""Track explicit intent without manufacturing edits during initial rendering."""
|
|
282
|
+
if self.rendering:
|
|
283
|
+
return
|
|
284
|
+
self.dirty_target_set.add(target)
|
|
285
|
+
view, key = target
|
|
286
|
+
if view == SettingView.LOCAL:
|
|
287
|
+
self.editor_by_target[target].setEnabled(self.override_by_key[key].isChecked())
|
|
288
|
+
self.draft_changed.emit()
|
|
289
|
+
|
|
290
|
+
def _override_changed(self, keys: tuple[str, ...]) -> None:
|
|
291
|
+
"""Apply an explicit scope choice while keeping untouched mixed identities intact."""
|
|
292
|
+
if self.rendering:
|
|
293
|
+
return
|
|
294
|
+
for key in keys:
|
|
295
|
+
self._edited((SettingView.LOCAL, key))
|
|
296
|
+
|
|
297
|
+
def changes(self) -> tuple[GitSettingChange, ...]:
|
|
298
|
+
"""Return only explicit edits that differ from the most recently read source.
|
|
299
|
+
|
|
300
|
+
A typed value loses the blanks around it first, so a name pasted with a space is written without it and a value
|
|
301
|
+
changed only by such blanks is no change at all.
|
|
302
|
+
"""
|
|
303
|
+
changes: list[GitSettingChange] = []
|
|
304
|
+
if self.snapshot is None:
|
|
305
|
+
return ()
|
|
306
|
+
for target, editor in self.editor_by_target.items():
|
|
307
|
+
if target not in self.dirty_target_set:
|
|
308
|
+
continue
|
|
309
|
+
view, key = target
|
|
310
|
+
direct = self.snapshot.value_by_target[(SettingView.DIRECT, key)] if view == SettingView.LOCAL else self.snapshot.value_by_target[target]
|
|
311
|
+
if view == SettingView.LOCAL and not self.override_by_key[key].isChecked():
|
|
312
|
+
if direct.state != "absent":
|
|
313
|
+
changes.append(GitSettingChange(key, view.scope, GitSettingOperation.UNSET))
|
|
314
|
+
else:
|
|
315
|
+
value = editor.currentData() if key == "core.autocrlf" else typed_value(editor.text())
|
|
316
|
+
if value is None:
|
|
317
|
+
if direct.state != "absent":
|
|
318
|
+
changes.append(GitSettingChange(key, view.scope, GitSettingOperation.UNSET))
|
|
319
|
+
elif value != direct.value:
|
|
320
|
+
changes.append(GitSettingChange(key, view.scope, GitSettingOperation.SET, value))
|
|
321
|
+
return tuple(changes)
|
|
322
|
+
|
|
323
|
+
def save(self, callback: Callable[[bool], None]) -> None:
|
|
324
|
+
"""Save fields independently and retain errors beside their actual reread values.
|
|
325
|
+
|
|
326
|
+
The tracking choice is written first, to the preferences file; its failure keeps it as a draft and is reported,
|
|
327
|
+
but the Git fields are still saved, and ``callback`` then receives False.
|
|
328
|
+
"""
|
|
329
|
+
tracking_failure = self._save_push_tracking()
|
|
330
|
+
changes = self.changes()
|
|
331
|
+
if not changes:
|
|
332
|
+
if tracking_failure:
|
|
333
|
+
self._show_save_failure(tracking_failure, "")
|
|
334
|
+
callback(not tracking_failure)
|
|
335
|
+
return
|
|
336
|
+
self.saving = True
|
|
337
|
+
self.setEnabled(False)
|
|
338
|
+
|
|
339
|
+
def complete(result: object, error: Exception | None) -> None:
|
|
340
|
+
"""Keep successful fields, preserve failed edits, and avoid touching a closed dialog."""
|
|
341
|
+
self.saving = False
|
|
342
|
+
if self.closed or not isValid(self):
|
|
343
|
+
return
|
|
344
|
+
self.setEnabled(True)
|
|
345
|
+
if error is not None:
|
|
346
|
+
self._show_save_failure(tracking_failure, "Git settings could not be saved. Your draft is retained.")
|
|
347
|
+
callback(False)
|
|
348
|
+
return
|
|
349
|
+
self.snapshot, results = result
|
|
350
|
+
messages: list[str] = []
|
|
351
|
+
complete_save = not tracking_failure
|
|
352
|
+
for outcome in results:
|
|
353
|
+
change = outcome.change
|
|
354
|
+
target = (SettingView.for_scope(change.scope), change.key)
|
|
355
|
+
if outcome.saved:
|
|
356
|
+
self.dirty_target_set.discard(target)
|
|
357
|
+
messages.append(change.scope.value.title() + " " + change.key + ": Saved")
|
|
358
|
+
effective = self.snapshot.value_by_target[(SettingView.EFFECTIVE, change.key)]
|
|
359
|
+
if effective.state == "error" or self.snapshot.value_by_target[target].state == "error":
|
|
360
|
+
messages.append("Saved, but the effective value could not be reread. Review the configuration before continuing.")
|
|
361
|
+
complete_save = False
|
|
362
|
+
if ((change.scope == GitConfigScope.LOCAL and change.operation == GitSettingOperation.UNSET and effective.scope in (GitConfigScope.LOCAL, GitConfigScope.WORKTREE))
|
|
363
|
+
or (change.operation == GitSettingOperation.SET and effective.value != change.value)):
|
|
364
|
+
messages.append("Another origin still overrides this setting. Review the effective value below.")
|
|
365
|
+
complete_save = False
|
|
366
|
+
else:
|
|
367
|
+
messages.append(change.scope.value.title() + " " + change.key + ": " + outcome.cause)
|
|
368
|
+
complete_save = False
|
|
369
|
+
self._render()
|
|
370
|
+
if complete_save:
|
|
371
|
+
self.status_label.show_success(RESULT_LINE_SEPARATOR.join(messages))
|
|
372
|
+
else:
|
|
373
|
+
self._show_save_failure(tracking_failure, RESULT_LINE_SEPARATOR.join(messages))
|
|
374
|
+
callback(complete_save)
|
|
375
|
+
try:
|
|
376
|
+
self.service.save(self.snapshot, changes, complete)
|
|
377
|
+
except ValueError as error:
|
|
378
|
+
self.saving = False
|
|
379
|
+
self.setEnabled(True)
|
|
380
|
+
self._show_save_failure(tracking_failure, str(error))
|
|
381
|
+
callback(False)
|
|
382
|
+
|
|
383
|
+
def refresh_metrics(self) -> None:
|
|
384
|
+
"""Follow the system font without a separate scaling preference."""
|
|
385
|
+
for editor in self.editor_by_target.values():
|
|
386
|
+
editor.setMinimumHeight(control_height(editor.fontMetrics().height()))
|
|
387
|
+
if self.push_tracking_combo is not None:
|
|
388
|
+
self.push_tracking_combo.setMinimumHeight(control_height(self.push_tracking_combo.fontMetrics().height()))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Frame the blocks every Settings page is built from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWidgets import QGroupBox, QVBoxLayout
|
|
6
|
+
|
|
7
|
+
from gitpane.config import SPACING_MD, SPACING_SM
|
|
8
|
+
|
|
9
|
+
GLOBAL_TITLE = "Global"
|
|
10
|
+
REPOSITORY_TITLE = "This repository"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def block_group(title: str) -> tuple[QGroupBox, QVBoxLayout]:
|
|
14
|
+
"""Build one framed block with the spacing used by every Settings block."""
|
|
15
|
+
group = QGroupBox(title)
|
|
16
|
+
group_layout = QVBoxLayout(group)
|
|
17
|
+
group_layout.setContentsMargins(SPACING_MD, SPACING_MD, SPACING_MD, SPACING_MD)
|
|
18
|
+
group_layout.setSpacing(SPACING_SM)
|
|
19
|
+
return group, group_layout
|