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,943 @@
|
|
|
1
|
+
"""Edit remote drafts apart from the repository, publishing them only on an explicit Save."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QEvent, QRect, QSignalBlocker, Qt, Signal
|
|
9
|
+
from PySide6.QtWidgets import (
|
|
10
|
+
QCheckBox,
|
|
11
|
+
QDialog,
|
|
12
|
+
QFileDialog,
|
|
13
|
+
QFormLayout,
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QHeaderView,
|
|
17
|
+
QLabel,
|
|
18
|
+
QLayout,
|
|
19
|
+
QLineEdit,
|
|
20
|
+
QMessageBox,
|
|
21
|
+
QPushButton,
|
|
22
|
+
QScrollArea,
|
|
23
|
+
QStyle,
|
|
24
|
+
QTabWidget,
|
|
25
|
+
QTreeWidget,
|
|
26
|
+
QTreeWidgetItem,
|
|
27
|
+
QVBoxLayout,
|
|
28
|
+
QWidget,
|
|
29
|
+
)
|
|
30
|
+
from shiboken6 import isValid
|
|
31
|
+
|
|
32
|
+
from gitpane.config import LOCAL_PREFIX, SPACING_MD, SPACING_SM, keep_selection_visible
|
|
33
|
+
from gitpane.operation.diagnostic import SensitiveTextFilter
|
|
34
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
35
|
+
from gitpane.process.process_runner import ProcessStatus
|
|
36
|
+
from gitpane.text.typed_path import session_typed_path, typed_path_value, typed_start_dir
|
|
37
|
+
from gitpane.text.typed_text import typed_value
|
|
38
|
+
from gitpane.transport.remote import TRACKING_LABEL_BY_FIELD, RemoteChange, url_edit_reason, validate_name
|
|
39
|
+
from gitpane.transport.remote_config import INACTIVE_REMOTE_SECTION, MERGE_TRACKING_FIELD, REMOTE_TRACKING_FIELD, RemoteValue, field_values, tracking_values
|
|
40
|
+
from gitpane.ui.look.action_icon import action_icon
|
|
41
|
+
from gitpane.ui.widget.dialog_layout import apply_left_aligned_form, fit_form
|
|
42
|
+
from gitpane.ui.widget.long_text import WrappingLabel
|
|
43
|
+
from gitpane.ui.widget.wheel_safe_drop_down import UrlSelector
|
|
44
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import Callable
|
|
48
|
+
|
|
49
|
+
from PySide6.QtGui import QIcon
|
|
50
|
+
|
|
51
|
+
from gitpane.transport.remote import RemoteOutcome
|
|
52
|
+
from gitpane.transport.remote_config import RemoteSnapshot
|
|
53
|
+
|
|
54
|
+
REMOTE_DIALOG_WIDTH = 960
|
|
55
|
+
REMOTE_DIALOG_HEIGHT = 520
|
|
56
|
+
REMOVE_REMOTE_TITLE = "Remove remote"
|
|
57
|
+
REMOTE_NAME_COLUMN = 0
|
|
58
|
+
TRACKING_NAME_COLUMN = 0
|
|
59
|
+
TRACKING_REMOTE_COLUMN = 1
|
|
60
|
+
TRACKING_MERGE_COLUMN = 2
|
|
61
|
+
REMOTE_LIST_STRETCH = 1
|
|
62
|
+
REMOTE_DETAILS_STRETCH = 4
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class RemoteDraft:
|
|
67
|
+
"""Retain each edited URL independently while changing target or selected occurrence."""
|
|
68
|
+
|
|
69
|
+
name: str = ""
|
|
70
|
+
fetch_by_value: dict[RemoteValue | None, str] = field(default_factory=dict)
|
|
71
|
+
push_by_value: dict[RemoteValue | None, str] = field(default_factory=dict)
|
|
72
|
+
fetch_selected: RemoteValue | None = None
|
|
73
|
+
push_selected: RemoteValue | None = None
|
|
74
|
+
separate: bool = False
|
|
75
|
+
local_override: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class TrackingDraft:
|
|
80
|
+
"""Keep a branch editor bound to its own capture while navigating other rows."""
|
|
81
|
+
|
|
82
|
+
snapshot: RemoteSnapshot
|
|
83
|
+
remote: str = ""
|
|
84
|
+
merge: str = ""
|
|
85
|
+
remote_selected: RemoteValue | None = None
|
|
86
|
+
merge_selected: RemoteValue | None = None
|
|
87
|
+
remote_by_value: dict[RemoteValue | None, str] = field(default_factory=dict)
|
|
88
|
+
merge_by_value: dict[RemoteValue | None, str] = field(default_factory=dict)
|
|
89
|
+
stale: bool = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RemoteDialog(QDialog):
|
|
93
|
+
"""Present captured repositories and retain unsaved details per selected remote."""
|
|
94
|
+
|
|
95
|
+
save_requested = Signal(object)
|
|
96
|
+
active_requested = Signal(object)
|
|
97
|
+
remove_requested = Signal(object)
|
|
98
|
+
tracking_requested = Signal(object)
|
|
99
|
+
tracking_reload_requested = Signal()
|
|
100
|
+
|
|
101
|
+
def __init__(self, session_id: str, parent: QWidget | None = None, *, typed_path: Callable[[str], str] = session_typed_path) -> None:
|
|
102
|
+
"""Build a scrollable editor with permanent Save and Close controls.
|
|
103
|
+
|
|
104
|
+
``typed_path`` reads ``~`` in the Url and Push Url fields where that is the usage; the tests hand their own
|
|
105
|
+
system and home folder.
|
|
106
|
+
"""
|
|
107
|
+
super().__init__(parent)
|
|
108
|
+
self.typed_path = typed_path
|
|
109
|
+
self.session_id = session_id
|
|
110
|
+
self.snapshot: RemoteSnapshot | None = None
|
|
111
|
+
self.selected_name: str | None = None
|
|
112
|
+
self.draft_by_name: dict[str | None, RemoteDraft] = {}
|
|
113
|
+
self.tracking_draft_by_ref: dict[str, TrackingDraft] = {}
|
|
114
|
+
self.selected_full_ref: str | None = None
|
|
115
|
+
self.loading = False
|
|
116
|
+
self.tracking_loading = False
|
|
117
|
+
self.uncertain = False
|
|
118
|
+
self.text_filter = SensitiveTextFilter()
|
|
119
|
+
self.closed = False
|
|
120
|
+
self.busy = False
|
|
121
|
+
self.read_failed = False
|
|
122
|
+
self.request_kind = ""
|
|
123
|
+
self.request: tuple | None = None
|
|
124
|
+
self.removal_dialog: QMessageBox | None = None
|
|
125
|
+
self.return_focus = parent.focusWidget() if parent else None
|
|
126
|
+
self.setWindowTitle("Remote repositories")
|
|
127
|
+
self.resize(REMOTE_DIALOG_WIDTH, REMOTE_DIALOG_HEIGHT)
|
|
128
|
+
layout = QVBoxLayout(self)
|
|
129
|
+
layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
|
130
|
+
self.scroll_area = QScrollArea()
|
|
131
|
+
self.scroll_area.setWidgetResizable(True)
|
|
132
|
+
remote_page = QWidget()
|
|
133
|
+
body = QHBoxLayout(remote_page)
|
|
134
|
+
body.setSpacing(SPACING_SM)
|
|
135
|
+
self.remote_tree = QTreeWidget()
|
|
136
|
+
self.remote_tree.setHeaderHidden(True)
|
|
137
|
+
self.remote_tree.setAccessibleName("Remote repositories")
|
|
138
|
+
body.addWidget(self.remote_tree, REMOTE_LIST_STRETCH)
|
|
139
|
+
tools = QVBoxLayout()
|
|
140
|
+
density = self.devicePixelRatioF()
|
|
141
|
+
self.add_button = self._tool_button("Add remote", action_icon("add_remote", density))
|
|
142
|
+
self.remove_button = self._tool_button("Remove remote", action_icon("remove_remote", density))
|
|
143
|
+
self.activate_button = self._tool_button("Activate remote", self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
|
|
144
|
+
self.deactivate_button = self._tool_button("Deactivate remote", self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
|
|
145
|
+
for button in (self.add_button, self.remove_button, self.activate_button, self.deactivate_button):
|
|
146
|
+
tools.addWidget(button)
|
|
147
|
+
tools.addStretch()
|
|
148
|
+
body.addLayout(tools)
|
|
149
|
+
self.details = QGroupBox("Edit Remote Details")
|
|
150
|
+
details_layout = QVBoxLayout(self.details)
|
|
151
|
+
content = QWidget()
|
|
152
|
+
form = QFormLayout(content)
|
|
153
|
+
form.setContentsMargins(0, 0, 0, 0)
|
|
154
|
+
apply_left_aligned_form(form)
|
|
155
|
+
self.url_edit = QLineEdit()
|
|
156
|
+
self.url_edit.setAccessibleName("Url")
|
|
157
|
+
self.url_label = self._label("Url")
|
|
158
|
+
self.url_label.setBuddy(self.url_edit)
|
|
159
|
+
url_row = QHBoxLayout()
|
|
160
|
+
url_row.addWidget(self.url_edit)
|
|
161
|
+
self.browse_button = QPushButton("Browse")
|
|
162
|
+
self.browse_button.setAccessibleName("Browse fetch URL")
|
|
163
|
+
url_row.addWidget(self.browse_button)
|
|
164
|
+
form.addRow(self.url_label, url_row)
|
|
165
|
+
self.name_edit = QLineEdit()
|
|
166
|
+
self.name_edit.setAccessibleName("Name")
|
|
167
|
+
name_label = self._label("Name")
|
|
168
|
+
name_label.setBuddy(self.name_edit)
|
|
169
|
+
form.addRow(name_label, self.name_edit)
|
|
170
|
+
self.separate_push_checkbox = QCheckBox("Separate Push Url")
|
|
171
|
+
form.addRow(self.separate_push_checkbox)
|
|
172
|
+
self.push_url_edit = QLineEdit()
|
|
173
|
+
self.push_url_edit.setAccessibleName("Push Url")
|
|
174
|
+
self.push_label = self._label("Push Url")
|
|
175
|
+
self.push_label.setBuddy(self.push_url_edit)
|
|
176
|
+
self.push_row = QWidget()
|
|
177
|
+
push_layout = QHBoxLayout(self.push_row)
|
|
178
|
+
push_layout.setContentsMargins(0, 0, 0, 0)
|
|
179
|
+
push_layout.addWidget(self.push_url_edit)
|
|
180
|
+
self.push_browse_button = QPushButton("Browse")
|
|
181
|
+
self.push_browse_button.setAccessibleName("Browse push URL")
|
|
182
|
+
self.push_browse_button.setAutoDefault(False)
|
|
183
|
+
push_layout.addWidget(self.push_browse_button)
|
|
184
|
+
form.addRow(self.push_label, self.push_row)
|
|
185
|
+
self.fetch_selector = UrlSelector()
|
|
186
|
+
self.fetch_selector.setAccessibleName("Configured fetch URL to edit")
|
|
187
|
+
self.push_selector = UrlSelector()
|
|
188
|
+
self.push_selector.setAccessibleName("Configured push URL to edit")
|
|
189
|
+
self.fetch_selector_label = self._label("Configured Fetch URLs")
|
|
190
|
+
self.fetch_selector_label.setBuddy(self.fetch_selector)
|
|
191
|
+
self.push_selector_label = self._label("Configured Push URLs")
|
|
192
|
+
self.push_selector_label.setBuddy(self.push_selector)
|
|
193
|
+
form.addRow(self.fetch_selector_label, self.fetch_selector)
|
|
194
|
+
form.addRow(self.push_selector_label, self.push_selector)
|
|
195
|
+
self.local_override_checkbox = QCheckBox("Create local Push URL override")
|
|
196
|
+
form.addRow(self.local_override_checkbox)
|
|
197
|
+
self.edit_reason_label = self._label("")
|
|
198
|
+
form.addRow(self.edit_reason_label)
|
|
199
|
+
self.effective_label = self._label("")
|
|
200
|
+
self.effective_label.setAccessibleName("Effective Fetch and Push destinations")
|
|
201
|
+
form.addRow(self.effective_label)
|
|
202
|
+
self.origin_label = self._label("")
|
|
203
|
+
form.addRow(self.origin_label)
|
|
204
|
+
self.unavailable_label = self._label("Inactive remotes use Git Extensions-compatible -remote sections. GitPane excludes them from automatic operations.")
|
|
205
|
+
form.addRow(self.unavailable_label)
|
|
206
|
+
self.scroll_area.setWidget(content)
|
|
207
|
+
details_layout.addWidget(self.scroll_area, 1)
|
|
208
|
+
details_actions = QHBoxLayout()
|
|
209
|
+
details_actions.addStretch()
|
|
210
|
+
self.save_button = QPushButton("Save changes")
|
|
211
|
+
details_actions.addWidget(self.save_button)
|
|
212
|
+
details_layout.addLayout(details_actions)
|
|
213
|
+
body.addWidget(self.details, REMOTE_DETAILS_STRETCH)
|
|
214
|
+
self.tabs = QTabWidget()
|
|
215
|
+
self.tabs.addTab(remote_page, "Remote repositories")
|
|
216
|
+
self._build_tracking()
|
|
217
|
+
self.tabs.addTab(self.tracking_page, "Default pull behavior (fetch & merge)")
|
|
218
|
+
layout.addWidget(self.tabs)
|
|
219
|
+
self.status_label = self._label("Loading remotes…")
|
|
220
|
+
layout.addWidget(self.status_label)
|
|
221
|
+
actions = QHBoxLayout()
|
|
222
|
+
actions.addStretch()
|
|
223
|
+
self.tracking_save_button = QPushButton("Save changes")
|
|
224
|
+
self.close_button = QPushButton("Close")
|
|
225
|
+
for button in (self.save_button, self.tracking_save_button, self.close_button, self.browse_button):
|
|
226
|
+
button.setAutoDefault(False)
|
|
227
|
+
actions.addWidget(self.tracking_save_button)
|
|
228
|
+
actions.addWidget(self.close_button)
|
|
229
|
+
layout.addLayout(actions)
|
|
230
|
+
self.remote_tree.currentItemChanged.connect(self._selection_changed)
|
|
231
|
+
self.add_button.clicked.connect(lambda: self.select_remote(None))
|
|
232
|
+
self.browse_button.clicked.connect(self._browse)
|
|
233
|
+
self.name_edit.textChanged.connect(self._update_action)
|
|
234
|
+
self.url_edit.textChanged.connect(self._update_action)
|
|
235
|
+
self.push_url_edit.textChanged.connect(self._update_action)
|
|
236
|
+
self.separate_push_checkbox.toggled.connect(self._toggle_push)
|
|
237
|
+
self.local_override_checkbox.toggled.connect(self._update_action)
|
|
238
|
+
self.fetch_selector.currentIndexChanged.connect(lambda: self._url_selected(False))
|
|
239
|
+
self.push_selector.currentIndexChanged.connect(lambda: self._url_selected(True))
|
|
240
|
+
self.push_browse_button.clicked.connect(lambda: self._browse(True))
|
|
241
|
+
self.save_button.clicked.connect(self._save)
|
|
242
|
+
self.tracking_save_button.clicked.connect(self._save)
|
|
243
|
+
self.activate_button.clicked.connect(lambda: self._set_active(True))
|
|
244
|
+
self.deactivate_button.clicked.connect(lambda: self._set_active(False))
|
|
245
|
+
self.remove_button.clicked.connect(self._remove)
|
|
246
|
+
self.close_button.clicked.connect(self.reject)
|
|
247
|
+
self.tabs.currentChanged.connect(self._update_action)
|
|
248
|
+
self._update_action()
|
|
249
|
+
self.refresh_metrics()
|
|
250
|
+
|
|
251
|
+
def _build_tracking(self) -> None:
|
|
252
|
+
"""Place the branch table and exact config editors inside a scrollable tab."""
|
|
253
|
+
self.tracking_page = QScrollArea()
|
|
254
|
+
self.tracking_page.setWidgetResizable(True)
|
|
255
|
+
content = QWidget()
|
|
256
|
+
body = QVBoxLayout(content)
|
|
257
|
+
self.tracking_tree = QTreeWidget()
|
|
258
|
+
self.tracking_tree.setRootIsDecorated(False)
|
|
259
|
+
self.tracking_tree.setHeaderLabels(["Local branch name", TRACKING_LABEL_BY_FIELD[REMOTE_TRACKING_FIELD], TRACKING_LABEL_BY_FIELD[MERGE_TRACKING_FIELD]])
|
|
260
|
+
self.tracking_tree.setAccessibleName("Local branch tracking")
|
|
261
|
+
self.tracking_tree.header().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
262
|
+
self.tracking_tree.header().setStretchLastSection(True)
|
|
263
|
+
body.addWidget(self.tracking_tree)
|
|
264
|
+
self.tracking_details = QGroupBox("Edit branch tracking")
|
|
265
|
+
form = QFormLayout(self.tracking_details)
|
|
266
|
+
apply_left_aligned_form(form)
|
|
267
|
+
self.local_branch_edit = QLineEdit()
|
|
268
|
+
self.local_branch_edit.setReadOnly(True)
|
|
269
|
+
self.tracking_remote_selector = UrlSelector()
|
|
270
|
+
self.merge_edit = QLineEdit()
|
|
271
|
+
self.tracking_remote_value_selector = UrlSelector()
|
|
272
|
+
self.merge_selector = UrlSelector()
|
|
273
|
+
for text, widget in (("Local branch name", self.local_branch_edit), ("Configured Remote values", self.tracking_remote_value_selector),
|
|
274
|
+
(TRACKING_LABEL_BY_FIELD[REMOTE_TRACKING_FIELD], self.tracking_remote_selector), ("Configured Merge values", self.merge_selector),
|
|
275
|
+
(TRACKING_LABEL_BY_FIELD[MERGE_TRACKING_FIELD], self.merge_edit)):
|
|
276
|
+
label = self._label(text)
|
|
277
|
+
label.setBuddy(widget)
|
|
278
|
+
widget.setAccessibleName(text)
|
|
279
|
+
form.addRow(label, widget)
|
|
280
|
+
self.tracking_origin_label = self._label("")
|
|
281
|
+
self.tracking_origin_label.setAccessibleName("Tracking Source and Effective value")
|
|
282
|
+
form.addRow(self.tracking_origin_label)
|
|
283
|
+
body.addWidget(self.tracking_details)
|
|
284
|
+
self.tracking_state_label = self._label("Loading branch tracking…")
|
|
285
|
+
body.addWidget(self.tracking_state_label)
|
|
286
|
+
self.tracking_reload_button = QPushButton("Reread tracking")
|
|
287
|
+
self.tracking_reload_button.setAutoDefault(False)
|
|
288
|
+
self.tracking_reload_button.setAccessibleName("Reread tracking")
|
|
289
|
+
body.addWidget(self.tracking_reload_button)
|
|
290
|
+
self.tracking_page.setWidget(content)
|
|
291
|
+
self.tracking_tree.currentItemChanged.connect(self._branch_selection_changed)
|
|
292
|
+
self.tracking_remote_selector.currentIndexChanged.connect(self._update_action)
|
|
293
|
+
self.merge_edit.textChanged.connect(self._update_action)
|
|
294
|
+
self.tracking_remote_value_selector.currentIndexChanged.connect(lambda: self._tracking_value_selected(REMOTE_TRACKING_FIELD))
|
|
295
|
+
self.merge_selector.currentIndexChanged.connect(lambda: self._tracking_value_selected(MERGE_TRACKING_FIELD))
|
|
296
|
+
self.tracking_reload_button.clicked.connect(self._reload_tracking)
|
|
297
|
+
|
|
298
|
+
def _reload_tracking(self) -> None:
|
|
299
|
+
"""Request an explicit fresh read while retaining the selected branch's draft."""
|
|
300
|
+
if self.busy or self.closed:
|
|
301
|
+
return
|
|
302
|
+
self._store_tracking_draft()
|
|
303
|
+
self.busy = True
|
|
304
|
+
self.status_label.setText("Reading branch tracking…")
|
|
305
|
+
self._update_action()
|
|
306
|
+
self.tracking_reload_requested.emit()
|
|
307
|
+
|
|
308
|
+
def tracking_reloaded(self, snapshot: RemoteSnapshot) -> None:
|
|
309
|
+
"""Accept a requested reread without selecting another branch or discarding text."""
|
|
310
|
+
self.busy = False
|
|
311
|
+
self.read_failed = False
|
|
312
|
+
self.uncertain = False
|
|
313
|
+
draft = self.tracking_draft_by_ref.get(self.selected_full_ref)
|
|
314
|
+
if draft is not None:
|
|
315
|
+
self._rebase_tracking_draft(draft, snapshot, set())
|
|
316
|
+
self.populate(snapshot, self.selected_name, select_first=False, preserve_tracking=False)
|
|
317
|
+
|
|
318
|
+
def _rebase_tracking_draft(self, draft: TrackingDraft, snapshot: RemoteSnapshot, successful_field_set: set[str]) -> None:
|
|
319
|
+
"""Accept observed successes and rebind failed buffers only to unambiguous values."""
|
|
320
|
+
full_ref = self.selected_full_ref
|
|
321
|
+
for field_name in TRACKING_LABEL_BY_FIELD:
|
|
322
|
+
selected = getattr(draft, field_name + "_selected")
|
|
323
|
+
text = getattr(draft, field_name)
|
|
324
|
+
edited = text != (selected.value if selected else "")
|
|
325
|
+
values = tracking_values(snapshot, full_ref, field_name)
|
|
326
|
+
value_by_selected = getattr(draft, field_name + "_by_value")
|
|
327
|
+
if field_name in successful_field_set:
|
|
328
|
+
value_by_selected.pop(selected, None)
|
|
329
|
+
matching_values = tuple(value for value in values if value.value == text)
|
|
330
|
+
selected = next(iter(matching_values)) if len(matching_values) == 1 else None
|
|
331
|
+
elif selected not in values:
|
|
332
|
+
selected = next(iter(values)) if len(values) == 1 else None
|
|
333
|
+
if not edited:
|
|
334
|
+
text = selected.value if selected else ""
|
|
335
|
+
setattr(draft, field_name + "_selected", selected)
|
|
336
|
+
setattr(draft, field_name, text)
|
|
337
|
+
value_by_selected[selected] = text
|
|
338
|
+
draft.snapshot = snapshot
|
|
339
|
+
draft.stale = False
|
|
340
|
+
|
|
341
|
+
def _branch_selection_changed(self, current: QTreeWidgetItem | None, previous: QTreeWidgetItem | None) -> None:
|
|
342
|
+
"""Select the full local ref even when its displayed name is shortened."""
|
|
343
|
+
if current is not None:
|
|
344
|
+
self.select_branch(current.data(TRACKING_NAME_COLUMN, Qt.ItemDataRole.UserRole))
|
|
345
|
+
|
|
346
|
+
def _populate_tracking(self) -> None:
|
|
347
|
+
"""Show every branch independently in full-name order without grouping upstreams."""
|
|
348
|
+
with QSignalBlocker(self.tracking_tree):
|
|
349
|
+
self.tracking_tree.clear()
|
|
350
|
+
for full_ref in sorted(self.snapshot.tracking_by_ref):
|
|
351
|
+
texts = [full_ref.removeprefix(LOCAL_PREFIX)]
|
|
352
|
+
for field_name in TRACKING_LABEL_BY_FIELD:
|
|
353
|
+
values = tracking_values(self.snapshot, full_ref, field_name)
|
|
354
|
+
texts.append("; ".join(value.value for value in values) if values else "Not configured")
|
|
355
|
+
item = QTreeWidgetItem(self.tracking_tree, texts)
|
|
356
|
+
item.setData(TRACKING_NAME_COLUMN, Qt.ItemDataRole.UserRole, full_ref)
|
|
357
|
+
for column, text in enumerate(texts):
|
|
358
|
+
item.setToolTip(column, text)
|
|
359
|
+
selected = self.selected_full_ref
|
|
360
|
+
if selected is None:
|
|
361
|
+
selected = next(iter(sorted(self.snapshot.tracking_by_ref)), None)
|
|
362
|
+
self.select_branch(selected, preserve=False)
|
|
363
|
+
|
|
364
|
+
def select_branch(self, full_ref: str | None, *, preserve: bool = True) -> None:
|
|
365
|
+
"""Load one branch's draft without saving or adopting another branch's identity."""
|
|
366
|
+
if self.busy or self.snapshot is None:
|
|
367
|
+
return
|
|
368
|
+
if preserve:
|
|
369
|
+
self._store_tracking_draft()
|
|
370
|
+
self.loading = True
|
|
371
|
+
self.selected_full_ref = full_ref
|
|
372
|
+
branch = self.snapshot.tracking_by_ref.get(full_ref)
|
|
373
|
+
with QSignalBlocker(self.tracking_tree):
|
|
374
|
+
self.tracking_tree.clearSelection()
|
|
375
|
+
for index in range(self.tracking_tree.topLevelItemCount()):
|
|
376
|
+
item = self.tracking_tree.topLevelItem(index)
|
|
377
|
+
if item.data(TRACKING_NAME_COLUMN, Qt.ItemDataRole.UserRole) == full_ref:
|
|
378
|
+
self.tracking_tree.setCurrentItem(item)
|
|
379
|
+
self.local_branch_edit.setText(full_ref.removeprefix(LOCAL_PREFIX) if full_ref else "")
|
|
380
|
+
if branch is not None and full_ref not in self.tracking_draft_by_ref:
|
|
381
|
+
remote_values = tracking_values(self.snapshot, full_ref, REMOTE_TRACKING_FIELD)
|
|
382
|
+
merge_values = tracking_values(self.snapshot, full_ref, MERGE_TRACKING_FIELD)
|
|
383
|
+
remote = next(iter(remote_values)) if len(remote_values) == 1 else None
|
|
384
|
+
merge = next(iter(merge_values)) if len(merge_values) == 1 else None
|
|
385
|
+
self.tracking_draft_by_ref[full_ref] = TrackingDraft(self.snapshot, remote.value if remote else "", merge.value if merge else "", remote, merge)
|
|
386
|
+
draft = self.tracking_draft_by_ref.get(full_ref)
|
|
387
|
+
self.tracking_remote_selector.clear()
|
|
388
|
+
self.tracking_remote_selector.addItem("This repository (.)", ".")
|
|
389
|
+
for name in sorted(self.snapshot.entry_by_name):
|
|
390
|
+
entry = self.snapshot.entry_by_name[name]
|
|
391
|
+
self.tracking_remote_selector.addItem(name + (" — Inactive" if not entry.active else ""), name)
|
|
392
|
+
if draft and draft.remote and self.tracking_remote_selector.findData(draft.remote) < 0:
|
|
393
|
+
self.tracking_remote_selector.addItem(draft.remote + " — Not configured", draft.remote)
|
|
394
|
+
self.tracking_remote_selector.setCurrentIndex(self.tracking_remote_selector.findData(draft.remote) if draft and draft.remote else -1)
|
|
395
|
+
self.merge_edit.setText(draft.merge if draft else "")
|
|
396
|
+
for field_name, selector in ((REMOTE_TRACKING_FIELD, self.tracking_remote_value_selector), (MERGE_TRACKING_FIELD, self.merge_selector)):
|
|
397
|
+
selector.clear()
|
|
398
|
+
if draft:
|
|
399
|
+
for value in tracking_values(draft.snapshot, full_ref, field_name):
|
|
400
|
+
selector.addItem(value.value + " — " + value.scope.value + ": " + value.origin, value)
|
|
401
|
+
selected = getattr(draft, field_name + "_selected")
|
|
402
|
+
selector.setCurrentIndex(selector.findData(selected) if selected else -1)
|
|
403
|
+
self.tracking_origin_label.setText("" if branch is None else "\n".join(value.key + " = " + value.value + "\nSource: " + value.scope.value + " — " + value.origin for value in branch.values))
|
|
404
|
+
if branch is None:
|
|
405
|
+
self.tracking_state_label.setText("No local branches to configure." if not self.snapshot.tracking_by_ref else "Selected branch is no longer available. Reread tracking.")
|
|
406
|
+
elif not tracking_values(self.snapshot, full_ref, REMOTE_TRACKING_FIELD) or not tracking_values(self.snapshot, full_ref, MERGE_TRACKING_FIELD):
|
|
407
|
+
self.tracking_state_label.setText("Tracking association is not configured.")
|
|
408
|
+
elif any(len(tracking_values(self.snapshot, full_ref, field_name)) > 1 for field_name in TRACKING_LABEL_BY_FIELD):
|
|
409
|
+
self.tracking_state_label.setText("Multiple tracking values. Select the exact configured value to edit; other values remain unchanged.")
|
|
410
|
+
elif draft.remote != "." and draft.remote not in self.snapshot.entry_by_name:
|
|
411
|
+
self.tracking_state_label.setText("Configured remote repository is not available. Choose a configured remote explicitly.")
|
|
412
|
+
else:
|
|
413
|
+
self.tracking_state_label.setText("Effective value: " + branch.upstream if branch.observed else "Configured tracking target is not observed locally.")
|
|
414
|
+
self.loading = False
|
|
415
|
+
self._update_action()
|
|
416
|
+
|
|
417
|
+
def _store_tracking_draft(self) -> None:
|
|
418
|
+
"""Retain the active branch's text buffers only in this dialog."""
|
|
419
|
+
if self.loading or self.tracking_loading or self.selected_full_ref not in self.tracking_draft_by_ref:
|
|
420
|
+
return
|
|
421
|
+
draft = self.tracking_draft_by_ref[self.selected_full_ref]
|
|
422
|
+
draft.remote = self.tracking_remote_selector.currentData() or ""
|
|
423
|
+
draft.merge = self.merge_edit.text()
|
|
424
|
+
draft.remote_by_value[draft.remote_selected] = draft.remote
|
|
425
|
+
draft.merge_by_value[draft.merge_selected] = draft.merge
|
|
426
|
+
|
|
427
|
+
def _tracking_value_selected(self, field_name: str) -> None:
|
|
428
|
+
"""Require an explicit choice before reducing a multi-value field to one editor."""
|
|
429
|
+
if self.loading or self.selected_full_ref not in self.tracking_draft_by_ref:
|
|
430
|
+
return
|
|
431
|
+
self._store_tracking_draft()
|
|
432
|
+
draft = self.tracking_draft_by_ref[self.selected_full_ref]
|
|
433
|
+
selector = self.merge_selector if field_name == MERGE_TRACKING_FIELD else self.tracking_remote_value_selector
|
|
434
|
+
selected = selector.currentData()
|
|
435
|
+
setattr(draft, field_name + "_selected", selected)
|
|
436
|
+
text = getattr(draft, field_name + "_by_value").get(selected, selected.value if selected else "")
|
|
437
|
+
if field_name == MERGE_TRACKING_FIELD:
|
|
438
|
+
self.merge_edit.setText(text)
|
|
439
|
+
else:
|
|
440
|
+
self.tracking_remote_selector.setCurrentIndex(self.tracking_remote_selector.findData(text) if text else -1)
|
|
441
|
+
self._update_action()
|
|
442
|
+
|
|
443
|
+
def _tracking_changes(self) -> tuple[RemoteChange, ...]:
|
|
444
|
+
"""Collect explicit changed fields without replacing neighboring occurrences.
|
|
445
|
+
|
|
446
|
+
The tracked branch is typed, so it is sent without the blanks around it while its draft keeps them; the remote
|
|
447
|
+
comes from a list and is sent as chosen.
|
|
448
|
+
"""
|
|
449
|
+
self._store_tracking_draft()
|
|
450
|
+
draft = self.tracking_draft_by_ref.get(self.selected_full_ref)
|
|
451
|
+
if draft is None:
|
|
452
|
+
return ()
|
|
453
|
+
changes: list[RemoteChange] = []
|
|
454
|
+
for field_name in TRACKING_LABEL_BY_FIELD:
|
|
455
|
+
selected = getattr(draft, field_name + "_selected")
|
|
456
|
+
text = getattr(draft, field_name)
|
|
457
|
+
if field_name == MERGE_TRACKING_FIELD:
|
|
458
|
+
text = typed_value(text)
|
|
459
|
+
if text != (selected.value if selected else ""):
|
|
460
|
+
changes.append(RemoteChange(field_name, text, selected))
|
|
461
|
+
return tuple(changes)
|
|
462
|
+
|
|
463
|
+
def _tool_button(self, name: str, icon: QIcon) -> QPushButton:
|
|
464
|
+
"""Build an icon-only list button whose name is still given to screen readers and shown as its tooltip.
|
|
465
|
+
|
|
466
|
+
Add and remove use GitPane's drawn plus and minus at the window's density; the others keep the style's arrows.
|
|
467
|
+
"""
|
|
468
|
+
button = QPushButton()
|
|
469
|
+
button.setIcon(icon)
|
|
470
|
+
button.setAccessibleName(name)
|
|
471
|
+
button.setToolTip(name)
|
|
472
|
+
button.setAutoDefault(False)
|
|
473
|
+
return button
|
|
474
|
+
|
|
475
|
+
def _label(self, text: str) -> QLabel:
|
|
476
|
+
label = WrappingLabel(text)
|
|
477
|
+
label.setTextFormat(Qt.TextFormat.PlainText)
|
|
478
|
+
label.setWordWrap(True)
|
|
479
|
+
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
480
|
+
return label
|
|
481
|
+
|
|
482
|
+
def populate(self, snapshot: RemoteSnapshot, selected_name: str | None = None, *, select_first: bool = True, preserve_tracking: bool = True) -> None:
|
|
483
|
+
"""Replace the observed list without publishing or discarding unrelated drafts."""
|
|
484
|
+
if preserve_tracking:
|
|
485
|
+
self._store_tracking_draft()
|
|
486
|
+
self.tracking_loading = True
|
|
487
|
+
self.read_failed = False
|
|
488
|
+
self.snapshot = snapshot
|
|
489
|
+
with QSignalBlocker(self.remote_tree):
|
|
490
|
+
self.remote_tree.clear()
|
|
491
|
+
active = QTreeWidgetItem(self.remote_tree, ["Active"])
|
|
492
|
+
inactive = QTreeWidgetItem(self.remote_tree, ["Inactive"])
|
|
493
|
+
for group in (active, inactive):
|
|
494
|
+
group.setFlags(group.flags() & ~Qt.ItemFlag.ItemIsSelectable)
|
|
495
|
+
group.setExpanded(True)
|
|
496
|
+
for name in sorted(snapshot.entry_by_name, key=lambda name: (name.casefold(), name)):
|
|
497
|
+
entry = snapshot.entry_by_name[name]
|
|
498
|
+
item = QTreeWidgetItem(active if entry.active else inactive, [entry.name])
|
|
499
|
+
item.setData(REMOTE_NAME_COLUMN, Qt.ItemDataRole.UserRole, entry.name)
|
|
500
|
+
selected = selected_name if selected_name in snapshot.entry_by_name else next(iter(snapshot.entry_by_name), None) if select_first else None
|
|
501
|
+
self.select_remote(selected, preserve=False)
|
|
502
|
+
self.status_label.setText("" if snapshot.entry_by_name else "No remotes configured. Choose Add remote.")
|
|
503
|
+
self._populate_tracking()
|
|
504
|
+
self.tracking_loading = False
|
|
505
|
+
self._update_action()
|
|
506
|
+
|
|
507
|
+
def _selection_changed(self, current: QTreeWidgetItem | None, previous: QTreeWidgetItem | None) -> None:
|
|
508
|
+
"""Select an exact data identity rather than a potentially shortened label."""
|
|
509
|
+
if current is not None and current.data(REMOTE_NAME_COLUMN, Qt.ItemDataRole.UserRole) is not None:
|
|
510
|
+
self.select_remote(current.data(REMOTE_NAME_COLUMN, Qt.ItemDataRole.UserRole))
|
|
511
|
+
|
|
512
|
+
def select_remote(self, name: str | None, *, preserve: bool = True) -> None:
|
|
513
|
+
"""Retain the old target's draft while displaying another captured target."""
|
|
514
|
+
if self.busy or self.snapshot is None:
|
|
515
|
+
return
|
|
516
|
+
if preserve:
|
|
517
|
+
self._store_draft()
|
|
518
|
+
self.loading = True
|
|
519
|
+
self.selected_name = name
|
|
520
|
+
with QSignalBlocker(self.remote_tree):
|
|
521
|
+
self.remote_tree.clearSelection()
|
|
522
|
+
for group_index in range(self.remote_tree.topLevelItemCount()):
|
|
523
|
+
group = self.remote_tree.topLevelItem(group_index)
|
|
524
|
+
for child_index in range(group.childCount()):
|
|
525
|
+
item = group.child(child_index)
|
|
526
|
+
if item.data(REMOTE_NAME_COLUMN, Qt.ItemDataRole.UserRole) == name:
|
|
527
|
+
self.remote_tree.setCurrentItem(item)
|
|
528
|
+
entry = self.snapshot.entry_by_name.get(name)
|
|
529
|
+
fetch_values = field_values(self.snapshot, name, "url") if entry is not None else ()
|
|
530
|
+
push_values = field_values(self.snapshot, name, "pushurl") if entry is not None else ()
|
|
531
|
+
if name not in self.draft_by_name:
|
|
532
|
+
self.draft_by_name[name] = RemoteDraft(name or "", fetch_selected=next(iter(fetch_values)) if len(fetch_values) == 1 else None,
|
|
533
|
+
push_selected=next(iter(push_values)) if len(push_values) == 1 else None, separate=bool(push_values))
|
|
534
|
+
draft = self.draft_by_name[name]
|
|
535
|
+
self.name_edit.setText(draft.name)
|
|
536
|
+
for selector, values, selected in ((self.fetch_selector, fetch_values, draft.fetch_selected), (self.push_selector, push_values, draft.push_selected)):
|
|
537
|
+
with QSignalBlocker(selector):
|
|
538
|
+
selector.clear()
|
|
539
|
+
for value in values:
|
|
540
|
+
selector.addItem(self.text_filter.redact(value.value) + " — " + value.scope.value + ": " + value.origin, value)
|
|
541
|
+
selector.setCurrentIndex(selector.findData(selected) if selected is not None else -1)
|
|
542
|
+
self.url_edit.setText(draft.fetch_by_value.get(draft.fetch_selected, draft.fetch_selected.value if draft.fetch_selected else ""))
|
|
543
|
+
self.push_url_edit.setText(draft.push_by_value.get(draft.push_selected, draft.push_selected.value if draft.push_selected else ""))
|
|
544
|
+
self.separate_push_checkbox.setChecked(draft.separate)
|
|
545
|
+
self.local_override_checkbox.setChecked(draft.local_override)
|
|
546
|
+
self.details.setTitle("Add remote" if name is None else "Edit Remote Details")
|
|
547
|
+
self.origin_label.setText("" if entry is None else "\n".join(value.key + " — " + value.scope.value + ": " + value.origin for value in entry.values))
|
|
548
|
+
self.effective_label.setText("" if entry is None else self.text_filter.redact("Effective Fetch URLs:\n" + "\n".join(entry.fetch_urls) + "\nEffective Push URLs:\n" + "\n".join(entry.push_urls)))
|
|
549
|
+
self.loading = False
|
|
550
|
+
self._toggle_push()
|
|
551
|
+
|
|
552
|
+
def _store_draft(self) -> None:
|
|
553
|
+
"""Save the current editor buffers in memory without touching Git."""
|
|
554
|
+
if self.loading or self.selected_name not in self.draft_by_name:
|
|
555
|
+
return
|
|
556
|
+
draft = self.draft_by_name[self.selected_name]
|
|
557
|
+
draft.name = self.name_edit.text()
|
|
558
|
+
draft.fetch_by_value[draft.fetch_selected] = self.url_edit.text()
|
|
559
|
+
draft.push_by_value[draft.push_selected] = self.push_url_edit.text()
|
|
560
|
+
draft.separate = self.separate_push_checkbox.isChecked()
|
|
561
|
+
draft.local_override = self.local_override_checkbox.isChecked()
|
|
562
|
+
|
|
563
|
+
def _url_selected(self, push: bool) -> None:
|
|
564
|
+
"""Retain the previous occurrence's draft before editing an explicitly selected URL."""
|
|
565
|
+
if self.loading or self.selected_name not in self.draft_by_name:
|
|
566
|
+
return
|
|
567
|
+
self._store_draft()
|
|
568
|
+
draft = self.draft_by_name[self.selected_name]
|
|
569
|
+
selected = (self.push_selector if push else self.fetch_selector).currentData()
|
|
570
|
+
if push:
|
|
571
|
+
draft.push_selected = selected
|
|
572
|
+
self.push_url_edit.setText(draft.push_by_value.get(selected, selected.value if selected else ""))
|
|
573
|
+
else:
|
|
574
|
+
draft.fetch_selected = selected
|
|
575
|
+
self.url_edit.setText(draft.fetch_by_value.get(selected, selected.value if selected else ""))
|
|
576
|
+
self._update_action()
|
|
577
|
+
|
|
578
|
+
def _toggle_push(self) -> None:
|
|
579
|
+
"""Show separate editors while preserving disabled push drafts and configured URLs."""
|
|
580
|
+
separate = self.separate_push_checkbox.isChecked()
|
|
581
|
+
self.url_label.setText("Fetch Url" if separate else "Url")
|
|
582
|
+
self.url_edit.setAccessibleName("Fetch Url" if separate else "Url")
|
|
583
|
+
self.push_label.setVisible(separate)
|
|
584
|
+
self.push_row.setVisible(separate)
|
|
585
|
+
self.push_selector.setVisible(separate and self.push_selector.count() > 1)
|
|
586
|
+
self.push_selector_label.setVisible(separate and self.push_selector.count() > 1)
|
|
587
|
+
self.fetch_selector.setVisible(self.fetch_selector.count() > 1)
|
|
588
|
+
self.fetch_selector_label.setVisible(self.fetch_selector.count() > 1)
|
|
589
|
+
self._update_action()
|
|
590
|
+
|
|
591
|
+
def typed_fetch_url(self) -> str:
|
|
592
|
+
"""Return the Url that Save sends to Git: the field's text without the blanks around it, ``~`` read where that is the usage.
|
|
593
|
+
|
|
594
|
+
Only a local folder address can change, since no HTTPS, SSH or ``file://`` address starts with ``~/``; the field
|
|
595
|
+
and the draft keep the text as typed.
|
|
596
|
+
"""
|
|
597
|
+
return typed_path_value(self.url_edit.text(), self.typed_path)
|
|
598
|
+
|
|
599
|
+
def typed_push_url(self) -> str:
|
|
600
|
+
"""Return the Push Url that Save sends to Git: the field's text without the blanks around it, ``~`` read where that is the usage.
|
|
601
|
+
|
|
602
|
+
Only a local folder address can change, since no HTTPS, SSH or ``file://`` address starts with ``~/``; the field
|
|
603
|
+
and the draft keep the text as typed.
|
|
604
|
+
"""
|
|
605
|
+
return typed_path_value(self.push_url_edit.text(), self.typed_path)
|
|
606
|
+
|
|
607
|
+
def _changes(self) -> tuple[RemoteChange, ...]:
|
|
608
|
+
"""Collect only edited fields and keep hidden push drafts out of this Save.
|
|
609
|
+
|
|
610
|
+
Names and addresses are compared and sent without the blanks around them, so a value that differs from Git only
|
|
611
|
+
by those blanks is no change; the drafts keep the text as typed. An address is sent with ``~`` read where that is
|
|
612
|
+
the usage, so Git gets the full path. It is a change only when both its trimmed text and that reading differ from
|
|
613
|
+
the configured value: an address Git already has, even one another tool wrote with ``~``, is rewritten only once
|
|
614
|
+
the user changes it, and ``~/…`` typed for the path already configured is no change.
|
|
615
|
+
"""
|
|
616
|
+
if self.snapshot is None or self.selected_name not in self.draft_by_name:
|
|
617
|
+
return ()
|
|
618
|
+
self._store_draft()
|
|
619
|
+
draft = self.draft_by_name[self.selected_name]
|
|
620
|
+
name = typed_value(draft.name)
|
|
621
|
+
fetch_url = self.typed_fetch_url()
|
|
622
|
+
push_url = self.typed_push_url()
|
|
623
|
+
changes: list[RemoteChange] = []
|
|
624
|
+
if self.selected_name is None:
|
|
625
|
+
changes.append(RemoteChange("add", fetch_url))
|
|
626
|
+
elif name != self.selected_name:
|
|
627
|
+
changes.append(RemoteChange("name", name))
|
|
628
|
+
if self.selected_name is not None and draft.fetch_selected is not None and self._address_changed(self.url_edit, fetch_url, draft.fetch_selected.value):
|
|
629
|
+
changes.append(RemoteChange("url", fetch_url, draft.fetch_selected))
|
|
630
|
+
if draft.separate and self._address_changed(self.push_url_edit, push_url, draft.push_selected.value if draft.push_selected else ""):
|
|
631
|
+
changes.append(RemoteChange("pushurl", push_url, draft.push_selected, draft.local_override))
|
|
632
|
+
return tuple(changes)
|
|
633
|
+
|
|
634
|
+
@staticmethod
|
|
635
|
+
def _address_changed(edit: QLineEdit, read_address: str, configured_address: str) -> bool:
|
|
636
|
+
"""Answer whether the address typed in ``edit`` is a change from ``configured_address``.
|
|
637
|
+
|
|
638
|
+
Neither the trimmed text nor its reading with ``~`` may be the configured value: the first keeps an address
|
|
639
|
+
written with ``~`` by another tool, the second keeps the full path typed back with ``~``.
|
|
640
|
+
"""
|
|
641
|
+
return typed_value(edit.text()) != configured_address and read_address != configured_address
|
|
642
|
+
|
|
643
|
+
def _update_action(self) -> None:
|
|
644
|
+
"""Explain ambiguous or inherited fields and enable only explicit valid edits."""
|
|
645
|
+
if self.loading:
|
|
646
|
+
return
|
|
647
|
+
ready = self.snapshot is not None and not self.busy and not self.closed and not self.uncertain and not self.read_failed
|
|
648
|
+
changes = self._changes()
|
|
649
|
+
valid = bool(changes)
|
|
650
|
+
try:
|
|
651
|
+
validate_name(typed_value(self.name_edit.text()))
|
|
652
|
+
except ValueError:
|
|
653
|
+
valid = False
|
|
654
|
+
for change in changes:
|
|
655
|
+
if not change.value or any(character in change.value for character in ("\0", "\n", "\r")):
|
|
656
|
+
valid = False
|
|
657
|
+
reasons: list[str] = []
|
|
658
|
+
inherited = False
|
|
659
|
+
if self.snapshot is not None and self.selected_name in self.snapshot.entry_by_name:
|
|
660
|
+
entry = self.snapshot.entry_by_name[self.selected_name]
|
|
661
|
+
inherited = any(value not in self.snapshot.direct_values for value in entry.values)
|
|
662
|
+
draft = self.draft_by_name[self.selected_name]
|
|
663
|
+
for field, selected, edit in (("url", draft.fetch_selected, self.url_edit), ("pushurl", draft.push_selected, self.push_url_edit)):
|
|
664
|
+
try:
|
|
665
|
+
reason = url_edit_reason(self.snapshot, self.selected_name, field, selected)
|
|
666
|
+
except ValueError as error:
|
|
667
|
+
reason = str(error)
|
|
668
|
+
edit.setReadOnly(bool(reason))
|
|
669
|
+
if reason:
|
|
670
|
+
reasons.append(reason)
|
|
671
|
+
if reason and any(change.field == field for change in changes):
|
|
672
|
+
valid = False
|
|
673
|
+
if inherited:
|
|
674
|
+
self.name_edit.setReadOnly(True)
|
|
675
|
+
reasons.append("Inherited configuration remains in its original file. Name cannot be changed here.")
|
|
676
|
+
if draft.push_selected is None and draft.separate and not draft.local_override:
|
|
677
|
+
reasons.append("Select Create local Push URL override to send pushes to a separate local destination.")
|
|
678
|
+
if any(change.field == "pushurl" for change in changes):
|
|
679
|
+
valid = False
|
|
680
|
+
else:
|
|
681
|
+
self.name_edit.setReadOnly(False)
|
|
682
|
+
self.url_edit.setReadOnly(False)
|
|
683
|
+
self.push_url_edit.setReadOnly(False)
|
|
684
|
+
if not inherited:
|
|
685
|
+
self.name_edit.setReadOnly(False)
|
|
686
|
+
entry = self.snapshot.entry_by_name.get(self.selected_name) if self.snapshot is not None else None
|
|
687
|
+
if entry is not None and not entry.active:
|
|
688
|
+
self.name_edit.setReadOnly(True)
|
|
689
|
+
reasons.append("Activate this remote before renaming it so Git can update its references and tracking.")
|
|
690
|
+
if any(change.field == "name" for change in changes):
|
|
691
|
+
valid = False
|
|
692
|
+
if entry is not None and entry.collision:
|
|
693
|
+
reasons.append("Active and inactive definitions collide. Review their configuration sources before making changes.")
|
|
694
|
+
valid = False
|
|
695
|
+
self.local_override_checkbox.setVisible(inherited and self.separate_push_checkbox.isChecked() and self.push_selector.count() == 0)
|
|
696
|
+
if not self.separate_push_checkbox.isChecked() and self.push_selector.count():
|
|
697
|
+
reasons.append("Configured Push URLs remain unchanged. Enable Separate Push Url to edit them.")
|
|
698
|
+
self.edit_reason_label.setText("\n".join(dict.fromkeys(reasons)))
|
|
699
|
+
self.save_button.setEnabled(ready and valid)
|
|
700
|
+
self.add_button.setEnabled(ready)
|
|
701
|
+
self.details.setEnabled(ready)
|
|
702
|
+
self.remote_tree.setEnabled(ready)
|
|
703
|
+
manageable = ready and entry is not None and not entry.collision
|
|
704
|
+
self.remove_button.setEnabled(manageable)
|
|
705
|
+
self.activate_button.setEnabled(manageable and not entry.active)
|
|
706
|
+
self.deactivate_button.setEnabled(manageable and entry.active)
|
|
707
|
+
self.tracking_tree.setEnabled(ready)
|
|
708
|
+
self.tracking_reload_button.setEnabled(not self.busy and not self.closed)
|
|
709
|
+
self.tracking_details.setEnabled(ready and self.selected_full_ref in (self.snapshot.tracking_by_ref if self.snapshot else {}))
|
|
710
|
+
if self.tabs.currentWidget() is self.tracking_page:
|
|
711
|
+
tracking_changes = self._tracking_changes()
|
|
712
|
+
valid = self.tracking_details.isEnabled() and bool(tracking_changes)
|
|
713
|
+
draft = self.tracking_draft_by_ref.get(self.selected_full_ref)
|
|
714
|
+
if draft is not None and (draft.stale or draft.snapshot.values != self.snapshot.values
|
|
715
|
+
or draft.snapshot.tracking_by_ref.get(self.selected_full_ref) != self.snapshot.tracking_by_ref.get(self.selected_full_ref)):
|
|
716
|
+
self.tracking_state_label.setText("Branch or configuration changed. Reread tracking before saving this branch.")
|
|
717
|
+
valid = False
|
|
718
|
+
for change in tracking_changes:
|
|
719
|
+
if not change.value or any(character in change.value for character in ("\0", "\n", "\r")) or change.field == MERGE_TRACKING_FIELD and not change.value.startswith(LOCAL_PREFIX):
|
|
720
|
+
valid = False
|
|
721
|
+
if tracking_values(self.snapshot, self.selected_full_ref, change.field) and change.selected is None:
|
|
722
|
+
valid = False
|
|
723
|
+
if change.selected is not None and change.selected not in self.snapshot.direct_values:
|
|
724
|
+
self.tracking_state_label.setText("This tracking value is inherited. Edit its original configuration source.")
|
|
725
|
+
valid = False
|
|
726
|
+
self.save_button.setEnabled(ready and valid)
|
|
727
|
+
self.tracking_save_button.setVisible(self.tabs.currentWidget() is self.tracking_page)
|
|
728
|
+
self.tracking_save_button.setEnabled(self.save_button.isEnabled())
|
|
729
|
+
|
|
730
|
+
def _set_active(self, active: bool) -> None:
|
|
731
|
+
"""Capture one section move while keeping unsaved field edits separate."""
|
|
732
|
+
button = self.activate_button if active else self.deactivate_button
|
|
733
|
+
if not button.isEnabled():
|
|
734
|
+
return
|
|
735
|
+
self._store_draft()
|
|
736
|
+
self.request_kind = "remote"
|
|
737
|
+
self.request = (self.snapshot, self.selected_name, active)
|
|
738
|
+
self.busy = True
|
|
739
|
+
self._update_action()
|
|
740
|
+
self.status_label.setText("Activating remote…" if active else "Deactivating remote…")
|
|
741
|
+
self.active_requested.emit(self.request)
|
|
742
|
+
|
|
743
|
+
def _remove(self) -> None:
|
|
744
|
+
"""Confirm the captured remote with cancellation as the default and Escape action."""
|
|
745
|
+
if not self.remove_button.isEnabled():
|
|
746
|
+
return
|
|
747
|
+
self._store_draft()
|
|
748
|
+
self.request_kind = "remote"
|
|
749
|
+
snapshot, name = self.snapshot, self.selected_name
|
|
750
|
+
self.busy = True
|
|
751
|
+
self._update_action()
|
|
752
|
+
confirmation = QMessageBox(self)
|
|
753
|
+
self.removal_dialog = confirmation
|
|
754
|
+
prepare_message_box(confirmation, REMOVE_REMOTE_TITLE)
|
|
755
|
+
confirmation.setTextFormat(Qt.TextFormat.PlainText)
|
|
756
|
+
confirmation.setText("Remove remote '" + name + "' from this repository? This does not delete the remote repository.")
|
|
757
|
+
remove = confirmation.addButton("Remove remote", QMessageBox.ButtonRole.DestructiveRole)
|
|
758
|
+
cancel = confirmation.addButton("Cancel removal", QMessageBox.ButtonRole.RejectRole)
|
|
759
|
+
confirmation.setDefaultButton(cancel)
|
|
760
|
+
confirmation.setEscapeButton(cancel)
|
|
761
|
+
|
|
762
|
+
def finished() -> None:
|
|
763
|
+
"""Emit only the confirmation's captured target while its parent is still live."""
|
|
764
|
+
self.removal_dialog = None
|
|
765
|
+
accepted = confirmation.clickedButton() is remove
|
|
766
|
+
confirmation.setParent(None)
|
|
767
|
+
confirmation.deleteLater()
|
|
768
|
+
if self.closed:
|
|
769
|
+
return
|
|
770
|
+
if not accepted:
|
|
771
|
+
self.busy = False
|
|
772
|
+
self.status_label.setText("Remote removal cancelled.")
|
|
773
|
+
self._update_action()
|
|
774
|
+
self.remove_button.setFocus()
|
|
775
|
+
return
|
|
776
|
+
self.request = (snapshot, name, None)
|
|
777
|
+
self.status_label.setText("Removing remote…")
|
|
778
|
+
self.remove_requested.emit(self.request)
|
|
779
|
+
|
|
780
|
+
confirmation.finished.connect(finished)
|
|
781
|
+
open_window_modal(confirmation)
|
|
782
|
+
|
|
783
|
+
def _save(self) -> None:
|
|
784
|
+
"""Freeze the selected draft before emitting a business intent."""
|
|
785
|
+
if not self.save_button.isEnabled():
|
|
786
|
+
return
|
|
787
|
+
if self.tabs.currentWidget() is self.tracking_page:
|
|
788
|
+
self.request_kind = "tracking"
|
|
789
|
+
draft = self.tracking_draft_by_ref[self.selected_full_ref]
|
|
790
|
+
self.request = (draft.snapshot, self.selected_full_ref, self._tracking_changes())
|
|
791
|
+
self.busy = True
|
|
792
|
+
self._update_action()
|
|
793
|
+
self.status_label.setText("Saving branch tracking…")
|
|
794
|
+
self.tracking_requested.emit(self.request)
|
|
795
|
+
return
|
|
796
|
+
self.request_kind = "remote"
|
|
797
|
+
self.request = (self.snapshot, typed_value(self.name_edit.text()) if self.selected_name is None else self.selected_name, self._changes())
|
|
798
|
+
self.busy = True
|
|
799
|
+
self._update_action()
|
|
800
|
+
self.status_label.setText("Saving remote…")
|
|
801
|
+
self.save_requested.emit(self.request)
|
|
802
|
+
|
|
803
|
+
def show_outcome(self, outcome: RemoteOutcome) -> None:
|
|
804
|
+
"""Retain failed field drafts and forbid replay after an uncertain native result."""
|
|
805
|
+
if self.request_kind == "tracking":
|
|
806
|
+
self._show_tracking_outcome(outcome)
|
|
807
|
+
return
|
|
808
|
+
self._store_draft()
|
|
809
|
+
old_name = self.selected_name
|
|
810
|
+
old_url, old_push = self.url_edit.text(), self.push_url_edit.text()
|
|
811
|
+
old_draft = self.draft_by_name.get(old_name)
|
|
812
|
+
self.busy = False
|
|
813
|
+
if outcome.steps and all(step.operation in ("Activate remote", "Deactivate remote", "Remove remote") for step in outcome.steps):
|
|
814
|
+
if getattr(outcome, "snapshot", None) is not None:
|
|
815
|
+
# Section moves keep the same fields; remap occurrence identities without losing drafts.
|
|
816
|
+
removing = any(step.operation == "Remove remote" for step in outcome.steps)
|
|
817
|
+
if old_draft is not None and old_name in outcome.snapshot.entry_by_name:
|
|
818
|
+
prefix = "remote." if outcome.snapshot.entry_by_name.get(old_name, self.snapshot.entry_by_name[old_name]).active else INACTIVE_REMOTE_SECTION
|
|
819
|
+
for field_name, config_field in (("fetch", "url"), ("push", "pushurl")):
|
|
820
|
+
value_by_selected = getattr(old_draft, field_name + "_by_value")
|
|
821
|
+
remapped_by_value: dict[RemoteValue | None, str] = {}
|
|
822
|
+
for value, text in value_by_selected.items():
|
|
823
|
+
remapped_by_value[replace(value, key=prefix + value.key.removeprefix("-").removeprefix("remote.")) if value else None] = text
|
|
824
|
+
setattr(old_draft, field_name + "_by_value", remapped_by_value)
|
|
825
|
+
selected = getattr(old_draft, field_name + "_selected")
|
|
826
|
+
if selected is not None:
|
|
827
|
+
selected = replace(selected, key=prefix + selected.key.removeprefix("-").removeprefix("remote."))
|
|
828
|
+
values = field_values(outcome.snapshot, old_name, config_field)
|
|
829
|
+
if selected not in values:
|
|
830
|
+
text = remapped_by_value.get(selected, selected.value)
|
|
831
|
+
selected = next(iter(values)) if len(values) == 1 else None
|
|
832
|
+
remapped_by_value[selected] = text
|
|
833
|
+
setattr(old_draft, field_name + "_selected", selected)
|
|
834
|
+
if removing and old_name not in outcome.snapshot.entry_by_name and outcome.status == OperationStatus.SUCCESS:
|
|
835
|
+
self.draft_by_name.pop(old_name, None)
|
|
836
|
+
self.populate(outcome.snapshot, old_name, select_first=False)
|
|
837
|
+
self.uncertain = outcome.status == OperationStatus.UNKNOWN
|
|
838
|
+
self.status_label.setText("\n".join(step.operation + ": " + step.status.value for step in outcome.steps))
|
|
839
|
+
if outcome.diagnostic is not None:
|
|
840
|
+
self.status_label.setText(self.status_label.text() + "\n" + (outcome.diagnostic.detail or outcome.diagnostic.cause))
|
|
841
|
+
self._update_action()
|
|
842
|
+
return
|
|
843
|
+
if getattr(outcome, "snapshot", None) is not None:
|
|
844
|
+
self.draft_by_name.pop(old_name, None)
|
|
845
|
+
self.draft_by_name.pop(outcome.selected_name, None)
|
|
846
|
+
self.populate(outcome.snapshot, outcome.selected_name)
|
|
847
|
+
failed_field_set = {step.before.field for step in outcome.steps if isinstance(step.before, RemoteChange) and step.status != OperationStatus.SUCCESS}
|
|
848
|
+
if "url" in failed_field_set:
|
|
849
|
+
self.url_edit.setText(old_url)
|
|
850
|
+
if "pushurl" in failed_field_set:
|
|
851
|
+
self.separate_push_checkbox.setChecked(True)
|
|
852
|
+
self.push_url_edit.setText(old_push)
|
|
853
|
+
if "name" in failed_field_set and old_draft is not None:
|
|
854
|
+
self.name_edit.setText(old_draft.name)
|
|
855
|
+
if old_draft is not None and not old_draft.separate:
|
|
856
|
+
self.separate_push_checkbox.setChecked(False)
|
|
857
|
+
self.push_url_edit.setText(old_push)
|
|
858
|
+
self.uncertain = outcome.status == OperationStatus.UNKNOWN or any(step.status == OperationStatus.UNKNOWN or step.process_status == ProcessStatus.SUCCESS and step.after is None for step in outcome.steps)
|
|
859
|
+
messages: list[str] = []
|
|
860
|
+
for step in outcome.steps:
|
|
861
|
+
messages.append(step.operation + ": " + step.status.value)
|
|
862
|
+
if outcome.diagnostic is not None:
|
|
863
|
+
messages.append(outcome.diagnostic.detail or outcome.diagnostic.cause)
|
|
864
|
+
self.status_label.setText("\n".join(messages))
|
|
865
|
+
self._update_action()
|
|
866
|
+
|
|
867
|
+
def _show_tracking_outcome(self, outcome: RemoteOutcome) -> None:
|
|
868
|
+
"""Publish observed tracking while keeping failed field buffers available."""
|
|
869
|
+
self._store_tracking_draft()
|
|
870
|
+
full_ref = self.selected_full_ref
|
|
871
|
+
draft = self.tracking_draft_by_ref.get(full_ref)
|
|
872
|
+
self.busy = False
|
|
873
|
+
snapshot = getattr(outcome, "snapshot", None)
|
|
874
|
+
if snapshot is not None:
|
|
875
|
+
if draft is not None and outcome.status in (OperationStatus.SUCCESS, OperationStatus.PARTIAL):
|
|
876
|
+
successful_field_set = {step.before.field for step in outcome.steps if isinstance(step.before, RemoteChange) and step.status == OperationStatus.SUCCESS}
|
|
877
|
+
self._rebase_tracking_draft(draft, snapshot, successful_field_set)
|
|
878
|
+
elif draft is not None and outcome.status == OperationStatus.REFUSED:
|
|
879
|
+
draft.stale = True
|
|
880
|
+
self.populate(snapshot, self.selected_name, select_first=False, preserve_tracking=False)
|
|
881
|
+
self.uncertain = outcome.status == OperationStatus.UNKNOWN or any(step.status == OperationStatus.UNKNOWN for step in outcome.steps)
|
|
882
|
+
texts: list[str] = []
|
|
883
|
+
for step in outcome.steps:
|
|
884
|
+
texts.append(step.operation + ": " + step.status.value)
|
|
885
|
+
if outcome.diagnostic is not None:
|
|
886
|
+
texts.append(outcome.diagnostic.detail or outcome.diagnostic.cause)
|
|
887
|
+
self.status_label.setText("\n".join(texts))
|
|
888
|
+
self._update_action()
|
|
889
|
+
|
|
890
|
+
def show_error(self, cause: str) -> None:
|
|
891
|
+
"""Keep the form unavailable after an incomplete initial read."""
|
|
892
|
+
self.busy = False
|
|
893
|
+
self.read_failed = True
|
|
894
|
+
self.status_label.setText(cause)
|
|
895
|
+
self.tracking_state_label.setText("Branch tracking could not be read. Reread tracking after reviewing the diagnostic.")
|
|
896
|
+
self._update_action()
|
|
897
|
+
|
|
898
|
+
def _browse(self, push: bool = False) -> None:
|
|
899
|
+
"""Choose a local destination asynchronously, from the folder the typed address stands for, without writing its remote.
|
|
900
|
+
|
|
901
|
+
``~`` is read where that is the usage; an address that is not an absolute folder leaves the selector at its own
|
|
902
|
+
start folder.
|
|
903
|
+
"""
|
|
904
|
+
edit = self.push_url_edit if push else self.url_edit
|
|
905
|
+
self._open_folder_chooser(typed_start_dir(edit.text(), self.typed_path), edit)
|
|
906
|
+
|
|
907
|
+
def _open_folder_chooser(self, start_dir: str, edit: QLineEdit) -> None:
|
|
908
|
+
"""Open the directory selector at ``start_dir`` and write the chosen folder into ``edit``; cancelling changes nothing."""
|
|
909
|
+
dialog = QFileDialog(self, "Choose remote repository", start_dir)
|
|
910
|
+
dialog.setFileMode(QFileDialog.FileMode.Directory)
|
|
911
|
+
dialog.fileSelected.connect(lambda filename: edit.setText(filename) if not self.closed else None)
|
|
912
|
+
dialog.open()
|
|
913
|
+
|
|
914
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
915
|
+
"""Fit the logical dialog bounds to the available screen without scaling twice."""
|
|
916
|
+
fit_form(self, available)
|
|
917
|
+
for tree in (self.remote_tree, self.tracking_tree):
|
|
918
|
+
keep_selection_visible(tree)
|
|
919
|
+
if not tree.styleSheet():
|
|
920
|
+
# Windows can paint an unfocused selection gray despite its palette; use the system accent explicitly.
|
|
921
|
+
tree.setStyleSheet("QTreeView::item:selected { background-color: palette(highlight); color: palette(highlighted-text); }")
|
|
922
|
+
header = self.tracking_tree.header()
|
|
923
|
+
for column in (TRACKING_NAME_COLUMN, TRACKING_REMOTE_COLUMN):
|
|
924
|
+
caption_width = header.fontMetrics().horizontalAdvance(self.tracking_tree.headerItem().text(column)) + SPACING_MD * 2
|
|
925
|
+
header.resizeSection(column, max(header.sectionSize(column), caption_width))
|
|
926
|
+
|
|
927
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
928
|
+
"""Keep both selected rows legible after a system palette update."""
|
|
929
|
+
super().changeEvent(event)
|
|
930
|
+
if event.type() == QEvent.Type.PaletteChange and hasattr(self, "tracking_tree"):
|
|
931
|
+
for tree in (self.remote_tree, self.tracking_tree):
|
|
932
|
+
keep_selection_visible(tree)
|
|
933
|
+
|
|
934
|
+
def done(self, result: int) -> None:
|
|
935
|
+
"""Invalidate pending intentions and restore focus after abandoning local drafts."""
|
|
936
|
+
self.closed = True
|
|
937
|
+
self.draft_by_name.clear()
|
|
938
|
+
self.tracking_draft_by_ref.clear()
|
|
939
|
+
if self.removal_dialog is not None:
|
|
940
|
+
self.removal_dialog.reject()
|
|
941
|
+
super().done(result)
|
|
942
|
+
if self.return_focus is not None and isValid(self.return_focus):
|
|
943
|
+
self.return_focus.setFocus()
|