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,724 @@
|
|
|
1
|
+
"""Show the Accounts page of Settings, from the list of connected service accounts to the account each address uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QEvent, Qt, Signal
|
|
9
|
+
from PySide6.QtGui import QBrush, QColor, QFont
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QApplication,
|
|
12
|
+
QComboBox,
|
|
13
|
+
QGridLayout,
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QHeaderView,
|
|
17
|
+
QLabel,
|
|
18
|
+
QMessageBox,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QTreeWidget,
|
|
21
|
+
QTreeWidgetItem,
|
|
22
|
+
QVBoxLayout,
|
|
23
|
+
QWidget,
|
|
24
|
+
)
|
|
25
|
+
from shiboken6 import isValid
|
|
26
|
+
|
|
27
|
+
from gitpane.account.account_choice import CHOOSE_DEFAULT_TEXT, global_rows, offers_account, repository_account_view, service_text
|
|
28
|
+
from gitpane.account.authentication import AccountCatalogue, SignInMethod, origin_address
|
|
29
|
+
from gitpane.config import SPACING_MD, SPACING_SM
|
|
30
|
+
from gitpane.operation.operation_status import OperationStatus
|
|
31
|
+
from gitpane.ui.look.theme_color import is_dark_palette
|
|
32
|
+
from gitpane.ui.look.theme_look import PALETTE_EVENT_TYPE_SET
|
|
33
|
+
from gitpane.ui.setting.setting_block import GLOBAL_TITLE, REPOSITORY_TITLE, block_group
|
|
34
|
+
from gitpane.ui.widget.long_text import FRAME_SIDE_COUNT
|
|
35
|
+
from gitpane.ui.widget.plain_label import plain_label
|
|
36
|
+
from gitpane.ui.widget.result_label import ResultLabel, ResultTone, result_color
|
|
37
|
+
from gitpane.ui.window_control.dialog_modality import open_window_modal, prepare_message_box
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from gitpane.account.account_choice import GlobalRow, RepositoryRow
|
|
41
|
+
from gitpane.account.authentication import AccountRemoval, AccountSelection, AuthOutcome
|
|
42
|
+
from gitpane.transport.remote_config import RemoteEntry
|
|
43
|
+
from gitpane.ui.setting.add_account_dialog import AddAccountDialog
|
|
44
|
+
|
|
45
|
+
SERVICE_COLUMN, ADDRESS_COLUMN, ACCOUNT_COLUMN, TYPE_COLUMN, STATUS_COLUMN = 0, 1, 2, 3, 4
|
|
46
|
+
ACCOUNT_COLUMN_TITLES = ("Service", "Address", "Account", "Type", "Status")
|
|
47
|
+
UNKNOWN_TYPE_TOOLTIP_TEXT = "This account was added before GitPane recorded how accounts sign in. Reconnect it to record it."
|
|
48
|
+
# Shown for an account whose stored secret is gone, typically erased by Git after a server refused it.
|
|
49
|
+
NEEDS_RECONNECT_STATUS_TEXT = "Needs reconnect"
|
|
50
|
+
NEEDS_RECONNECT_TOOLTIP_TEXT = "Its saved sign-in is no longer in the system credential store. Select it and click Reconnect."
|
|
51
|
+
# Shown for an account whose stored secret a check of the system credential store found.
|
|
52
|
+
OK_STATUS_TEXT = "OK"
|
|
53
|
+
# Every text the Status column can show; it keeps the width of the widest one so it never jumps or hides when a check answers.
|
|
54
|
+
STATUS_TEXTS = (OK_STATUS_TEXT, NEEDS_RECONNECT_STATUS_TEXT)
|
|
55
|
+
# Columns as wide as their content; Address takes the rest of the list and cuts a long address with an ellipsis.
|
|
56
|
+
CONTENT_WIDTH_COLUMNS = (SERVICE_COLUMN, ACCOUNT_COLUMN, TYPE_COLUMN)
|
|
57
|
+
# The single column of the hidden list that measures the Status texts.
|
|
58
|
+
PROBE_COLUMN = 0
|
|
59
|
+
ACCOUNT_LIST_STRETCH = 1
|
|
60
|
+
EMPTY_ACCOUNT_LIST_TEXT = "No connected accounts yet. Use Add account… to connect one."
|
|
61
|
+
ACCOUNTS_PAGE_NAME = "Accounts"
|
|
62
|
+
PLACE_SEPARATOR = " › "
|
|
63
|
+
UNSAVED_MARK = " *"
|
|
64
|
+
EMPTY_GLOBAL_TEXT = "No connected accounts yet. Use Add account… above."
|
|
65
|
+
GLOBAL_SAVED_TEXT = "Global settings saved."
|
|
66
|
+
NO_REPOSITORY_ACCOUNT_TEXT = "Open a repository to choose the account it uses."
|
|
67
|
+
READING_REMOTES_TEXT = "Reading this repository's remotes…"
|
|
68
|
+
NO_HTTPS_REMOTE_TEXT = "This repository has no HTTPS remote."
|
|
69
|
+
MISSING_CHOICE_TEXT = "The account chosen for this repository is no longer connected. Choose another account."
|
|
70
|
+
SUBMODULE_HELP_TEXT = "This account is used for this repository only. Submodules use their own setting, or the global default for their service."
|
|
71
|
+
REPOSITORY_SAVED_TEXT = "Repository settings saved."
|
|
72
|
+
CREDENTIAL_MANAGER_GROUP_TITLE = "Git credential manager"
|
|
73
|
+
# Shown in the credential manager line while the search started by the opening of Settings runs.
|
|
74
|
+
CREDENTIAL_MANAGER_CHECKING_TEXT = "Checking…"
|
|
75
|
+
SIGN_IN_PLACE_NAME = "Sign-in in progress"
|
|
76
|
+
ROW_LABEL_COLUMN, ROW_CHOICE_COLUMN = 0, 1
|
|
77
|
+
ROW_COLUMN_COUNT = 2
|
|
78
|
+
ROW_SPAN = 1
|
|
79
|
+
ROW_CHOICE_STRETCH = 1
|
|
80
|
+
NO_SELECTION_INDEX = -1
|
|
81
|
+
FOLLOW_DEFAULT_INDEX = 0
|
|
82
|
+
FIRST_LAYOUT_ITEM_INDEX = 0
|
|
83
|
+
EMPTY_CATALOGUE = AccountCatalogue((), {}, {})
|
|
84
|
+
REMOVE_ACCOUNT_BUTTON_TEXT = "Remove account…"
|
|
85
|
+
REMOVE_TITLE = "Remove account"
|
|
86
|
+
REMOVE_QUESTION_TEXT = "Remove {label} ({address}) from Connected accounts?"
|
|
87
|
+
REMOVE_DETAIL_TEXT = ("Its stored password or token is erased from this computer. Repositories and their files do not change. "
|
|
88
|
+
"Where this account was the default or a repository's choice, GitPane will ask for another account.")
|
|
89
|
+
REMOVE_CONFIRM_TEXT = "Remove account"
|
|
90
|
+
KEEP_ACCOUNT_TEXT = "Keep account"
|
|
91
|
+
REMOVING_TEXT = "Removing {label}…"
|
|
92
|
+
REMOVED_TEXT = "{label} was removed from Connected accounts."
|
|
93
|
+
DEFAULT_CLEARED_TEXT = " Choose a new default account for {address} in Global."
|
|
94
|
+
REPOSITORY_CHOICE_CLEARED_TEXT = " Repositories that used it must choose another account."
|
|
95
|
+
NOT_ERASED_TEXT = "{label} was not removed: its stored password or token could not be erased. "
|
|
96
|
+
# Start of the Accounts page result when the removal was refused before anything was erased.
|
|
97
|
+
ACCOUNT_NOT_REMOVED_TEXT = "{label} was not removed. "
|
|
98
|
+
LIST_NOT_SAVED_TEXT = "The stored password or token of {label} was erased, but Connected accounts could not be saved. "
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def removal_result_text(selection: AccountSelection, removal: AccountRemoval) -> str:
|
|
102
|
+
"""Say that the account is gone and what the user now has to choose again.
|
|
103
|
+
|
|
104
|
+
A cleared default is only worth mentioning while its address still has an account to choose; the next account added
|
|
105
|
+
there becomes its default by itself.
|
|
106
|
+
"""
|
|
107
|
+
text = REMOVED_TEXT.format(label=selection.label)
|
|
108
|
+
if removal.default_cleared and removal.address_has_account:
|
|
109
|
+
text += DEFAULT_CLEARED_TEXT.format(address=origin_address(selection.origin))
|
|
110
|
+
if removal.repository_choice_count:
|
|
111
|
+
text += REPOSITORY_CHOICE_CLEARED_TEXT
|
|
112
|
+
return text
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def removal_failure_text(selection: AccountSelection, outcome: AuthOutcome) -> str:
|
|
116
|
+
"""Say which step of a removal failed, followed by the prepared cause, never the raw helper output.
|
|
117
|
+
|
|
118
|
+
A removal refused before anything ran, for example while a sign-in is still running or when the credential manager
|
|
119
|
+
found cannot be used, erased nothing, so only the service's reason is worth showing.
|
|
120
|
+
"""
|
|
121
|
+
cause = outcome.diagnostic.cause if outcome.diagnostic is not None and outcome.diagnostic.cause else outcome.status.value
|
|
122
|
+
if outcome.status == OperationStatus.PARTIAL:
|
|
123
|
+
return LIST_NOT_SAVED_TEXT.format(label=selection.label) + cause
|
|
124
|
+
if outcome.refused_before_start:
|
|
125
|
+
return ACCOUNT_NOT_REMOVED_TEXT.format(label=selection.label) + cause
|
|
126
|
+
return NOT_ERASED_TEXT.format(label=selection.label) + cause
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def fill_global_combo(combo: QComboBox, row: GlobalRow, selected_namespace: str | None) -> None:
|
|
130
|
+
"""Offer every account of the row's address and show the chosen one; with none chosen the menu stays empty and asks for a choice.
|
|
131
|
+
|
|
132
|
+
There is no 'no default' entry: a default can only be replaced by another account. Signals are blocked so filling
|
|
133
|
+
the menu is never taken for a user choice.
|
|
134
|
+
"""
|
|
135
|
+
combo.blockSignals(True)
|
|
136
|
+
combo.clear()
|
|
137
|
+
combo.setPlaceholderText(CHOOSE_DEFAULT_TEXT)
|
|
138
|
+
selected_index = NO_SELECTION_INDEX
|
|
139
|
+
for choice in row.choices:
|
|
140
|
+
combo.addItem(choice.text, choice.namespace)
|
|
141
|
+
if choice.namespace == selected_namespace:
|
|
142
|
+
selected_index = combo.count() - 1
|
|
143
|
+
combo.setCurrentIndex(selected_index)
|
|
144
|
+
combo.blockSignals(False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fill_repository_combo(combo: QComboBox, row: RepositoryRow, selected_namespace: str | None) -> None:
|
|
148
|
+
"""Offer 'Use global default — …' first, then every account of the row's address, and show the chosen one.
|
|
149
|
+
|
|
150
|
+
The first entry carries no account, so choosing it means following the Global default. An address without any
|
|
151
|
+
connected account leaves only that entry, which then explains how to add one, in a disabled menu. Signals are
|
|
152
|
+
blocked so filling the menu is never taken for a user choice.
|
|
153
|
+
"""
|
|
154
|
+
combo.blockSignals(True)
|
|
155
|
+
combo.clear()
|
|
156
|
+
combo.addItem(row.default_text, None)
|
|
157
|
+
selected_index = FOLLOW_DEFAULT_INDEX
|
|
158
|
+
for choice in row.choices:
|
|
159
|
+
combo.addItem(choice.text, choice.namespace)
|
|
160
|
+
if choice.namespace == selected_namespace:
|
|
161
|
+
selected_index = combo.count() - 1
|
|
162
|
+
combo.setCurrentIndex(selected_index)
|
|
163
|
+
combo.setEnabled(bool(row.choices))
|
|
164
|
+
combo.blockSignals(False)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def repository_draft_needed(row: RepositoryRow, namespace: str | None) -> bool:
|
|
168
|
+
"""Tell whether choosing this account for the row leaves something to save.
|
|
169
|
+
|
|
170
|
+
A saved choice naming an account no longer listed must be replaced, so any choice on such a row is worth saving.
|
|
171
|
+
"""
|
|
172
|
+
return row.missing_choice or namespace != row.chosen_namespace
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def clear_grid(grid: QGridLayout) -> None:
|
|
176
|
+
"""Remove and delete every widget of a grid so its rows can be built again."""
|
|
177
|
+
while grid.count():
|
|
178
|
+
item = grid.takeAt(FIRST_LAYOUT_ITEM_INDEX)
|
|
179
|
+
widget = item.widget()
|
|
180
|
+
if widget is not None:
|
|
181
|
+
widget.setParent(None)
|
|
182
|
+
widget.deleteLater()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def row_grid() -> QGridLayout:
|
|
186
|
+
"""Build the two-column grid of a choice block: the address on the left, its menu on the right."""
|
|
187
|
+
grid = QGridLayout()
|
|
188
|
+
grid.setHorizontalSpacing(SPACING_MD)
|
|
189
|
+
grid.setVerticalSpacing(SPACING_SM)
|
|
190
|
+
grid.setColumnStretch(ROW_CHOICE_COLUMN, ROW_CHOICE_STRETCH)
|
|
191
|
+
return grid
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AccountPage(QWidget):
|
|
195
|
+
"""List every connected account, let the user choose the accounts used, and ask the account controller to save; no account rule lives here.
|
|
196
|
+
|
|
197
|
+
Each choice block keeps a draft of the rows the user changed. A refresh rebuilds every row but shows the draft for a
|
|
198
|
+
changed row, so a new account, an automatic default or a write from another window never erases what the user chose.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
add_account_requested = Signal()
|
|
202
|
+
reconnect_requested = Signal()
|
|
203
|
+
remove_account_requested = Signal(str)
|
|
204
|
+
save_global_requested = Signal()
|
|
205
|
+
save_repository_requested = Signal()
|
|
206
|
+
|
|
207
|
+
def __init__(self, repository_dir: str | None, parent: QWidget | None = None) -> None:
|
|
208
|
+
"""Build the Connected accounts list and the choice blocks; ``repository_dir`` is the repository these settings were opened for, if any."""
|
|
209
|
+
super().__init__(parent)
|
|
210
|
+
self.repository_dir = repository_dir
|
|
211
|
+
self.sign_in_dialog: AddAccountDialog | None = None
|
|
212
|
+
self.remove_confirmation: QMessageBox | None = None
|
|
213
|
+
self.removing = False
|
|
214
|
+
self.catalogue = EMPTY_CATALOGUE
|
|
215
|
+
# Accounts whose stored secret a check found. Status says OK only for them: a check after a refused transfer
|
|
216
|
+
# covers a single account, so an answer says nothing about the accounts it did not check.
|
|
217
|
+
self.secret_found_namespace_set: set[str] = set()
|
|
218
|
+
self.global_rows: tuple[GlobalRow, ...] = ()
|
|
219
|
+
self.global_combo_by_origin: dict[str, QComboBox] = {}
|
|
220
|
+
self.global_label_by_origin: dict[str, QLabel] = {}
|
|
221
|
+
self.global_draft_namespace_by_origin: dict[str, str] = {}
|
|
222
|
+
# None until the repository's remotes have been read; the account controller bumps the generation for each new read.
|
|
223
|
+
self.remote_entries: tuple[RemoteEntry, ...] | None = None
|
|
224
|
+
self.remote_generation = 0
|
|
225
|
+
self.repository_rows: tuple[RepositoryRow, ...] = ()
|
|
226
|
+
self.repository_ssh_notes: tuple[str, ...] = ()
|
|
227
|
+
self.repository_combo_by_origin: dict[str, QComboBox] = {}
|
|
228
|
+
self.repository_label_by_origin: dict[str, QLabel] = {}
|
|
229
|
+
# None as a draft value means the row follows the Global default.
|
|
230
|
+
self.repository_draft_namespace_by_origin: dict[str, str | None] = {}
|
|
231
|
+
layout = QVBoxLayout(self)
|
|
232
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
233
|
+
layout.setSpacing(SPACING_SM)
|
|
234
|
+
# Above the list, so the reason Settings opened by itself is the first thing read.
|
|
235
|
+
self.account_notice_label = ResultLabel()
|
|
236
|
+
self.account_notice_label.setVisible(False)
|
|
237
|
+
self.account_notice_address = ""
|
|
238
|
+
layout.addWidget(self.account_notice_label)
|
|
239
|
+
self.account_group = QGroupBox("Connected accounts")
|
|
240
|
+
body = QHBoxLayout(self.account_group)
|
|
241
|
+
body.setSpacing(SPACING_SM)
|
|
242
|
+
self.account_tree = QTreeWidget()
|
|
243
|
+
self.account_tree.setColumnCount(len(ACCOUNT_COLUMN_TITLES))
|
|
244
|
+
self.account_tree.setHeaderLabels(list(ACCOUNT_COLUMN_TITLES))
|
|
245
|
+
self.account_tree.setRootIsDecorated(False)
|
|
246
|
+
self.account_tree.setAccessibleName("Connected accounts")
|
|
247
|
+
header = self.account_tree.header()
|
|
248
|
+
header.setStretchLastSection(False)
|
|
249
|
+
for column in CONTENT_WIDTH_COLUMNS:
|
|
250
|
+
header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents)
|
|
251
|
+
header.setSectionResizeMode(ADDRESS_COLUMN, QHeaderView.ResizeMode.Stretch)
|
|
252
|
+
header.setSectionResizeMode(STATUS_COLUMN, QHeaderView.ResizeMode.Fixed)
|
|
253
|
+
self._fit_status_column()
|
|
254
|
+
body.addWidget(self.account_tree, ACCOUNT_LIST_STRETCH)
|
|
255
|
+
tools = QVBoxLayout()
|
|
256
|
+
self.add_account_button = QPushButton("Add account…")
|
|
257
|
+
self.add_account_button.setAutoDefault(False)
|
|
258
|
+
self.add_account_button.clicked.connect(self.add_account_requested)
|
|
259
|
+
self.reconnect_button = QPushButton("Reconnect")
|
|
260
|
+
self.reconnect_button.setAutoDefault(False)
|
|
261
|
+
self.reconnect_button.setEnabled(False)
|
|
262
|
+
self.reconnect_button.clicked.connect(self.reconnect_requested)
|
|
263
|
+
self.remove_account_button = QPushButton(REMOVE_ACCOUNT_BUTTON_TEXT)
|
|
264
|
+
self.remove_account_button.setAutoDefault(False)
|
|
265
|
+
self.remove_account_button.setEnabled(False)
|
|
266
|
+
self.remove_account_button.clicked.connect(self._confirm_removal)
|
|
267
|
+
tools.addWidget(self.add_account_button)
|
|
268
|
+
tools.addWidget(self.reconnect_button)
|
|
269
|
+
tools.addWidget(self.remove_account_button)
|
|
270
|
+
tools.addStretch()
|
|
271
|
+
body.addLayout(tools)
|
|
272
|
+
layout.addWidget(self.account_group)
|
|
273
|
+
self.status_label = ResultLabel()
|
|
274
|
+
layout.addWidget(self.status_label)
|
|
275
|
+
self.global_group, global_layout = block_group(GLOBAL_TITLE)
|
|
276
|
+
global_layout.addWidget(plain_label("Default account of each service address, used by every repository that does not choose its own."))
|
|
277
|
+
self.global_grid = row_grid()
|
|
278
|
+
global_layout.addLayout(self.global_grid)
|
|
279
|
+
self.global_empty_label = plain_label(EMPTY_GLOBAL_TEXT)
|
|
280
|
+
global_layout.addWidget(self.global_empty_label)
|
|
281
|
+
self.global_status_label = ResultLabel()
|
|
282
|
+
global_layout.addWidget(self.global_status_label)
|
|
283
|
+
self.save_global_button = QPushButton("Save global settings")
|
|
284
|
+
self.save_global_button.setAutoDefault(False)
|
|
285
|
+
self.save_global_button.clicked.connect(self.save_global_requested)
|
|
286
|
+
global_layout.addWidget(self.save_global_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
|
287
|
+
layout.addWidget(self.global_group)
|
|
288
|
+
self.repository_group, repository_layout = block_group(REPOSITORY_TITLE)
|
|
289
|
+
self.repository_path_label = plain_label(repository_dir if repository_dir is not None else NO_REPOSITORY_ACCOUNT_TEXT)
|
|
290
|
+
repository_layout.addWidget(self.repository_path_label)
|
|
291
|
+
self.repository_remote_label = plain_label(READING_REMOTES_TEXT if repository_dir is not None else "")
|
|
292
|
+
repository_layout.addWidget(self.repository_remote_label)
|
|
293
|
+
self.repository_grid = row_grid()
|
|
294
|
+
repository_layout.addLayout(self.repository_grid)
|
|
295
|
+
self.repository_help_label = plain_label(SUBMODULE_HELP_TEXT)
|
|
296
|
+
repository_layout.addWidget(self.repository_help_label)
|
|
297
|
+
self.repository_status_label = ResultLabel()
|
|
298
|
+
repository_layout.addWidget(self.repository_status_label)
|
|
299
|
+
self.save_repository_button = QPushButton("Save this repository settings")
|
|
300
|
+
self.save_repository_button.setAutoDefault(False)
|
|
301
|
+
self.save_repository_button.clicked.connect(self.save_repository_requested)
|
|
302
|
+
repository_layout.addWidget(self.save_repository_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
|
303
|
+
self.repository_group.setEnabled(repository_dir is not None)
|
|
304
|
+
layout.addWidget(self.repository_group)
|
|
305
|
+
# Last block, so the accounts come first. Read only and without any button: opening Settings again searches again.
|
|
306
|
+
self.credential_manager_group, credential_manager_layout = block_group(CREDENTIAL_MANAGER_GROUP_TITLE)
|
|
307
|
+
self.credential_manager_label = ResultLabel()
|
|
308
|
+
credential_manager_layout.addWidget(self.credential_manager_label)
|
|
309
|
+
layout.addWidget(self.credential_manager_group)
|
|
310
|
+
layout.addStretch()
|
|
311
|
+
self.account_tree.itemSelectionChanged.connect(self._selection_changed)
|
|
312
|
+
self._render_global()
|
|
313
|
+
self._refresh_block_state()
|
|
314
|
+
|
|
315
|
+
def show_credential_manager_checking(self) -> None:
|
|
316
|
+
"""Say, in the ordinary tone, that the search for the credential manager is still running."""
|
|
317
|
+
self.credential_manager_label.show_result(CREDENTIAL_MANAGER_CHECKING_TEXT, ResultTone.NEUTRAL)
|
|
318
|
+
|
|
319
|
+
def show_credential_manager(self, text: str, refused: bool) -> None:
|
|
320
|
+
"""Show which credential manager GitPane uses in the ordinary tone, or why none is usable in red, like the red line of a refused operation."""
|
|
321
|
+
if refused:
|
|
322
|
+
self.credential_manager_label.show_failure(text)
|
|
323
|
+
return
|
|
324
|
+
self.credential_manager_label.show_result(text, ResultTone.NEUTRAL)
|
|
325
|
+
|
|
326
|
+
def show_account_notice(self, text: str, address: str) -> None:
|
|
327
|
+
"""Show in orange, above Connected accounts, why an operation needs an account here; a newer refusal replaces an older one.
|
|
328
|
+
|
|
329
|
+
``address`` is kept so that only a sign-in on that same address clears the notice.
|
|
330
|
+
"""
|
|
331
|
+
self.account_notice_address = address
|
|
332
|
+
self.account_notice_label.show_warning(text)
|
|
333
|
+
self.account_notice_label.setVisible(True)
|
|
334
|
+
|
|
335
|
+
def clear_account_notice(self, address: str = "") -> None:
|
|
336
|
+
"""Hide the refusal notice once the account it asked for is connected; an empty address clears any notice.
|
|
337
|
+
|
|
338
|
+
Host names are compared regardless of case, since a typed URL keeps whatever case the user wrote.
|
|
339
|
+
"""
|
|
340
|
+
if address and address.casefold() != self.account_notice_address.casefold():
|
|
341
|
+
return
|
|
342
|
+
self.account_notice_address = ""
|
|
343
|
+
self.account_notice_label.clear()
|
|
344
|
+
self.account_notice_label.setVisible(False)
|
|
345
|
+
|
|
346
|
+
def show_catalogue(self, catalogue: AccountCatalogue) -> None:
|
|
347
|
+
"""Show a fresh read of the accounts: the list in the catalogue's order and every choice row, keeping the rows the user changed.
|
|
348
|
+
|
|
349
|
+
The Type column says how each account signs in: browser, token, or username + password.
|
|
350
|
+
"""
|
|
351
|
+
self.catalogue = catalogue
|
|
352
|
+
selected_namespace = self.selected_namespace()
|
|
353
|
+
self.account_tree.clear()
|
|
354
|
+
selected_item = None
|
|
355
|
+
for selection in catalogue.accounts:
|
|
356
|
+
item = QTreeWidgetItem()
|
|
357
|
+
item.setText(SERVICE_COLUMN, service_text(catalogue, selection))
|
|
358
|
+
item.setText(ADDRESS_COLUMN, origin_address(selection.origin))
|
|
359
|
+
item.setText(ACCOUNT_COLUMN, selection.label)
|
|
360
|
+
sign_in_method = catalogue.sign_in_method_by_namespace.get(selection.namespace, SignInMethod.BROWSER_OR_TOKEN)
|
|
361
|
+
item.setText(TYPE_COLUMN, sign_in_method.type_label)
|
|
362
|
+
if sign_in_method == SignInMethod.BROWSER_OR_TOKEN:
|
|
363
|
+
item.setToolTip(TYPE_COLUMN, UNKNOWN_TYPE_TOOLTIP_TEXT)
|
|
364
|
+
item.setData(SERVICE_COLUMN, Qt.ItemDataRole.UserRole, selection.namespace)
|
|
365
|
+
self.account_tree.addTopLevelItem(item)
|
|
366
|
+
if selection.namespace == selected_namespace:
|
|
367
|
+
selected_item = item
|
|
368
|
+
self._render_status()
|
|
369
|
+
if selected_item is not None:
|
|
370
|
+
self.account_tree.setCurrentItem(selected_item)
|
|
371
|
+
if not catalogue.accounts:
|
|
372
|
+
self.status_label.setText(EMPTY_ACCOUNT_LIST_TEXT)
|
|
373
|
+
elif self.status_label.text() == EMPTY_ACCOUNT_LIST_TEXT:
|
|
374
|
+
self.status_label.clear()
|
|
375
|
+
self._selection_changed()
|
|
376
|
+
self._render_global()
|
|
377
|
+
self._render_repository()
|
|
378
|
+
self._refresh_block_state()
|
|
379
|
+
|
|
380
|
+
def show_needs_reconnect(self, namespace_set: frozenset[str], present_namespace_set: frozenset[str] = frozenset()) -> None:
|
|
381
|
+
"""Show, in the Status column only, the answer of a check of the stored secrets; every other row and block stays as it is.
|
|
382
|
+
|
|
383
|
+
``namespace_set`` names every listed account known to need a reconnect; ``present_namespace_set`` the accounts
|
|
384
|
+
this check found with their secret, which then say OK. Used when the check answers after the list was shown, so
|
|
385
|
+
the choice blocks and their open menus are never rebuilt under the user.
|
|
386
|
+
"""
|
|
387
|
+
self.catalogue = replace(self.catalogue, needs_reconnect_namespace_set=frozenset(namespace_set))
|
|
388
|
+
self.secret_found_namespace_set -= namespace_set
|
|
389
|
+
self.secret_found_namespace_set |= present_namespace_set - namespace_set
|
|
390
|
+
self._render_status()
|
|
391
|
+
|
|
392
|
+
def changeEvent(self, event: QEvent) -> None:
|
|
393
|
+
"""Pick the light or dark colours of the Status column again when the theme changes, and its width when the text size does."""
|
|
394
|
+
super().changeEvent(event)
|
|
395
|
+
if event.type() in PALETTE_EVENT_TYPE_SET:
|
|
396
|
+
self._render_status()
|
|
397
|
+
# A style or font change can arrive while the base class is still being built, before the list exists.
|
|
398
|
+
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(self, "account_tree"):
|
|
399
|
+
self._fit_status_column()
|
|
400
|
+
|
|
401
|
+
def _render_status(self) -> None:
|
|
402
|
+
"""Write the Status column: ``Needs reconnect`` in bold error colour for an account whose secret is gone, ``OK`` in the success colour for one a check found, empty otherwise.
|
|
403
|
+
|
|
404
|
+
Nothing is said before a check answered for the account: an empty cell never claims the account works.
|
|
405
|
+
"""
|
|
406
|
+
dark = is_dark_palette(QApplication.palette())
|
|
407
|
+
for index in range(self.account_tree.topLevelItemCount()):
|
|
408
|
+
item = self.account_tree.topLevelItem(index)
|
|
409
|
+
namespace = item.data(SERVICE_COLUMN, Qt.ItemDataRole.UserRole)
|
|
410
|
+
font = item.font(STATUS_COLUMN)
|
|
411
|
+
if namespace in self.catalogue.needs_reconnect_namespace_set:
|
|
412
|
+
item.setText(STATUS_COLUMN, NEEDS_RECONNECT_STATUS_TEXT)
|
|
413
|
+
item.setToolTip(STATUS_COLUMN, NEEDS_RECONNECT_TOOLTIP_TEXT)
|
|
414
|
+
item.setForeground(STATUS_COLUMN, QBrush(QColor(result_color(ResultTone.FAILURE, dark))))
|
|
415
|
+
font.setBold(True)
|
|
416
|
+
item.setFont(STATUS_COLUMN, font)
|
|
417
|
+
continue
|
|
418
|
+
font.setBold(False)
|
|
419
|
+
item.setFont(STATUS_COLUMN, font)
|
|
420
|
+
item.setToolTip(STATUS_COLUMN, "")
|
|
421
|
+
if namespace in self.secret_found_namespace_set:
|
|
422
|
+
item.setText(STATUS_COLUMN, OK_STATUS_TEXT)
|
|
423
|
+
item.setForeground(STATUS_COLUMN, QBrush(QColor(result_color(ResultTone.SUCCESS, dark))))
|
|
424
|
+
continue
|
|
425
|
+
item.setText(STATUS_COLUMN, "")
|
|
426
|
+
item.setData(STATUS_COLUMN, Qt.ItemDataRole.ForegroundRole, None)
|
|
427
|
+
|
|
428
|
+
def _fit_status_column(self) -> None:
|
|
429
|
+
"""Give the Status column the width of its title or of its widest possible text, in bold, so a check's answer never widens it.
|
|
430
|
+
|
|
431
|
+
The texts are measured in a hidden one-column list with the same font and style, which gives the width the
|
|
432
|
+
list's own cells need, margins included.
|
|
433
|
+
"""
|
|
434
|
+
tree = self.account_tree
|
|
435
|
+
probe = QTreeWidget()
|
|
436
|
+
probe.setFont(tree.font())
|
|
437
|
+
probe.setStyle(tree.style())
|
|
438
|
+
probe.setColumnCount(1)
|
|
439
|
+
probe.setRootIsDecorated(False)
|
|
440
|
+
bold_font = QFont(tree.font())
|
|
441
|
+
bold_font.setBold(True)
|
|
442
|
+
for text in STATUS_TEXTS:
|
|
443
|
+
item = QTreeWidgetItem([text])
|
|
444
|
+
item.setFont(PROBE_COLUMN, bold_font)
|
|
445
|
+
probe.addTopLevelItem(item)
|
|
446
|
+
header = tree.header()
|
|
447
|
+
header.resizeSection(STATUS_COLUMN, max(header.sectionSizeHint(STATUS_COLUMN), probe.sizeHintForColumn(PROBE_COLUMN)))
|
|
448
|
+
probe.deleteLater()
|
|
449
|
+
|
|
450
|
+
def needed_width(self) -> int:
|
|
451
|
+
"""Return the page width at which Connected accounts shows every column whole, Status included, without scrolling sideways.
|
|
452
|
+
|
|
453
|
+
Settings opens at least this wide, within the screen. The list is the only control of its block whose width
|
|
454
|
+
depends on the accounts, so the block needs its own minimum plus what the list's columns need beyond the list's minimum.
|
|
455
|
+
"""
|
|
456
|
+
tree = self.account_tree
|
|
457
|
+
header = tree.header()
|
|
458
|
+
columns_width = header.sectionSize(STATUS_COLUMN)
|
|
459
|
+
for column in (*CONTENT_WIDTH_COLUMNS, ADDRESS_COLUMN):
|
|
460
|
+
columns_width += max(header.sectionSizeHint(column), tree.sizeHintForColumn(column))
|
|
461
|
+
tree_width = columns_width + tree.frameWidth() * FRAME_SIDE_COUNT
|
|
462
|
+
group_width = self.account_group.minimumSizeHint().width() - tree.minimumSizeHint().width() + tree_width
|
|
463
|
+
return max(self.minimumSizeHint().width(), group_width)
|
|
464
|
+
|
|
465
|
+
def show_remotes(self, entries: tuple[RemoteEntry, ...]) -> None:
|
|
466
|
+
"""Show a fresh read of this repository's remotes as one row per HTTPS address, keeping the rows the user changed."""
|
|
467
|
+
self.remote_entries = tuple(entries)
|
|
468
|
+
self.repository_remote_label.clear()
|
|
469
|
+
self._render_repository()
|
|
470
|
+
self._refresh_block_state()
|
|
471
|
+
if not self.repository_rows and not self.repository_ssh_notes:
|
|
472
|
+
self.repository_remote_label.setText(NO_HTTPS_REMOTE_TEXT)
|
|
473
|
+
|
|
474
|
+
def show_remote_error(self, text: str) -> None:
|
|
475
|
+
"""Explain why the remotes could not be read; rows already shown and the draft stay as they are."""
|
|
476
|
+
self.repository_remote_label.setText(text)
|
|
477
|
+
|
|
478
|
+
def repository_changes(self) -> dict[str, str | None]:
|
|
479
|
+
"""Return the rows changed in This repository and not saved yet; None means the row follows the Global default again."""
|
|
480
|
+
return dict(self.repository_draft_namespace_by_origin)
|
|
481
|
+
|
|
482
|
+
def repository_saved(self) -> None:
|
|
483
|
+
"""Forget the This repository draft after a successful save; the Global block keeps its own draft."""
|
|
484
|
+
self.repository_draft_namespace_by_origin.clear()
|
|
485
|
+
self._render_repository()
|
|
486
|
+
self._refresh_block_state()
|
|
487
|
+
self.repository_status_label.show_success(REPOSITORY_SAVED_TEXT)
|
|
488
|
+
|
|
489
|
+
def show_repository_error(self, text: str) -> None:
|
|
490
|
+
"""Explain a failed This repository save inside the block, keeping the draft so the user can retry."""
|
|
491
|
+
self.repository_status_label.show_failure(text)
|
|
492
|
+
|
|
493
|
+
def unsaved_place_names(self) -> list[str]:
|
|
494
|
+
"""Name the places of this page holding unsaved work, in page order, so closing or saving Settings can say what would be lost."""
|
|
495
|
+
place_names = []
|
|
496
|
+
if self.global_draft_namespace_by_origin:
|
|
497
|
+
place_names.append(ACCOUNTS_PAGE_NAME + PLACE_SEPARATOR + GLOBAL_TITLE)
|
|
498
|
+
if self.repository_draft_namespace_by_origin:
|
|
499
|
+
place_names.append(ACCOUNTS_PAGE_NAME + PLACE_SEPARATOR + REPOSITORY_TITLE)
|
|
500
|
+
if self.sign_in_dialog is not None and isValid(self.sign_in_dialog) and self.sign_in_dialog.running:
|
|
501
|
+
place_names.append(ACCOUNTS_PAGE_NAME + PLACE_SEPARATOR + SIGN_IN_PLACE_NAME)
|
|
502
|
+
return place_names
|
|
503
|
+
|
|
504
|
+
def selected_namespace(self) -> str | None:
|
|
505
|
+
"""Return the store namespace of the selected account, or None when no row is selected."""
|
|
506
|
+
items = self.account_tree.selectedItems()
|
|
507
|
+
if not items:
|
|
508
|
+
return None
|
|
509
|
+
first_item_index = 0
|
|
510
|
+
return items[first_item_index].data(SERVICE_COLUMN, Qt.ItemDataRole.UserRole)
|
|
511
|
+
|
|
512
|
+
def show_message(self, text: str, tone: ResultTone = ResultTone.NEUTRAL) -> None:
|
|
513
|
+
"""Show the result of the last account action under the list, in green when it worked and in red when it failed."""
|
|
514
|
+
self.status_label.show_result(text, tone)
|
|
515
|
+
|
|
516
|
+
def global_changes(self) -> dict[str, str]:
|
|
517
|
+
"""Return the defaults changed in the Global block and not saved yet, by service address."""
|
|
518
|
+
return dict(self.global_draft_namespace_by_origin)
|
|
519
|
+
|
|
520
|
+
def global_saved(self) -> None:
|
|
521
|
+
"""Forget the Global draft after a successful save; the other block keeps its own draft."""
|
|
522
|
+
self.global_draft_namespace_by_origin.clear()
|
|
523
|
+
self._render_global()
|
|
524
|
+
self._refresh_block_state()
|
|
525
|
+
self.global_status_label.show_success(GLOBAL_SAVED_TEXT)
|
|
526
|
+
|
|
527
|
+
def show_global_error(self, text: str) -> None:
|
|
528
|
+
"""Explain a failed Global save inside the block, keeping the draft so the user can retry."""
|
|
529
|
+
self.global_status_label.show_failure(text)
|
|
530
|
+
|
|
531
|
+
def abandon_sign_in(self) -> None:
|
|
532
|
+
"""Close the open sign-in window, if any, so no late result can reach it and its token is dropped."""
|
|
533
|
+
dialog = self.sign_in_dialog
|
|
534
|
+
self.sign_in_dialog = None
|
|
535
|
+
if dialog is None or not isValid(dialog):
|
|
536
|
+
return
|
|
537
|
+
dialog.abandon()
|
|
538
|
+
dialog.reject()
|
|
539
|
+
|
|
540
|
+
def _render_global(self) -> None:
|
|
541
|
+
"""Build one row per service address; a changed row shows its draft, others the saved default.
|
|
542
|
+
|
|
543
|
+
A draft that now equals the saved default, or names an account no longer offered, is dropped: nothing is left to save.
|
|
544
|
+
"""
|
|
545
|
+
rows = global_rows(self.catalogue)
|
|
546
|
+
row_by_origin = {}
|
|
547
|
+
for row in rows:
|
|
548
|
+
row_by_origin[row.origin] = row
|
|
549
|
+
for origin in tuple(self.global_draft_namespace_by_origin):
|
|
550
|
+
row = row_by_origin.get(origin)
|
|
551
|
+
namespace = self.global_draft_namespace_by_origin[origin]
|
|
552
|
+
if row is None or namespace == row.default_namespace or not offers_account(row.choices, namespace):
|
|
553
|
+
del self.global_draft_namespace_by_origin[origin]
|
|
554
|
+
clear_grid(self.global_grid)
|
|
555
|
+
self.global_rows = rows
|
|
556
|
+
self.global_combo_by_origin = {}
|
|
557
|
+
self.global_label_by_origin = {}
|
|
558
|
+
for row in rows:
|
|
559
|
+
label = plain_label(row.title)
|
|
560
|
+
combo = QComboBox()
|
|
561
|
+
combo.setAccessibleName("Default account for " + row.address)
|
|
562
|
+
label.setBuddy(combo)
|
|
563
|
+
fill_global_combo(combo, row, self.global_draft_namespace_by_origin.get(row.origin, row.default_namespace))
|
|
564
|
+
combo.activated.connect(lambda _index, origin=row.origin: self._global_chosen(origin))
|
|
565
|
+
grid_row = self.global_grid.rowCount()
|
|
566
|
+
self.global_grid.addWidget(label, grid_row, ROW_LABEL_COLUMN)
|
|
567
|
+
self.global_grid.addWidget(combo, grid_row, ROW_CHOICE_COLUMN)
|
|
568
|
+
self.global_label_by_origin[row.origin] = label
|
|
569
|
+
self.global_combo_by_origin[row.origin] = combo
|
|
570
|
+
self.global_empty_label.setVisible(not rows)
|
|
571
|
+
|
|
572
|
+
def _global_chosen(self, origin: str) -> None:
|
|
573
|
+
"""Record the user's choice for one address; choosing the saved default again means nothing is left to save there."""
|
|
574
|
+
combo = self.global_combo_by_origin.get(origin)
|
|
575
|
+
row = self._global_row(origin)
|
|
576
|
+
if combo is None or row is None:
|
|
577
|
+
return
|
|
578
|
+
namespace = combo.currentData()
|
|
579
|
+
if namespace is None or namespace == row.default_namespace:
|
|
580
|
+
self.global_draft_namespace_by_origin.pop(origin, None)
|
|
581
|
+
else:
|
|
582
|
+
self.global_draft_namespace_by_origin[origin] = namespace
|
|
583
|
+
self.global_status_label.clear()
|
|
584
|
+
self._refresh_block_state()
|
|
585
|
+
|
|
586
|
+
def _global_row(self, origin: str) -> GlobalRow | None:
|
|
587
|
+
"""Find the displayed Global row of this address."""
|
|
588
|
+
for row in self.global_rows:
|
|
589
|
+
if row.origin == origin:
|
|
590
|
+
return row
|
|
591
|
+
return None
|
|
592
|
+
|
|
593
|
+
def _render_repository(self) -> None:
|
|
594
|
+
"""Build one row per HTTPS address of the remotes and one note per SSH remote; a changed row shows its draft.
|
|
595
|
+
|
|
596
|
+
Nothing is built before the remotes have been read. A draft whose address left the remotes, that names an account
|
|
597
|
+
no longer offered, or that now equals the saved choice is dropped: nothing is left to save there.
|
|
598
|
+
"""
|
|
599
|
+
clear_grid(self.repository_grid)
|
|
600
|
+
self.repository_combo_by_origin = {}
|
|
601
|
+
self.repository_label_by_origin = {}
|
|
602
|
+
if self.remote_entries is None:
|
|
603
|
+
self.repository_rows = ()
|
|
604
|
+
self.repository_ssh_notes = ()
|
|
605
|
+
return
|
|
606
|
+
view = repository_account_view(self.catalogue, self.remote_entries)
|
|
607
|
+
row_by_origin = {}
|
|
608
|
+
for row in view.rows:
|
|
609
|
+
row_by_origin[row.origin] = row
|
|
610
|
+
for origin in tuple(self.repository_draft_namespace_by_origin):
|
|
611
|
+
row = row_by_origin.get(origin)
|
|
612
|
+
namespace = self.repository_draft_namespace_by_origin[origin]
|
|
613
|
+
if row is None or (namespace is not None and not offers_account(row.choices, namespace)) or not repository_draft_needed(row, namespace):
|
|
614
|
+
del self.repository_draft_namespace_by_origin[origin]
|
|
615
|
+
self.repository_rows = view.rows
|
|
616
|
+
self.repository_ssh_notes = view.ssh_notes
|
|
617
|
+
for row in view.rows:
|
|
618
|
+
label = plain_label(row.title)
|
|
619
|
+
combo = QComboBox()
|
|
620
|
+
combo.setAccessibleName("Account for " + row.address + " in this repository")
|
|
621
|
+
label.setBuddy(combo)
|
|
622
|
+
drafted = row.origin in self.repository_draft_namespace_by_origin
|
|
623
|
+
fill_repository_combo(combo, row, self.repository_draft_namespace_by_origin[row.origin] if drafted else row.chosen_namespace)
|
|
624
|
+
if row.missing_choice and not drafted:
|
|
625
|
+
combo.setCurrentIndex(NO_SELECTION_INDEX)
|
|
626
|
+
combo.activated.connect(lambda _index, origin=row.origin: self._repository_chosen(origin))
|
|
627
|
+
grid_row = self.repository_grid.rowCount()
|
|
628
|
+
self.repository_grid.addWidget(label, grid_row, ROW_LABEL_COLUMN)
|
|
629
|
+
self.repository_grid.addWidget(combo, grid_row, ROW_CHOICE_COLUMN)
|
|
630
|
+
if row.missing_choice:
|
|
631
|
+
self.repository_grid.addWidget(plain_label(MISSING_CHOICE_TEXT), self.repository_grid.rowCount(), ROW_CHOICE_COLUMN)
|
|
632
|
+
self.repository_label_by_origin[row.origin] = label
|
|
633
|
+
self.repository_combo_by_origin[row.origin] = combo
|
|
634
|
+
for note in view.ssh_notes:
|
|
635
|
+
self.repository_grid.addWidget(plain_label(note), self.repository_grid.rowCount(), ROW_LABEL_COLUMN, ROW_SPAN, ROW_COLUMN_COUNT)
|
|
636
|
+
|
|
637
|
+
def _repository_chosen(self, origin: str) -> None:
|
|
638
|
+
"""Record the user's choice for one address of this repository; choosing the saved one again leaves nothing to save there."""
|
|
639
|
+
combo = self.repository_combo_by_origin.get(origin)
|
|
640
|
+
row = self._repository_row(origin)
|
|
641
|
+
if combo is None or row is None:
|
|
642
|
+
return
|
|
643
|
+
namespace = combo.currentData()
|
|
644
|
+
if repository_draft_needed(row, namespace):
|
|
645
|
+
self.repository_draft_namespace_by_origin[origin] = namespace
|
|
646
|
+
else:
|
|
647
|
+
self.repository_draft_namespace_by_origin.pop(origin, None)
|
|
648
|
+
self.repository_status_label.clear()
|
|
649
|
+
self._refresh_block_state()
|
|
650
|
+
|
|
651
|
+
def _repository_row(self, origin: str) -> RepositoryRow | None:
|
|
652
|
+
"""Find the displayed This repository row of this address."""
|
|
653
|
+
for row in self.repository_rows:
|
|
654
|
+
if row.origin == origin:
|
|
655
|
+
return row
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
def _refresh_block_state(self) -> None:
|
|
659
|
+
"""Mark a block with unsaved choices in its title and enable its Save button only then."""
|
|
660
|
+
global_changed = bool(self.global_draft_namespace_by_origin)
|
|
661
|
+
self.global_group.setTitle(GLOBAL_TITLE + UNSAVED_MARK if global_changed else GLOBAL_TITLE)
|
|
662
|
+
self.save_global_button.setEnabled(global_changed)
|
|
663
|
+
repository_changed = bool(self.repository_draft_namespace_by_origin)
|
|
664
|
+
self.repository_group.setTitle(REPOSITORY_TITLE + UNSAVED_MARK if repository_changed else REPOSITORY_TITLE)
|
|
665
|
+
self.save_repository_button.setEnabled(repository_changed and self.repository_dir is not None)
|
|
666
|
+
|
|
667
|
+
def set_removing(self, running: bool, label: str = "") -> None:
|
|
668
|
+
"""Lock the account actions while a removal runs and say which account is being removed; unlock them when it ends."""
|
|
669
|
+
self.removing = running
|
|
670
|
+
self.add_account_button.setEnabled(not running)
|
|
671
|
+
self._selection_changed()
|
|
672
|
+
if running:
|
|
673
|
+
self.status_label.setText(REMOVING_TEXT.format(label=label))
|
|
674
|
+
|
|
675
|
+
def _confirm_removal(self) -> None:
|
|
676
|
+
"""Ask before removing the selected account, without blocking the application; Keep account is the default and the answer of Escape.
|
|
677
|
+
|
|
678
|
+
Only one question is open at a time. The account is captured when asking, so changing the selection meanwhile
|
|
679
|
+
cannot redirect the removal to another account.
|
|
680
|
+
"""
|
|
681
|
+
if self.remove_confirmation is not None and isValid(self.remove_confirmation):
|
|
682
|
+
self.remove_confirmation.raise_()
|
|
683
|
+
self.remove_confirmation.activateWindow()
|
|
684
|
+
return
|
|
685
|
+
selection = self._selected_account()
|
|
686
|
+
if selection is None or self.removing:
|
|
687
|
+
return
|
|
688
|
+
box = QMessageBox(QMessageBox.Icon.Warning, REMOVE_TITLE, REMOVE_QUESTION_TEXT.format(label=selection.label, address=origin_address(selection.origin)), parent=self)
|
|
689
|
+
prepare_message_box(box, REMOVE_TITLE)
|
|
690
|
+
box.setTextFormat(Qt.TextFormat.PlainText)
|
|
691
|
+
box.setInformativeText(REMOVE_DETAIL_TEXT)
|
|
692
|
+
remove_button = box.addButton(REMOVE_CONFIRM_TEXT, QMessageBox.ButtonRole.DestructiveRole)
|
|
693
|
+
keep_button = box.addButton(KEEP_ACCOUNT_TEXT, QMessageBox.ButtonRole.RejectRole)
|
|
694
|
+
box.setDefaultButton(keep_button)
|
|
695
|
+
box.setEscapeButton(keep_button)
|
|
696
|
+
self.remove_confirmation = box
|
|
697
|
+
|
|
698
|
+
def finished() -> None:
|
|
699
|
+
"""Ask for the removal only on an explicit Remove account; any other ending keeps the account."""
|
|
700
|
+
self.remove_confirmation = None
|
|
701
|
+
confirmed = box.clickedButton() is remove_button
|
|
702
|
+
box.deleteLater()
|
|
703
|
+
if confirmed:
|
|
704
|
+
self.remove_account_requested.emit(selection.namespace)
|
|
705
|
+
|
|
706
|
+
box.finished.connect(finished)
|
|
707
|
+
open_window_modal(box)
|
|
708
|
+
|
|
709
|
+
def _selected_account(self) -> AccountSelection | None:
|
|
710
|
+
"""Find the listed account of the selected row, or None when no row is selected."""
|
|
711
|
+
namespace = self.selected_namespace()
|
|
712
|
+
for selection in self.catalogue.accounts:
|
|
713
|
+
if selection.namespace == namespace:
|
|
714
|
+
return selection
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
def _selection_changed(self) -> None:
|
|
718
|
+
"""Offer Reconnect and Remove account for a selected account, but not while a removal runs.
|
|
719
|
+
|
|
720
|
+
Reconnect is always offered, since an expired sign-in cannot be known in advance.
|
|
721
|
+
"""
|
|
722
|
+
selected = self.selected_namespace() is not None
|
|
723
|
+
self.reconnect_button.setEnabled(selected and not self.removing)
|
|
724
|
+
self.remove_account_button.setEnabled(selected and not self.removing)
|