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,750 @@
|
|
|
1
|
+
"""Keep editable tool settings separate from accepted application preferences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QRect, Qt, Signal
|
|
10
|
+
from PySide6.QtGui import QFont
|
|
11
|
+
from PySide6.QtWidgets import QComboBox, QDialog, QFileDialog, QFrame, QGroupBox, QHBoxLayout, QLabel, QLayout, QLineEdit, QListWidget, QMessageBox, QPushButton, QScrollArea, QStackedWidget, QVBoxLayout, QWidget
|
|
12
|
+
from shiboken6 import isValid
|
|
13
|
+
|
|
14
|
+
from gitpane.config import LAYOUT_SPACING, SETTING_HEIGHT, SETTING_MIN_HEIGHT, SETTING_MIN_WIDTH, SETTING_WIDTH, SPACING_MD, SharedText, control_height, fit_window
|
|
15
|
+
from gitpane.system.path_identity import canonical_path
|
|
16
|
+
from gitpane.text.typed_path import session_typed_path, typed_path_value
|
|
17
|
+
from gitpane.text.typed_text import typed_value
|
|
18
|
+
from gitpane.tool.app_bundle import chosen_application_program
|
|
19
|
+
from gitpane.tool.editor_recipe import EDITOR_PLATFORM, EditorRecipe, chosen_program, editor_preset, offered_editor_presets, recipe_for_preset
|
|
20
|
+
from gitpane.tool.kdiff3 import tool_program_usable
|
|
21
|
+
from gitpane.tool.mergetool_recipe import MERGETOOL_DEFAULT_COMMAND
|
|
22
|
+
from gitpane.ui.setting.account_page import ACCOUNTS_PAGE_NAME
|
|
23
|
+
from gitpane.ui.setting.setting_block import block_group
|
|
24
|
+
from gitpane.ui.setting.utility_page import UtilityPageText
|
|
25
|
+
from gitpane.ui.widget.long_text import FRAME_SIDE_COUNT, largest_client_size
|
|
26
|
+
from gitpane.ui.widget.result_label import ResultLabel
|
|
27
|
+
from gitpane.ui.window_control.button_focus import set_button_enabled
|
|
28
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
|
|
33
|
+
from PySide6.QtGui import QShowEvent
|
|
34
|
+
|
|
35
|
+
from gitpane.account.ssh import SshConfiguration
|
|
36
|
+
from gitpane.operation.diagnostic import PreparedDiagnostic
|
|
37
|
+
from gitpane.tool.editor_recipe import EditorPreset
|
|
38
|
+
from gitpane.ui.setting.account_page import AccountPage
|
|
39
|
+
from gitpane.ui.setting.git_setting_widget import GitSettingWidget
|
|
40
|
+
from gitpane.ui.setting.ssh_widget import SshWidget
|
|
41
|
+
from gitpane.ui.setting.utility_page import UtilityPage
|
|
42
|
+
|
|
43
|
+
TOOLS_PAGE_NAME = "Tools"
|
|
44
|
+
GIT_PAGE_NAME = "Git"
|
|
45
|
+
SSH_PAGE_NAME = "SSH"
|
|
46
|
+
SETTING_PAGE_NAMES = (TOOLS_PAGE_NAME, GIT_PAGE_NAME, ACCOUNTS_PAGE_NAME, SSH_PAGE_NAME, UtilityPageText.PAGE_NAME.value)
|
|
47
|
+
# Narrower than the repository window's navigation: the page names are single short words, and the pages need the room.
|
|
48
|
+
SETTING_NAVIGATION_WIDTH = 140
|
|
49
|
+
NAVIGATION_TEXT_COLUMN = 0
|
|
50
|
+
NO_NAVIGATION_ROW = -1
|
|
51
|
+
UNSAVED_TITLE = "Unsaved changes"
|
|
52
|
+
UNSAVED_TEXT_PREFIX = "Unsaved changes in: "
|
|
53
|
+
UNSAVED_PLACE_SEPARATOR = ", "
|
|
54
|
+
DISCARD_AND_CLOSE_TEXT = "Discard and close"
|
|
55
|
+
KEEP_EDITING_TEXT = "Keep editing"
|
|
56
|
+
GIT_AVAILABLE_TEMPLATE = "Git available: {version}"
|
|
57
|
+
GIT_VERIFIED_TEMPLATE = "Git is working.\n{version}\n{git_filename}"
|
|
58
|
+
# Lines of the status and of the Verify Git box, joined when a warning about the accepted Git follows them.
|
|
59
|
+
STATUS_LINE_SEPARATOR = "\n"
|
|
60
|
+
VERIFIED_WARNING_SEPARATOR = "\n\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SettingDialogText(Enum):
|
|
64
|
+
"""Texts the Settings window shows, one member per text so a misspelt name is an error."""
|
|
65
|
+
|
|
66
|
+
BROWSE = "Browse"
|
|
67
|
+
SCOPE = "Tool settings apply to all repositories. Git values can be global or repository-specific."
|
|
68
|
+
GIT_EXECUTABLE = "Git executable"
|
|
69
|
+
"""The title of the Git group, the name of its path field and the title of the file chooser."""
|
|
70
|
+
GIT_DETECT = "Detect Git"
|
|
71
|
+
GIT_VERIFY = "Verify Git"
|
|
72
|
+
"""The button that checks the chosen Git, and the title of the window that answers."""
|
|
73
|
+
GIT_VERIFYING = "Verifying Git…"
|
|
74
|
+
SAVE = "Save settings"
|
|
75
|
+
CANCEL = "Cancel changes"
|
|
76
|
+
TEXT_EDITOR = "Text editor"
|
|
77
|
+
"""The title of the group choosing the program that Show in text editor starts."""
|
|
78
|
+
TEXT_EDITOR_PRESET = "Text editor preset"
|
|
79
|
+
TEXT_EDITOR_EXECUTABLE = "Text editor executable"
|
|
80
|
+
"""The name of the text editor's path field and the title of its file chooser."""
|
|
81
|
+
TEXT_EDITOR_BROWSE = "Browse for text editor executable"
|
|
82
|
+
TEXT_EDITOR_ARGUMENTS = "Text editor arguments"
|
|
83
|
+
TEXT_EDITOR_HELP = "Use {file} as a separate argument, or omit it to append the filename. Put arguments containing spaces in double quotes."
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ToolDraft:
|
|
88
|
+
"""What the Tools page would check and save, each typed value taken without the blanks around it.
|
|
89
|
+
|
|
90
|
+
The program paths also have ``~`` read as the home folder where that is the usage: Save writes these values, the full
|
|
91
|
+
path every reader of the preferences starts, never the ``~`` text.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
git_filename: str
|
|
95
|
+
difftool_filename: str
|
|
96
|
+
difftool_command: str
|
|
97
|
+
mergetool_filename: str
|
|
98
|
+
mergetool_command: str
|
|
99
|
+
editor_recipe: EditorRecipe
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SettingDialog(QDialog):
|
|
103
|
+
"""Edit shared Git and comparison settings while the settings controller handles validation."""
|
|
104
|
+
|
|
105
|
+
validation_requested = Signal(str, bool)
|
|
106
|
+
git_verify_requested = Signal(str)
|
|
107
|
+
detection_requested = Signal()
|
|
108
|
+
draft_changed = Signal()
|
|
109
|
+
|
|
110
|
+
def __init__(self, git_filename: str, parent: QWidget | None = None, *, difftool_filename: str = "", difftool_command: str = "", git_setting_widget: GitSettingWidget | None = None,
|
|
111
|
+
account_page: AccountPage | None = None, ssh_widget: SshWidget | None = None, utility_page: UtilityPage | None = None,
|
|
112
|
+
mergetool_filename: str = "", mergetool_command: str = MERGETOOL_DEFAULT_COMMAND, mergetool_suggested: bool = False, editor_recipe: EditorRecipe | None = None,
|
|
113
|
+
kdiff3_install_line: str = "", typed_path: Callable[[str], str] = session_typed_path) -> None:
|
|
114
|
+
"""Show one page per settings area beside a navigation list, keeping save controls outside the scrolling pages.
|
|
115
|
+
|
|
116
|
+
A non-empty ``kdiff3_install_line`` is shown under the diff or merge tool field while that field leads to no usable
|
|
117
|
+
program: the caller gives it only while no KDiff3 is found, and a tool of the user's own needs no KDiff3.
|
|
118
|
+
``typed_path`` reads ``~`` in the program paths where that is the usage; tests hand their own system and home
|
|
119
|
+
folder.
|
|
120
|
+
"""
|
|
121
|
+
super().__init__(parent)
|
|
122
|
+
self.typed_path = typed_path
|
|
123
|
+
self.kdiff3_install_line = kdiff3_install_line
|
|
124
|
+
self.setWindowTitle(SharedText.SETTING.value)
|
|
125
|
+
self.resize(SETTING_WIDTH, SETTING_HEIGHT)
|
|
126
|
+
# The first show widens the window, within the screen, when the Accounts page needs more than the default width.
|
|
127
|
+
self.starting_width_fitted = False
|
|
128
|
+
layout = QVBoxLayout(self)
|
|
129
|
+
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
130
|
+
layout.setSpacing(LAYOUT_SPACING)
|
|
131
|
+
layout.setContentsMargins(SPACING_MD, SPACING_MD, SPACING_MD, SPACING_MD)
|
|
132
|
+
tools_page = QWidget()
|
|
133
|
+
tools_layout = QVBoxLayout(tools_page)
|
|
134
|
+
tools_layout.setContentsMargins(0, 0, 0, 0)
|
|
135
|
+
tools_layout.setSpacing(SPACING_MD)
|
|
136
|
+
scope_label = QLabel(SettingDialogText.SCOPE.value)
|
|
137
|
+
scope_label.setWordWrap(True)
|
|
138
|
+
tools_layout.addWidget(scope_label)
|
|
139
|
+
tools_layout.addWidget(self._build_git_group(git_filename))
|
|
140
|
+
tools_layout.addWidget(self._build_difftool_group(difftool_filename, difftool_command, kdiff3_install_line))
|
|
141
|
+
tools_layout.addWidget(self._build_mergetool_group(mergetool_filename, mergetool_command, mergetool_suggested, kdiff3_install_line))
|
|
142
|
+
tools_layout.addWidget(self._build_editor_group(editor_recipe or EditorRecipe()))
|
|
143
|
+
tools_layout.addStretch()
|
|
144
|
+
self.git_setting_widget = git_setting_widget
|
|
145
|
+
self.ssh_widget = ssh_widget
|
|
146
|
+
self.utility_page = utility_page
|
|
147
|
+
# Accounts are saved by the buttons of their own blocks, never by the Save button below, so the account list does
|
|
148
|
+
# not invalidate the Git check that Save relies on.
|
|
149
|
+
self.account_page = account_page
|
|
150
|
+
self.navigation_list = QListWidget()
|
|
151
|
+
self.navigation_list.setAccessibleName("Settings pages")
|
|
152
|
+
self.page_stack = QStackedWidget()
|
|
153
|
+
self.scroll_area_by_page: dict[str, QScrollArea] = {}
|
|
154
|
+
page_by_name: dict[str, QWidget | None] = {TOOLS_PAGE_NAME: tools_page, GIT_PAGE_NAME: git_setting_widget, ACCOUNTS_PAGE_NAME: account_page, SSH_PAGE_NAME: ssh_widget,
|
|
155
|
+
UtilityPageText.PAGE_NAME.value: utility_page}
|
|
156
|
+
for page_name in SETTING_PAGE_NAMES:
|
|
157
|
+
self.navigation_list.addItem(page_name)
|
|
158
|
+
self._add_scrolling_page(page_name, page_by_name[page_name])
|
|
159
|
+
if git_setting_widget is not None:
|
|
160
|
+
git_setting_widget.draft_changed.connect(self._draft_changed)
|
|
161
|
+
git_setting_widget.save_attention.connect(self.show_git_result)
|
|
162
|
+
self.scroll_area_by_page[GIT_PAGE_NAME].verticalScrollBar().rangeChanged.connect(self._reveal_git_result)
|
|
163
|
+
if ssh_widget is not None:
|
|
164
|
+
ssh_widget.draft_changed.connect(self._draft_changed)
|
|
165
|
+
self.body_layout = QHBoxLayout()
|
|
166
|
+
self.body_layout.setSpacing(SPACING_MD)
|
|
167
|
+
self.body_layout.addWidget(self.navigation_list)
|
|
168
|
+
self.body_layout.addWidget(self.page_stack, 1)
|
|
169
|
+
layout.addLayout(self.body_layout, 1)
|
|
170
|
+
self.status_label = ResultLabel()
|
|
171
|
+
layout.addWidget(self.status_label)
|
|
172
|
+
action_layout = QHBoxLayout()
|
|
173
|
+
action_layout.addStretch()
|
|
174
|
+
self.save_button = QPushButton(SettingDialogText.SAVE.value)
|
|
175
|
+
self.save_button.setDefault(True)
|
|
176
|
+
self.save_button.clicked.connect(lambda: self.validation_requested.emit(self.typed_git_filename(), True))
|
|
177
|
+
self.cancel_button = QPushButton(SettingDialogText.CANCEL.value)
|
|
178
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
179
|
+
action_layout.addWidget(self.cancel_button)
|
|
180
|
+
action_layout.addWidget(self.save_button)
|
|
181
|
+
layout.addLayout(action_layout)
|
|
182
|
+
QWidget.setTabOrder(self.cancel_button, self.save_button)
|
|
183
|
+
self.navigation_list.currentRowChanged.connect(self._navigation_row_changed)
|
|
184
|
+
self.show_page(TOOLS_PAGE_NAME)
|
|
185
|
+
self.path_edit.textChanged.connect(self._draft_changed)
|
|
186
|
+
self.difftool_path_edit.textChanged.connect(self._draft_changed)
|
|
187
|
+
self.difftool_command_edit.textChanged.connect(self._draft_changed)
|
|
188
|
+
self.mergetool_path_edit.textChanged.connect(self._draft_changed)
|
|
189
|
+
self.mergetool_command_edit.textChanged.connect(self._draft_changed)
|
|
190
|
+
for path_edit, install_label in ((self.difftool_path_edit, self.difftool_install_label), (self.mergetool_path_edit, self.mergetool_install_label)):
|
|
191
|
+
self._update_install_label(path_edit, install_label)
|
|
192
|
+
path_edit.textChanged.connect(lambda _text, path_edit=path_edit, install_label=install_label: self._update_install_label(path_edit, install_label))
|
|
193
|
+
self.editor_path_edit.textChanged.connect(self._draft_changed)
|
|
194
|
+
self.editor_argument_edit.textChanged.connect(self._draft_changed)
|
|
195
|
+
self.editor_preset_combo.currentIndexChanged.connect(self._draft_changed)
|
|
196
|
+
self.refresh_metrics()
|
|
197
|
+
self.path_edit.setFocus()
|
|
198
|
+
self.path_edit.selectAll()
|
|
199
|
+
self.close_confirmation: QMessageBox | None = None
|
|
200
|
+
self.git_verified_box: QMessageBox | None = None
|
|
201
|
+
self.tool_baseline = self.tool_draft()
|
|
202
|
+
self.ssh_baseline = self._ssh_values()
|
|
203
|
+
|
|
204
|
+
def tool_draft(self) -> ToolDraft:
|
|
205
|
+
"""Capture what the Tools page would check and save, read once for the check, the save and the unsaved-changes question.
|
|
206
|
+
|
|
207
|
+
Every typed value loses the blanks around it, so a path pasted with a space still names the file, and a value
|
|
208
|
+
changed only by such blanks does not count as a change when Settings closes. The program paths also have ``~``
|
|
209
|
+
read as the home folder where that is the usage, so Save writes the full path.
|
|
210
|
+
"""
|
|
211
|
+
return ToolDraft(self.typed_git_filename(), typed_path_value(self.difftool_path_edit.text(), self.typed_path), typed_value(self.difftool_command_edit.text()),
|
|
212
|
+
typed_path_value(self.mergetool_path_edit.text(), self.typed_path), typed_value(self.mergetool_command_edit.text()), self.editor_recipe())
|
|
213
|
+
|
|
214
|
+
def typed_git_filename(self) -> str:
|
|
215
|
+
"""Return the Git path as typed, without the blanks around it and with ``~`` read as the home folder: the one value both checked and saved.
|
|
216
|
+
|
|
217
|
+
Verify, Save and every later check read it here, so a path verified once is the very path Save compares and
|
|
218
|
+
writes, even when it was pasted with a space around it. ``~`` is read where that is the usage, so the path saved
|
|
219
|
+
is the full path, the one the Verify Git box showed.
|
|
220
|
+
"""
|
|
221
|
+
return typed_path_value(self.path_edit.text(), self.typed_path)
|
|
222
|
+
|
|
223
|
+
def _ssh_values(self) -> SshConfiguration | None:
|
|
224
|
+
"""Capture what the SSH page would save; a dialog built without that page has nothing to compare."""
|
|
225
|
+
if self.ssh_widget is None:
|
|
226
|
+
return None
|
|
227
|
+
return self.ssh_widget.configuration()
|
|
228
|
+
|
|
229
|
+
def unsaved_place_names(self) -> list[str]:
|
|
230
|
+
"""Name the places holding unsaved work, in navigation order, so closing can say what would be lost."""
|
|
231
|
+
place_names = []
|
|
232
|
+
if self.tool_draft() != self.tool_baseline:
|
|
233
|
+
place_names.append(TOOLS_PAGE_NAME)
|
|
234
|
+
if self.git_setting_widget is not None and (self.git_setting_widget.dirty_target_set or self.git_setting_widget.push_tracking_changed()):
|
|
235
|
+
place_names.append(GIT_PAGE_NAME)
|
|
236
|
+
if self.account_page is not None:
|
|
237
|
+
place_names.extend(self.account_page.unsaved_place_names())
|
|
238
|
+
if self._ssh_values() != self.ssh_baseline:
|
|
239
|
+
place_names.append(SSH_PAGE_NAME)
|
|
240
|
+
return place_names
|
|
241
|
+
|
|
242
|
+
def mark_saved(self) -> None:
|
|
243
|
+
"""Take the current Tools and SSH values as saved after a bottom Save that left Settings open, so a later close does not list them."""
|
|
244
|
+
self.tool_baseline = self.tool_draft()
|
|
245
|
+
self.ssh_baseline = self._ssh_values()
|
|
246
|
+
|
|
247
|
+
def reject(self) -> None:
|
|
248
|
+
"""Close at once when nothing would be lost; otherwise ask first without blocking the application.
|
|
249
|
+
|
|
250
|
+
The title bar close button, Escape and Cancel changes all end up here. Keep editing is the default and the answer
|
|
251
|
+
of Escape, and there is deliberately no way to save everything from the question.
|
|
252
|
+
"""
|
|
253
|
+
if self.close_confirmation is not None and isValid(self.close_confirmation):
|
|
254
|
+
self.close_confirmation.raise_()
|
|
255
|
+
self.close_confirmation.activateWindow()
|
|
256
|
+
return
|
|
257
|
+
place_names = self.unsaved_place_names()
|
|
258
|
+
if not place_names:
|
|
259
|
+
QDialog.reject(self)
|
|
260
|
+
return
|
|
261
|
+
box = QMessageBox(QMessageBox.Icon.Warning, UNSAVED_TITLE, UNSAVED_TEXT_PREFIX + UNSAVED_PLACE_SEPARATOR.join(place_names), parent=self)
|
|
262
|
+
prepare_message_box(box, UNSAVED_TITLE)
|
|
263
|
+
box.setTextFormat(Qt.TextFormat.PlainText)
|
|
264
|
+
discard_button = box.addButton(DISCARD_AND_CLOSE_TEXT, QMessageBox.ButtonRole.DestructiveRole)
|
|
265
|
+
keep_button = box.addButton(KEEP_EDITING_TEXT, QMessageBox.ButtonRole.RejectRole)
|
|
266
|
+
box.setDefaultButton(keep_button)
|
|
267
|
+
box.setEscapeButton(keep_button)
|
|
268
|
+
self.close_confirmation = box
|
|
269
|
+
|
|
270
|
+
def finished() -> None:
|
|
271
|
+
"""Close Settings only on an explicit Discard and close; any other ending keeps the drafts."""
|
|
272
|
+
self.close_confirmation = None
|
|
273
|
+
discarded = box.clickedButton() is discard_button
|
|
274
|
+
box.deleteLater()
|
|
275
|
+
if discarded:
|
|
276
|
+
self.discard_and_close()
|
|
277
|
+
|
|
278
|
+
box.finished.connect(finished)
|
|
279
|
+
open_window_modal(box)
|
|
280
|
+
|
|
281
|
+
def discard_and_close(self) -> None:
|
|
282
|
+
"""Close without saving or asking: used by Discard and close, by the application shutdown, and to clean up tests.
|
|
283
|
+
|
|
284
|
+
Closing emits finished, so whoever owns Settings still cancels a sign-in in progress.
|
|
285
|
+
"""
|
|
286
|
+
box = self.close_confirmation
|
|
287
|
+
self.close_confirmation = None
|
|
288
|
+
if box is not None and isValid(box):
|
|
289
|
+
box.reject()
|
|
290
|
+
self._close_git_verified_box()
|
|
291
|
+
QDialog.reject(self)
|
|
292
|
+
|
|
293
|
+
def show_success_with_warning(self, text: str, warning: str = "") -> None:
|
|
294
|
+
"""Show a result that worked in bold green, or in bold orange followed by the warning that still needs the user's attention."""
|
|
295
|
+
if not warning:
|
|
296
|
+
self.status_label.show_success(text)
|
|
297
|
+
return
|
|
298
|
+
self.status_label.show_warning(text + STATUS_LINE_SEPARATOR + warning)
|
|
299
|
+
|
|
300
|
+
def show_git_available(self, version: str, warning: str = "") -> None:
|
|
301
|
+
"""Show the version of an accepted Git as ordinary text, or in bold orange followed by the warning that comes with it."""
|
|
302
|
+
status_text = GIT_AVAILABLE_TEMPLATE.format(version=version)
|
|
303
|
+
if not warning:
|
|
304
|
+
self.status_label.setText(status_text)
|
|
305
|
+
return
|
|
306
|
+
self.status_label.show_warning(status_text + STATUS_LINE_SEPARATOR + warning)
|
|
307
|
+
|
|
308
|
+
def show_git_verified(self, version: str, git_filename: str, warning: str = "") -> None:
|
|
309
|
+
"""Show a successful Verify Git in bold green and confirm it in a box naming the version and the checked path.
|
|
310
|
+
|
|
311
|
+
The check takes a few milliseconds and used to rewrite the same ordinary line Settings opens with, so the click
|
|
312
|
+
looked like it did nothing. The box is modal to Settings only and never runs a blocking loop of its own; the
|
|
313
|
+
version comes from Git and the path from the user, so both are shown as plain text. Closing the box gives the
|
|
314
|
+
focus back to Verify Git. A ``warning`` about the accepted Git turns the line orange and ends the box, which then
|
|
315
|
+
carries the warning icon.
|
|
316
|
+
"""
|
|
317
|
+
self.show_success_with_warning(GIT_AVAILABLE_TEMPLATE.format(version=version), warning)
|
|
318
|
+
box_text = GIT_VERIFIED_TEMPLATE.format(version=version, git_filename=git_filename)
|
|
319
|
+
icon = QMessageBox.Icon.Information
|
|
320
|
+
if warning:
|
|
321
|
+
box_text += VERIFIED_WARNING_SEPARATOR + warning
|
|
322
|
+
icon = QMessageBox.Icon.Warning
|
|
323
|
+
self._close_git_verified_box()
|
|
324
|
+
box = QMessageBox(icon, SettingDialogText.GIT_VERIFY.value, box_text, QMessageBox.StandardButton.Ok, self)
|
|
325
|
+
prepare_message_box(box, SettingDialogText.GIT_VERIFY.value)
|
|
326
|
+
box.setTextFormat(Qt.TextFormat.PlainText)
|
|
327
|
+
self.git_verified_box = box
|
|
328
|
+
|
|
329
|
+
def finished() -> None:
|
|
330
|
+
"""Forget the closed box and put the focus back on the button that asked for the check."""
|
|
331
|
+
if self.git_verified_box is box:
|
|
332
|
+
self.git_verified_box = None
|
|
333
|
+
box.deleteLater()
|
|
334
|
+
button = self.verify_button
|
|
335
|
+
if isValid(button) and button.isVisible() and button.isEnabled():
|
|
336
|
+
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
|
337
|
+
|
|
338
|
+
box.finished.connect(finished)
|
|
339
|
+
open_window_modal(box)
|
|
340
|
+
|
|
341
|
+
def _close_git_verified_box(self) -> None:
|
|
342
|
+
"""Close the box confirming a successful Verify Git, if one is still open."""
|
|
343
|
+
box = self.git_verified_box
|
|
344
|
+
self.git_verified_box = None
|
|
345
|
+
if box is not None and isValid(box):
|
|
346
|
+
box.reject()
|
|
347
|
+
|
|
348
|
+
def _add_scrolling_page(self, page_name: str, page: QWidget | None) -> None:
|
|
349
|
+
"""Give a page its own scroll area so a long page never pushes the navigation or the save controls away; a missing page stays empty."""
|
|
350
|
+
scroll_area = QScrollArea()
|
|
351
|
+
scroll_area.setWidgetResizable(True)
|
|
352
|
+
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
|
353
|
+
scroll_area.setWidget(page if page is not None else QWidget())
|
|
354
|
+
self.scroll_area_by_page[page_name] = scroll_area
|
|
355
|
+
self.page_stack.addWidget(scroll_area)
|
|
356
|
+
|
|
357
|
+
def _build_git_group(self, git_filename: str) -> QGroupBox:
|
|
358
|
+
"""Frame the Git executable path with its browse, detect and verify actions."""
|
|
359
|
+
self.git_group, group_layout = block_group(SettingDialogText.GIT_EXECUTABLE.value)
|
|
360
|
+
self.path_edit = QLineEdit(git_filename)
|
|
361
|
+
self.path_edit.setAccessibleName(SettingDialogText.GIT_EXECUTABLE.value)
|
|
362
|
+
label = QLabel("Executable")
|
|
363
|
+
label.setBuddy(self.path_edit)
|
|
364
|
+
group_layout.addWidget(label)
|
|
365
|
+
self.browse_button = QPushButton(SettingDialogText.BROWSE.value)
|
|
366
|
+
self.browse_button.setAccessibleName("Browse for Git executable")
|
|
367
|
+
self.browse_button.clicked.connect(self._browse)
|
|
368
|
+
git_path_layout = QHBoxLayout()
|
|
369
|
+
git_path_layout.addWidget(self.path_edit, 1)
|
|
370
|
+
git_path_layout.addWidget(self.browse_button)
|
|
371
|
+
group_layout.addLayout(git_path_layout)
|
|
372
|
+
self.detect_button = QPushButton(SettingDialogText.GIT_DETECT.value)
|
|
373
|
+
self.detect_button.clicked.connect(self.detection_requested)
|
|
374
|
+
self.verify_button = QPushButton(SettingDialogText.GIT_VERIFY.value)
|
|
375
|
+
self.verify_button.clicked.connect(lambda: self.git_verify_requested.emit(self.typed_git_filename()))
|
|
376
|
+
git_action_layout = QHBoxLayout()
|
|
377
|
+
git_action_layout.addWidget(self.detect_button)
|
|
378
|
+
git_action_layout.addWidget(self.verify_button)
|
|
379
|
+
git_action_layout.addStretch()
|
|
380
|
+
group_layout.addLayout(git_action_layout)
|
|
381
|
+
return self.git_group
|
|
382
|
+
|
|
383
|
+
def _build_install_label(self, install_line: str) -> QLabel:
|
|
384
|
+
"""Say under a tool field how to install KDiff3, as selectable text so its command can be copied; shown by _update_install_label.
|
|
385
|
+
|
|
386
|
+
The line is bold so the command is seen under the field. Only the weight is set: the colour stays the text colour
|
|
387
|
+
of the palette, so it follows the light or dark appearance, and the family and size still follow the fonts of the
|
|
388
|
+
application.
|
|
389
|
+
"""
|
|
390
|
+
label = QLabel(install_line)
|
|
391
|
+
# A font with only its weight set: every other attribute is still inherited from the application and the window.
|
|
392
|
+
weight_only_font = QFont()
|
|
393
|
+
weight_only_font.setBold(True)
|
|
394
|
+
label.setFont(weight_only_font)
|
|
395
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
396
|
+
label.setWordWrap(True)
|
|
397
|
+
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
398
|
+
label.setVisible(False)
|
|
399
|
+
return label
|
|
400
|
+
|
|
401
|
+
def _update_install_label(self, path_edit: QLineEdit, install_label: QLabel) -> None:
|
|
402
|
+
"""Show how to install KDiff3 under a tool field only while no KDiff3 is found and the field leads to no usable program, as typed or browsed.
|
|
403
|
+
|
|
404
|
+
The line follows the path the action would use, ``~`` read as the home folder where that is the usage.
|
|
405
|
+
"""
|
|
406
|
+
install_label.setVisible(bool(self.kdiff3_install_line) and not tool_program_usable(typed_path_value(path_edit.text(), self.typed_path)))
|
|
407
|
+
|
|
408
|
+
def _build_difftool_group(self, difftool_filename: str, difftool_command: str, install_line: str) -> QGroupBox:
|
|
409
|
+
"""Frame the external comparison executable, how to install KDiff3 when none is found, and its argument template."""
|
|
410
|
+
self.difftool_group, group_layout = block_group("External diff tool")
|
|
411
|
+
self.difftool_path_edit = QLineEdit(difftool_filename)
|
|
412
|
+
self.difftool_path_edit.setAccessibleName("External diff tool executable")
|
|
413
|
+
executable_label = QLabel("Executable")
|
|
414
|
+
executable_label.setBuddy(self.difftool_path_edit)
|
|
415
|
+
group_layout.addWidget(executable_label)
|
|
416
|
+
self.difftool_browse_button = QPushButton("Browse…")
|
|
417
|
+
self.difftool_browse_button.setAccessibleName("Browse for external diff tool executable")
|
|
418
|
+
self.difftool_browse_button.clicked.connect(self._browse_difftool)
|
|
419
|
+
difftool_path_layout = QHBoxLayout()
|
|
420
|
+
difftool_path_layout.addWidget(self.difftool_path_edit, 1)
|
|
421
|
+
difftool_path_layout.addWidget(self.difftool_browse_button)
|
|
422
|
+
group_layout.addLayout(difftool_path_layout)
|
|
423
|
+
self.difftool_install_label = self._build_install_label(install_line)
|
|
424
|
+
group_layout.addWidget(self.difftool_install_label)
|
|
425
|
+
self.difftool_command_edit = QLineEdit(difftool_command)
|
|
426
|
+
self.difftool_command_edit.setAccessibleName("External diff tool arguments")
|
|
427
|
+
argument_label = QLabel("Arguments (advanced)")
|
|
428
|
+
argument_label.setBuddy(self.difftool_command_edit)
|
|
429
|
+
group_layout.addWidget(argument_label)
|
|
430
|
+
group_layout.addWidget(self.difftool_command_edit)
|
|
431
|
+
help_label = QLabel('The default arguments are ready to use. Edit only if your tool needs extra options or a different file order. '
|
|
432
|
+
'$LOCAL and $REMOTE are replaced with the two files to compare. Put arguments containing spaces in double quotes.')
|
|
433
|
+
help_label.setWordWrap(True)
|
|
434
|
+
self.difftool_command_edit.setToolTip(help_label.text() + " If your tool starts a separate process, add its option to wait until the comparison closes.")
|
|
435
|
+
group_layout.addWidget(help_label)
|
|
436
|
+
return self.difftool_group
|
|
437
|
+
|
|
438
|
+
def _build_mergetool_group(self, mergetool_filename: str, mergetool_command: str, mergetool_suggested: bool, install_line: str) -> QGroupBox:
|
|
439
|
+
"""Frame the merge executable, how to install KDiff3 when none is found, its not-yet-saved notice and its argument template."""
|
|
440
|
+
self.mergetool_group, group_layout = block_group("External merge tool")
|
|
441
|
+
self.mergetool_path_edit = QLineEdit(mergetool_filename)
|
|
442
|
+
self.mergetool_path_edit.setAccessibleName("External merge tool executable")
|
|
443
|
+
merge_executable_label = QLabel("Executable")
|
|
444
|
+
merge_executable_label.setBuddy(self.mergetool_path_edit)
|
|
445
|
+
group_layout.addWidget(merge_executable_label)
|
|
446
|
+
self.mergetool_browse_button = QPushButton("Browse…")
|
|
447
|
+
self.mergetool_browse_button.setAccessibleName("Browse for external merge tool executable")
|
|
448
|
+
self.mergetool_browse_button.clicked.connect(self._browse_mergetool)
|
|
449
|
+
mergetool_path_layout = QHBoxLayout()
|
|
450
|
+
mergetool_path_layout.addWidget(self.mergetool_path_edit, 1)
|
|
451
|
+
mergetool_path_layout.addWidget(self.mergetool_browse_button)
|
|
452
|
+
group_layout.addLayout(mergetool_path_layout)
|
|
453
|
+
self.mergetool_install_label = self._build_install_label(install_line)
|
|
454
|
+
group_layout.addWidget(self.mergetool_install_label)
|
|
455
|
+
self.mergetool_save_notice_label = QLabel("This merge tool is not saved yet. Click Save to use it for conflict resolution.")
|
|
456
|
+
self.mergetool_save_notice_label.setWordWrap(True)
|
|
457
|
+
self.mergetool_save_notice_label.setVisible(mergetool_suggested)
|
|
458
|
+
group_layout.addWidget(self.mergetool_save_notice_label)
|
|
459
|
+
self.mergetool_command_edit = QLineEdit(mergetool_command)
|
|
460
|
+
self.mergetool_command_edit.setAccessibleName("External merge tool arguments")
|
|
461
|
+
merge_argument_label = QLabel("Arguments (advanced)")
|
|
462
|
+
merge_argument_label.setBuddy(self.mergetool_command_edit)
|
|
463
|
+
group_layout.addWidget(merge_argument_label)
|
|
464
|
+
group_layout.addWidget(self.mergetool_command_edit)
|
|
465
|
+
merge_help_label = QLabel('The default arguments are ready for KDiff3. $BASE, $LOCAL and $REMOTE are the available conflict versions; '
|
|
466
|
+
'$MERGED is the output file. Keep all four parameters. Put arguments containing spaces in double quotes.')
|
|
467
|
+
merge_help_label.setWordWrap(True)
|
|
468
|
+
self.mergetool_command_edit.setToolTip(merge_help_label.text())
|
|
469
|
+
group_layout.addWidget(merge_help_label)
|
|
470
|
+
return self.mergetool_group
|
|
471
|
+
|
|
472
|
+
def _build_editor_group(self, recipe: EditorRecipe) -> QGroupBox:
|
|
473
|
+
"""Frame the text editor that Show in text editor starts: its preset, executable and arguments."""
|
|
474
|
+
self.editor_group, form = block_group(SettingDialogText.TEXT_EDITOR.value)
|
|
475
|
+
self.editor_preset_combo = QComboBox()
|
|
476
|
+
self.editor_preset_combo.setAccessibleName(SettingDialogText.TEXT_EDITOR_PRESET.value)
|
|
477
|
+
for preset in self._offered_editor_presets():
|
|
478
|
+
self.editor_preset_combo.addItem(preset.label, preset.preset_id)
|
|
479
|
+
if self.editor_preset_combo.findData(recipe.preset_id) < 0:
|
|
480
|
+
self.editor_preset_combo.addItem("Saved editor", recipe.preset_id)
|
|
481
|
+
self.editor_preset_combo.setCurrentIndex(self.editor_preset_combo.findData(recipe.preset_id))
|
|
482
|
+
label = QLabel("Preset")
|
|
483
|
+
label.setBuddy(self.editor_preset_combo)
|
|
484
|
+
form.addWidget(label)
|
|
485
|
+
form.addWidget(self.editor_preset_combo)
|
|
486
|
+
self.editor_path_edit = QLineEdit(recipe.program_filename)
|
|
487
|
+
self.editor_path_edit.setAccessibleName(SettingDialogText.TEXT_EDITOR_EXECUTABLE.value)
|
|
488
|
+
label = QLabel("Executable")
|
|
489
|
+
label.setBuddy(self.editor_path_edit)
|
|
490
|
+
form.addWidget(label)
|
|
491
|
+
self.editor_browse_button = QPushButton("Browse…")
|
|
492
|
+
self.editor_browse_button.setAccessibleName(SettingDialogText.TEXT_EDITOR_BROWSE.value)
|
|
493
|
+
self.editor_browse_button.clicked.connect(self._browse_editor)
|
|
494
|
+
row = QHBoxLayout()
|
|
495
|
+
row.addWidget(self.editor_path_edit, 1)
|
|
496
|
+
row.addWidget(self.editor_browse_button)
|
|
497
|
+
form.addLayout(row)
|
|
498
|
+
self.editor_argument_edit = QLineEdit(recipe.argument_template)
|
|
499
|
+
self.editor_argument_edit.setAccessibleName(SettingDialogText.TEXT_EDITOR_ARGUMENTS.value)
|
|
500
|
+
label = QLabel("Arguments")
|
|
501
|
+
label.setBuddy(self.editor_argument_edit)
|
|
502
|
+
form.addWidget(label)
|
|
503
|
+
form.addWidget(self.editor_argument_edit)
|
|
504
|
+
self.editor_help_label = QLabel(SettingDialogText.TEXT_EDITOR_HELP.value)
|
|
505
|
+
self.editor_help_label.setWordWrap(True)
|
|
506
|
+
form.addWidget(self.editor_help_label)
|
|
507
|
+
self.editor_status_label = QLabel()
|
|
508
|
+
self.editor_status_label.setTextFormat(Qt.TextFormat.PlainText)
|
|
509
|
+
self.editor_status_label.setWordWrap(True)
|
|
510
|
+
form.addWidget(self.editor_status_label)
|
|
511
|
+
self.editor_settings_button = QPushButton("Open Settings")
|
|
512
|
+
self.editor_settings_button.clicked.connect(self.show_editor_setting)
|
|
513
|
+
form.addWidget(self.editor_settings_button)
|
|
514
|
+
self.editor_preset_combo.activated.connect(self._select_editor_preset)
|
|
515
|
+
self._show_editor_status(recipe)
|
|
516
|
+
return self.editor_group
|
|
517
|
+
|
|
518
|
+
def _navigation_row_changed(self, row: int) -> None:
|
|
519
|
+
"""Follow a click or keyboard move in the navigation list; a cleared selection keeps the current page."""
|
|
520
|
+
if row == NO_NAVIGATION_ROW:
|
|
521
|
+
return
|
|
522
|
+
self.show_page(SETTING_PAGE_NAMES[row])
|
|
523
|
+
|
|
524
|
+
def show_page(self, name: str) -> None:
|
|
525
|
+
"""Show one page and hide the bottom Save and Cancel on Accounts, whose blocks have their own save buttons.
|
|
526
|
+
|
|
527
|
+
The bottom Save never records an account choice, so offering it there would suggest it does. The Utilities page
|
|
528
|
+
saves nothing itself, so Save and Cancel stay there and still apply to the drafts of the other pages.
|
|
529
|
+
"""
|
|
530
|
+
self.navigation_list.setCurrentRow(SETTING_PAGE_NAMES.index(name))
|
|
531
|
+
self.page_stack.setCurrentWidget(self.scroll_area_by_page[name])
|
|
532
|
+
bottom_action_visible = name != ACCOUNTS_PAGE_NAME
|
|
533
|
+
self.save_button.setVisible(bottom_action_visible)
|
|
534
|
+
self.cancel_button.setVisible(bottom_action_visible)
|
|
535
|
+
|
|
536
|
+
def current_page_name(self) -> str:
|
|
537
|
+
"""Name the page currently shown on the right of the navigation list."""
|
|
538
|
+
current_scroll_area = self.page_stack.currentWidget()
|
|
539
|
+
for page_name, scroll_area in self.scroll_area_by_page.items():
|
|
540
|
+
if scroll_area is current_scroll_area:
|
|
541
|
+
return page_name
|
|
542
|
+
return TOOLS_PAGE_NAME
|
|
543
|
+
|
|
544
|
+
def reveal_widget(self, page_name: str, widget: QWidget) -> None:
|
|
545
|
+
"""Show the page holding a widget and scroll that page until the widget is in view."""
|
|
546
|
+
self.show_page(page_name)
|
|
547
|
+
scroll_area = self.scroll_area_by_page[page_name]
|
|
548
|
+
page_layout = scroll_area.widget().layout()
|
|
549
|
+
if page_layout is not None:
|
|
550
|
+
page_layout.activate()
|
|
551
|
+
scroll_area.ensureWidgetVisible(widget)
|
|
552
|
+
|
|
553
|
+
def _show_editor_status(self, recipe: EditorRecipe) -> None:
|
|
554
|
+
"""Explain an unavailable editor without replacing the user's saved executable; an empty one is a valid choice, with no error."""
|
|
555
|
+
try:
|
|
556
|
+
recipe.validate()
|
|
557
|
+
except ValueError as error:
|
|
558
|
+
self.editor_status_label.setText(str(error))
|
|
559
|
+
self.editor_settings_button.show()
|
|
560
|
+
else:
|
|
561
|
+
self.editor_status_label.clear()
|
|
562
|
+
self.editor_settings_button.hide()
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def _offered_editor_presets() -> list[EditorPreset]:
|
|
566
|
+
"""List the editor presets of this system; on macOS and Linux only those whose program is found, by reading files only."""
|
|
567
|
+
return offered_editor_presets(EDITOR_PLATFORM, lambda preset: bool(recipe_for_preset(preset).program_filename))
|
|
568
|
+
|
|
569
|
+
def _select_editor_preset(self, index: int) -> None:
|
|
570
|
+
"""Apply a preset only after explicit selection; Custom preserves the free-form draft."""
|
|
571
|
+
preset_id = self.editor_preset_combo.itemData(index)
|
|
572
|
+
if preset_id == "custom":
|
|
573
|
+
return
|
|
574
|
+
for preset in self._offered_editor_presets():
|
|
575
|
+
if preset.preset_id != preset_id:
|
|
576
|
+
continue
|
|
577
|
+
recipe = recipe_for_preset(preset)
|
|
578
|
+
self.editor_path_edit.setText(recipe.program_filename)
|
|
579
|
+
self.editor_argument_edit.setText(recipe.argument_template)
|
|
580
|
+
self._show_editor_status(recipe)
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
def editor_recipe(self) -> EditorRecipe:
|
|
584
|
+
"""Capture the current editable recipe without publishing or launching it, path and arguments without the blanks around them.
|
|
585
|
+
|
|
586
|
+
The program path also has ``~`` read as the home folder where that is the usage; the arguments are not a path.
|
|
587
|
+
"""
|
|
588
|
+
return EditorRecipe(self.editor_preset_combo.currentData() or "custom", typed_path_value(self.editor_path_edit.text(), self.typed_path), typed_value(self.editor_argument_edit.text()))
|
|
589
|
+
|
|
590
|
+
def show_editor_setting(self) -> None:
|
|
591
|
+
"""Show the Tools page on the editor fields while preserving an existing unsaved draft."""
|
|
592
|
+
self.reveal_widget(TOOLS_PAGE_NAME, self.editor_path_edit)
|
|
593
|
+
self.editor_path_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
|
594
|
+
|
|
595
|
+
def _browse_editor(self) -> None:
|
|
596
|
+
"""Choose the actual editor executable without starting it or saving the draft.
|
|
597
|
+
|
|
598
|
+
A picked application becomes the program it declares; a program that is not the current preset's switches to
|
|
599
|
+
Custom. TextEdit selects its own preset.
|
|
600
|
+
"""
|
|
601
|
+
start_filename = typed_path_value(self.editor_path_edit.text(), self.typed_path)
|
|
602
|
+
filename, _ = QFileDialog.getOpenFileName(self, SettingDialogText.TEXT_EDITOR_EXECUTABLE.value, start_filename)
|
|
603
|
+
if filename:
|
|
604
|
+
self._apply_chosen_editor(filename)
|
|
605
|
+
self.editor_path_edit.setFocus()
|
|
606
|
+
|
|
607
|
+
def _apply_chosen_editor(self, filename: str) -> None:
|
|
608
|
+
"""Put a picked editor in the draft, as its preset when the pick stands for one that this system offers.
|
|
609
|
+
|
|
610
|
+
An editor application that stands for a preset brings the command-line tool of the very application picked, which
|
|
611
|
+
may not be the copy the preset found. When that preset is not offered, for a copy outside the applications folders,
|
|
612
|
+
the tool still comes with the preset's arguments, under Custom.
|
|
613
|
+
"""
|
|
614
|
+
program_filename, preset_id = chosen_program(filename, EDITOR_PLATFORM)
|
|
615
|
+
preset_index = self.editor_preset_combo.findData(preset_id) if preset_id else -1
|
|
616
|
+
if preset_index >= 0:
|
|
617
|
+
self.editor_preset_combo.setCurrentIndex(preset_index)
|
|
618
|
+
self._select_editor_preset(preset_index)
|
|
619
|
+
if program_filename:
|
|
620
|
+
self.editor_path_edit.setText(program_filename)
|
|
621
|
+
self._show_editor_status(self.editor_recipe())
|
|
622
|
+
return
|
|
623
|
+
if preset_id and not program_filename:
|
|
624
|
+
# Only macOS offers the preset of an application that only macOS ships; elsewhere the pick is a plain program.
|
|
625
|
+
program_filename = chosen_application_program(filename)
|
|
626
|
+
self.editor_path_edit.setText(program_filename)
|
|
627
|
+
if not self._is_current_preset_program(program_filename):
|
|
628
|
+
custom_index = self.editor_preset_combo.findData("custom")
|
|
629
|
+
self.editor_preset_combo.setCurrentIndex(custom_index)
|
|
630
|
+
self._select_editor_preset(custom_index)
|
|
631
|
+
preset = editor_preset(preset_id) if preset_id else None
|
|
632
|
+
if preset is not None and program_filename:
|
|
633
|
+
self.editor_argument_edit.setText(preset.arguments)
|
|
634
|
+
self._show_editor_status(self.editor_recipe())
|
|
635
|
+
|
|
636
|
+
def _is_current_preset_program(self, program_filename: str) -> bool:
|
|
637
|
+
"""Tell whether ``program_filename`` is the program the selected preset, other than Custom, would fill in.
|
|
638
|
+
|
|
639
|
+
Both sides are compared as the disk names them, so a link to the preset's program, such as /usr/local/bin/code
|
|
640
|
+
for VS Code, keeps the preset.
|
|
641
|
+
"""
|
|
642
|
+
preset_id = self.editor_preset_combo.currentData()
|
|
643
|
+
if preset_id == "custom":
|
|
644
|
+
return False
|
|
645
|
+
for preset in self._offered_editor_presets():
|
|
646
|
+
if preset.preset_id == preset_id:
|
|
647
|
+
preset_program_filename = recipe_for_preset(preset).program_filename
|
|
648
|
+
return bool(preset_program_filename) and canonical_path(preset_program_filename) == canonical_path(program_filename)
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
652
|
+
"""Open, the first time only, wide enough for the Accounts page; the user's own width is kept when the window is shown again."""
|
|
653
|
+
super().showEvent(event)
|
|
654
|
+
if self.starting_width_fitted:
|
|
655
|
+
return
|
|
656
|
+
self.starting_width_fitted = True
|
|
657
|
+
largest = largest_client_size(self, self.screen().availableGeometry())
|
|
658
|
+
width = min(max(self.width(), self.starting_width()), largest.width())
|
|
659
|
+
if width > self.width():
|
|
660
|
+
self.resize(width, self.height())
|
|
661
|
+
|
|
662
|
+
def starting_width(self) -> int:
|
|
663
|
+
"""Return the default width, or the width at which the Accounts page shows its Connected accounts list whole.
|
|
664
|
+
|
|
665
|
+
The page sits beside the navigation list in a scroll area, whose vertical scroll bar is counted since a long
|
|
666
|
+
page shows one.
|
|
667
|
+
"""
|
|
668
|
+
if self.account_page is None:
|
|
669
|
+
return SETTING_WIDTH
|
|
670
|
+
scroll_area = self.scroll_area_by_page[ACCOUNTS_PAGE_NAME]
|
|
671
|
+
margins = self.layout().contentsMargins()
|
|
672
|
+
page_area_width = self.account_page.needed_width() + scroll_area.verticalScrollBar().sizeHint().width() + scroll_area.frameWidth() * FRAME_SIDE_COUNT
|
|
673
|
+
width = margins.left() + self.navigation_list.minimumWidth() + self.body_layout.spacing() + page_area_width + margins.right()
|
|
674
|
+
return max(SETTING_WIDTH, width)
|
|
675
|
+
|
|
676
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
677
|
+
"""Keep save and cancel visible while the pages scroll on small screens, and keep every page name readable in the navigation."""
|
|
678
|
+
text_width = self.navigation_list.sizeHintForColumn(NAVIGATION_TEXT_COLUMN) + self.navigation_list.frameWidth() * FRAME_SIDE_COUNT + SPACING_MD
|
|
679
|
+
self.navigation_list.setFixedWidth(max(SETTING_NAVIGATION_WIDTH, text_width))
|
|
680
|
+
for control in (self.path_edit, self.browse_button, self.detect_button, self.verify_button, self.difftool_path_edit, self.difftool_command_edit,
|
|
681
|
+
self.editor_preset_combo, self.editor_path_edit, self.editor_argument_edit, self.editor_browse_button,
|
|
682
|
+
self.difftool_browse_button, self.mergetool_path_edit, self.mergetool_command_edit, self.mergetool_browse_button, self.save_button, self.cancel_button):
|
|
683
|
+
control.setMinimumHeight(control_height(control.fontMetrics().height()))
|
|
684
|
+
fit_window(self, SETTING_MIN_WIDTH, SETTING_MIN_HEIGHT, available)
|
|
685
|
+
if self.git_setting_widget is not None:
|
|
686
|
+
self.git_setting_widget.refresh_metrics()
|
|
687
|
+
if self.utility_page is not None:
|
|
688
|
+
self.utility_page.refresh_metrics()
|
|
689
|
+
|
|
690
|
+
def show_git_result(self) -> None:
|
|
691
|
+
"""Show the Git page on a user-triggered failed save and allow keyboard copying of each result."""
|
|
692
|
+
if self.git_setting_widget is None:
|
|
693
|
+
return
|
|
694
|
+
result_label = self.git_setting_widget.status_label
|
|
695
|
+
self.reveal_widget(GIT_PAGE_NAME, result_label)
|
|
696
|
+
result_label.setFocus(Qt.FocusReason.OtherFocusReason)
|
|
697
|
+
self._reveal_git_result()
|
|
698
|
+
|
|
699
|
+
def show_mergetool_setting(self) -> None:
|
|
700
|
+
"""Show the Tools page and focus the merge executable while preserving an already open draft."""
|
|
701
|
+
self.reveal_widget(TOOLS_PAGE_NAME, self.mergetool_path_edit)
|
|
702
|
+
self.mergetool_path_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
|
703
|
+
|
|
704
|
+
def _reveal_git_result(self) -> None:
|
|
705
|
+
"""Keep a focused save result visible as wrapped content changes the Git page's scroll range."""
|
|
706
|
+
if self.git_setting_widget is None:
|
|
707
|
+
return
|
|
708
|
+
result_label = self.git_setting_widget.status_label
|
|
709
|
+
if result_label.hasFocus():
|
|
710
|
+
self.scroll_area_by_page[GIT_PAGE_NAME].ensureWidgetVisible(result_label)
|
|
711
|
+
|
|
712
|
+
def set_loading(self, loading: bool) -> None:
|
|
713
|
+
"""Prevent duplicate submissions while keeping the draft editable and cancellable."""
|
|
714
|
+
set_button_enabled(self.save_button, not loading)
|
|
715
|
+
set_button_enabled(self.verify_button, not loading)
|
|
716
|
+
if loading:
|
|
717
|
+
self.status_label.setText(SettingDialogText.GIT_VERIFYING.value)
|
|
718
|
+
|
|
719
|
+
def show_failure(self, diagnostic: PreparedDiagnostic) -> None:
|
|
720
|
+
"""Keep the exact draft visible with a safe explanation of failed validation."""
|
|
721
|
+
self.set_loading(False)
|
|
722
|
+
self.status_label.show_failure(diagnostic.cause)
|
|
723
|
+
|
|
724
|
+
def _draft_changed(self) -> None:
|
|
725
|
+
"""Invalidate a displayed version as soon as its source path changes."""
|
|
726
|
+
self.set_loading(False)
|
|
727
|
+
self.status_label.clear()
|
|
728
|
+
self.draft_changed.emit()
|
|
729
|
+
|
|
730
|
+
def _browse(self) -> None:
|
|
731
|
+
"""Use a native file chooser without changing the draft when cancelled."""
|
|
732
|
+
git_filename, _ = QFileDialog.getOpenFileName(self, SettingDialogText.GIT_EXECUTABLE.value, self.typed_git_filename())
|
|
733
|
+
if git_filename:
|
|
734
|
+
self.path_edit.setText(git_filename)
|
|
735
|
+
self.path_edit.setFocus()
|
|
736
|
+
|
|
737
|
+
def _browse_difftool(self) -> None:
|
|
738
|
+
"""Choose the actual comparison executable without starting it; a picked application becomes the program it declares."""
|
|
739
|
+
start_filename = typed_path_value(self.difftool_path_edit.text(), self.typed_path)
|
|
740
|
+
filename, _ = QFileDialog.getOpenFileName(self, "External diff tool executable", start_filename)
|
|
741
|
+
if filename:
|
|
742
|
+
self.difftool_path_edit.setText(chosen_application_program(filename))
|
|
743
|
+
|
|
744
|
+
def _browse_mergetool(self) -> None:
|
|
745
|
+
"""Choose the merge executable without starting it or saving the draft; a picked application becomes the program it declares."""
|
|
746
|
+
start_filename = typed_path_value(self.mergetool_path_edit.text(), self.typed_path)
|
|
747
|
+
filename, _ = QFileDialog.getOpenFileName(self, "External merge tool executable", start_filename)
|
|
748
|
+
if filename:
|
|
749
|
+
self.mergetool_path_edit.setText(chosen_application_program(filename))
|
|
750
|
+
self.mergetool_path_edit.setFocus()
|