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,879 @@
|
|
|
1
|
+
"""Collect clone intentions in a form laid out like the Git Extensions Clone window, with an Account row added on top."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QMargins, Qt, QTimer, Signal
|
|
10
|
+
from PySide6.QtGui import QColor, QPalette
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QApplication,
|
|
13
|
+
QCheckBox,
|
|
14
|
+
QComboBox,
|
|
15
|
+
QDialog,
|
|
16
|
+
QFileDialog,
|
|
17
|
+
QFrame,
|
|
18
|
+
QGridLayout,
|
|
19
|
+
QGroupBox,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QLineEdit,
|
|
22
|
+
QPushButton,
|
|
23
|
+
QRadioButton,
|
|
24
|
+
QScrollArea,
|
|
25
|
+
QSizePolicy,
|
|
26
|
+
QStyle,
|
|
27
|
+
QVBoxLayout,
|
|
28
|
+
QWidget,
|
|
29
|
+
)
|
|
30
|
+
from shiboken6 import isValid
|
|
31
|
+
|
|
32
|
+
from gitpane.account.account_choice import ENTER_URL_FIRST_TEXT
|
|
33
|
+
from gitpane.config import ERROR_DARK_COLOR, ERROR_LIGHT_COLOR, fit_window
|
|
34
|
+
from gitpane.recent.recent_availability import RecentAvailabilityCheck
|
|
35
|
+
from gitpane.text.typed_path import session_typed_path, typed_path_value, typed_start_dir
|
|
36
|
+
from gitpane.text.typed_text import typed_value
|
|
37
|
+
from gitpane.thread.weak_signal import weak_signal_emitter
|
|
38
|
+
from gitpane.transport.clone import SUBMODULE_PARTIAL_TEXT, CloneOptions, looks_like_git_repository, suggested_name
|
|
39
|
+
from gitpane.ui.look.action_icon import action_icon
|
|
40
|
+
from gitpane.ui.look.theme_color import is_dark_palette
|
|
41
|
+
from gitpane.ui.look.theme_look import follow_theme
|
|
42
|
+
from gitpane.ui.look.windows_control_look import apply_windows_combo_look, apply_windows_line_edit_look
|
|
43
|
+
from gitpane.ui.widget.busy_indicator import BusyIndicator
|
|
44
|
+
from gitpane.ui.widget.content_growth import ContentGrowthMixin, OpeningFit
|
|
45
|
+
from gitpane.ui.widget.content_measure import ScrolledContentMeasure
|
|
46
|
+
from gitpane.ui.widget.dialog_layout import (
|
|
47
|
+
BROWSE_TEXT,
|
|
48
|
+
BUTTON_COLUMN,
|
|
49
|
+
FIELD_COLUMN,
|
|
50
|
+
FORM_BAND_BUTTON_MIN_SIZE,
|
|
51
|
+
FORM_GRID_BODY_MARGINS,
|
|
52
|
+
FORM_GRID_CAPTION_WIDTH,
|
|
53
|
+
FORM_GRID_COLUMN_SPACING,
|
|
54
|
+
FORM_GRID_ROW_SPACING,
|
|
55
|
+
FORM_GRID_ROWS_TO_BLOCK_SPACE,
|
|
56
|
+
FORM_GRID_SIDE_BUTTON_MIN_WIDTH,
|
|
57
|
+
BandButtonSizing,
|
|
58
|
+
add_form_caption,
|
|
59
|
+
apply_side_button_look,
|
|
60
|
+
install_banded_layout,
|
|
61
|
+
)
|
|
62
|
+
from gitpane.ui.widget.long_text import WrappingLabel
|
|
63
|
+
from gitpane.ui.widget.plain_label import plain_label
|
|
64
|
+
from gitpane.ui.widget.result_label import ResultLabel, ResultTone
|
|
65
|
+
from gitpane.ui.widget.standard_icon_label import StandardIconLabel
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from collections.abc import Callable
|
|
69
|
+
|
|
70
|
+
from PySide6.QtCore import QRect
|
|
71
|
+
|
|
72
|
+
from gitpane.ui.setting.add_account_dialog import AddAccountDialog
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Client width of the Git Extensions capture, the form's smallest starting width. The height is the smallest that shows the starting form (Account row, yellow
|
|
76
|
+
# box 17 px below the Branch row as on the capture, help text under the check boxes) without scrolling, with 18 px left
|
|
77
|
+
# under the help text, the space Git Extensions leaves under its check boxes; that space is enough for a one-line account
|
|
78
|
+
# message.
|
|
79
|
+
CLONE_WIDTH, CLONE_HEIGHT = 648, 420
|
|
80
|
+
CLONE_TITLE = "Clone repository"
|
|
81
|
+
ACCOUNT_CAPTION = "Account:"
|
|
82
|
+
REPOSITORY_CAPTION = "Repository to clone:"
|
|
83
|
+
DESTINATION_CAPTION = "Destination:"
|
|
84
|
+
SUBDIRECTORY_CAPTION = "Subdirectory to create:"
|
|
85
|
+
BRANCH_CAPTION = "Branch:"
|
|
86
|
+
# Every caption of the form's left column; the start window lines its own caption column up with these.
|
|
87
|
+
CLONE_FORM_CAPTIONS = (ACCOUNT_CAPTION, REPOSITORY_CAPTION, DESTINATION_CAPTION, SUBDIRECTORY_CAPTION, BRANCH_CAPTION)
|
|
88
|
+
ADD_ACCOUNT_TEXT = "Add account…"
|
|
89
|
+
# The side button without an icon in the form's right-hand column; the start window's column takes its width too.
|
|
90
|
+
CLONE_PLAIN_SIDE_BUTTON_TEXTS = (ADD_ACCOUNT_TEXT,)
|
|
91
|
+
SUBMODULE_CHECK_TEXT = "Initialize all submodules"
|
|
92
|
+
FULL_HISTORY_CHECK_TEXT = "Download full history"
|
|
93
|
+
# Texts of the Git Extensions Clone window, word for word.
|
|
94
|
+
DEFAULT_BRANCH_TEXT = "(default: remote HEAD)"
|
|
95
|
+
TARGET_INTRO_TEXT = "The repository will be cloned to a new directory located here:"
|
|
96
|
+
TARGET_NEW_TEXT = "(New directory)"
|
|
97
|
+
TARGET_EXISTS_TEXT = "(Directory already exists)"
|
|
98
|
+
REPOSITORY_TYPE_TITLE = "Repository type"
|
|
99
|
+
PERSONAL_REPOSITORY_TEXT = "Personal repository"
|
|
100
|
+
BARE_REPOSITORY_TEXT = "Public repository, no working directory (--bare)"
|
|
101
|
+
# Git's usual ending for the folder of a bare repository, added to the proposed subdirectory.
|
|
102
|
+
BARE_SUFFIX = ".git"
|
|
103
|
+
# Wait after the last change of the destination before looking whether the folder exists: typing never reads the disk.
|
|
104
|
+
TARGET_CHECK_DELAY_MS = 300
|
|
105
|
+
# Wait after the last change of the URL or of the account before reading the remote branches: typing starts no Git process.
|
|
106
|
+
BRANCH_LIST_DELAY_MS = 600
|
|
107
|
+
# The Branch row says this without any line of text: the first is the accessible name of the turning indicator shown
|
|
108
|
+
# while the branches are read, the second the tooltip of the warning icon shown after a failure.
|
|
109
|
+
BRANCH_LOADING_TEXT = "Reading branches…"
|
|
110
|
+
BRANCH_FAILED_TEXT = "Branches could not be read"
|
|
111
|
+
DEFAULT_BRANCH_INDEX = 0
|
|
112
|
+
EMPTY_HELP = "Enter a repository URL or a local folder, choose a destination and the subdirectory to create."
|
|
113
|
+
CLONE_RUNNING_TEXT = "Cloning repository and submodules…"
|
|
114
|
+
CLONE_FAILED_TEXT = "Clone failed. Review the details, correct the URL or destination, then try again."
|
|
115
|
+
OPEN_FAILED_TEXT = "Repository cloned, but opening failed. Open repository to retry."
|
|
116
|
+
# While stopping, GitPane cannot tell yet whether Git wrote anything: an empty folder it created is removed afterwards,
|
|
117
|
+
# anything Git wrote is kept.
|
|
118
|
+
STOPPING_TEXT = "Stopping clone… Anything Git already wrote will be preserved."
|
|
119
|
+
CLONE_CANCELLED_TEXT = "Clone cancelled."
|
|
120
|
+
CLONE_CANCELLED_PRESERVED_TEXT = CLONE_CANCELLED_TEXT + " The partial directory was preserved: "
|
|
121
|
+
OPENING_CANCELLED_TEXT = "Opening cancelled. The cloned repository was preserved."
|
|
122
|
+
|
|
123
|
+
# The rows, their columns and the yellow box's distance below them come from the shared form grid of dialog_layout,
|
|
124
|
+
# measured on this window's capture; the box itself spans x 14 to 632 there.
|
|
125
|
+
TARGET_PADDING = QMargins(5, 4, 5, 4)
|
|
126
|
+
# Colors of the yellow box, owned here instead of read from the palette: macOS gives no yellow tooltip background in
|
|
127
|
+
# either appearance, and its tooltip text is black in dark. The light set is the pale yellow Git Extensions shows under a
|
|
128
|
+
# light Windows theme; the dark set is a dark version of the same yellow chosen on captures, its text at 7.5:1.
|
|
129
|
+
TARGET_BOX_LIGHT_BACKGROUND_COLOR = "#ffffe1"
|
|
130
|
+
TARGET_BOX_LIGHT_BORDER_COLOR = "#a0a0a0"
|
|
131
|
+
TARGET_BOX_LIGHT_TEXT_COLOR = "#000000"
|
|
132
|
+
TARGET_BOX_DARK_BACKGROUND_COLOR = "#5c4f1f"
|
|
133
|
+
TARGET_BOX_DARK_BORDER_COLOR = "#a8923f"
|
|
134
|
+
TARGET_BOX_DARK_TEXT_COLOR = "#fff6d8"
|
|
135
|
+
# The Repository type box starts 9 px below the yellow box and 6 px right of its left edge. The check boxes follow it
|
|
136
|
+
# closer than on the capture, since the help text comes under them here.
|
|
137
|
+
TARGET_TO_TYPE_SPACE = 9
|
|
138
|
+
TYPE_INDENT = 6
|
|
139
|
+
TYPE_TO_CHECKS_SPACE = 6
|
|
140
|
+
# The check boxes start 14 px right of the box's left edge.
|
|
141
|
+
CHECKS_INDENT = 14
|
|
142
|
+
CHECKS_SPACING = 24
|
|
143
|
+
CHECKS_TO_STATUS_SPACE = 6
|
|
144
|
+
ACCOUNT_ROW, REPOSITORY_ROW, DESTINATION_ROW, SUBDIRECTORY_ROW, BRANCH_ROW = 0, 1, 2, 3, 4
|
|
145
|
+
# The Account row's menu and button share the field and button columns.
|
|
146
|
+
ACCOUNT_COLUMN_SPAN = 2
|
|
147
|
+
MOST_RECENT_DESTINATION_INDEX = 0
|
|
148
|
+
# Characters the form's drop-down lists ask room for. A list sized to its entries would widen the form to the longest
|
|
149
|
+
# remembered destination or branch name; the field column stretches anyway, and a long entry scrolls inside its field.
|
|
150
|
+
COMBO_MINIMUM_CHARACTERS = 20
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BranchReadState(Enum):
|
|
154
|
+
"""What the Branch row shows beside its list: nothing, the turning indicator while reading, or the warning icon after a failure."""
|
|
155
|
+
|
|
156
|
+
IDLE = "idle"
|
|
157
|
+
READING = "reading"
|
|
158
|
+
FAILED = "failed"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CloneDialogText(Enum):
|
|
162
|
+
"""Sentences of the clone form that the clone controller writes into it."""
|
|
163
|
+
|
|
164
|
+
# Written under the check boxes when the form switched itself to the account named like the address's owner.
|
|
165
|
+
OWNER_ACCOUNT_CHOSEN = "The branches could not be read with the previous account: {label}, the owner named in the address, is used instead."
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CloneDialog(ContentGrowthMixin, QDialog):
|
|
169
|
+
"""Keep the complete clone form available through progress and recoverable errors.
|
|
170
|
+
|
|
171
|
+
The form only collects what the user asks for and hands it over as a URL, a destination and ``CloneOptions``; the
|
|
172
|
+
clone service builds the Git command.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
clone_requested = Signal()
|
|
176
|
+
cancel_requested = Signal()
|
|
177
|
+
# Asked a short while after the URL or the account last changed: the clone controller reads the remote branches.
|
|
178
|
+
branch_list_requested = Signal()
|
|
179
|
+
# The URL or the account changed: a branch list still running answers for the old one and must be dropped.
|
|
180
|
+
branch_list_outdated = Signal()
|
|
181
|
+
open_requested = Signal()
|
|
182
|
+
retry_requested = Signal()
|
|
183
|
+
# Answer of the background check of a folder path found on the clipboard: the folder and whether it is a repository.
|
|
184
|
+
clipboard_repository_checked = Signal(str, bool)
|
|
185
|
+
|
|
186
|
+
def __init__(self, parent: QWidget | None = None, is_clipboard_repository: Callable[[str], bool] = looks_like_git_repository,
|
|
187
|
+
target_exists: Callable[[str], bool] = os.path.lexists, typed_path: Callable[[str], str] = session_typed_path) -> None:
|
|
188
|
+
"""Build the Git Extensions rows in a scrolling body above the grey button band; Git's output goes to the clone's own console.
|
|
189
|
+
|
|
190
|
+
``is_clipboard_repository`` answers, on a background thread, whether a folder path found on the clipboard is a
|
|
191
|
+
repository; tests inject it to hold or answer that question without a disk. ``target_exists`` answers whether the
|
|
192
|
+
folder the clone would create is already there; the window gallery injects it to show an example path in both
|
|
193
|
+
states without creating any folder. ``typed_path`` reads ``~`` in the address and the destination where that is the
|
|
194
|
+
usage; tests hand their own system and home folder.
|
|
195
|
+
"""
|
|
196
|
+
super().__init__(parent)
|
|
197
|
+
self.return_focus = parent.focusWidget() if parent is not None else None
|
|
198
|
+
self.finished.connect(self._restore_focus)
|
|
199
|
+
self.setWindowTitle(CLONE_TITLE)
|
|
200
|
+
self.resize(CLONE_WIDTH, CLONE_HEIGHT)
|
|
201
|
+
self.custom_name = False
|
|
202
|
+
self.running = False
|
|
203
|
+
self.partial = False
|
|
204
|
+
self.recursive_partial = False
|
|
205
|
+
self.authentication_endpoint = ""
|
|
206
|
+
self.account_origin = ""
|
|
207
|
+
# The user picked an account in the menu or added one from this form: the form then never switches account by
|
|
208
|
+
# itself, until the service address changes.
|
|
209
|
+
self.account_chosen_by_user = False
|
|
210
|
+
# Line the form wrote when it switched itself to the owner's account; empty when it did not.
|
|
211
|
+
self.owner_account_note = ""
|
|
212
|
+
# Account the form put in the menu by itself for the address's owner; empty when it did not switch.
|
|
213
|
+
self.owner_switch_namespace = ""
|
|
214
|
+
# Owner named in the address when the form switched, compared without regard to case; empty when it did not switch.
|
|
215
|
+
self.owner_switch_owner = ""
|
|
216
|
+
# Menu choice just before the switch, None standing for 'Use global default': it comes back when the address names
|
|
217
|
+
# another owner.
|
|
218
|
+
self.account_before_owner_switch: str | None = None
|
|
219
|
+
self.add_account_dialog: AddAccountDialog | None = None
|
|
220
|
+
self.operation_id: str | None = None
|
|
221
|
+
# Id of the branch list whose answer the form waits for; empty when none runs.
|
|
222
|
+
self.branch_request_id = ""
|
|
223
|
+
# Folder and existence the target box last showed, so the box can be shown again in a new appearance.
|
|
224
|
+
self.shown_target: tuple[str, bool | None] = ("", None)
|
|
225
|
+
self.is_clipboard_repository = is_clipboard_repository
|
|
226
|
+
self.target_exists = target_exists
|
|
227
|
+
self.typed_path = typed_path
|
|
228
|
+
# Created by the first clipboard path to check, closed with the form so a late answer never fills a closed form.
|
|
229
|
+
self.clipboard_repository_check: RecentAvailabilityCheck | None = None
|
|
230
|
+
self.url_edit = QLineEdit()
|
|
231
|
+
self.url_browse_button = self._side_button(BROWSE_TEXT)
|
|
232
|
+
self.destination_combo = QComboBox()
|
|
233
|
+
self.destination_combo.setEditable(True)
|
|
234
|
+
self.destination_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
|
235
|
+
# A remembered folder is only offered in the list, never completed into the field while the user types a path.
|
|
236
|
+
self.destination_combo.setCompleter(None)
|
|
237
|
+
self.parent_edit = self.destination_combo.lineEdit()
|
|
238
|
+
self.browse_button = self._side_button(BROWSE_TEXT)
|
|
239
|
+
self.name_edit = QLineEdit()
|
|
240
|
+
self.branch_combo = QComboBox()
|
|
241
|
+
self.branch_combo.setEditable(True)
|
|
242
|
+
self.branch_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
|
243
|
+
self.branch_combo.addItem(DEFAULT_BRANCH_TEXT, "")
|
|
244
|
+
branch_marker_row = self._build_branch_marker_row()
|
|
245
|
+
account_section = self._build_account_section()
|
|
246
|
+
for combo in (self.account_combo, self.destination_combo, self.branch_combo):
|
|
247
|
+
combo.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
|
248
|
+
combo.setMinimumContentsLength(COMBO_MINIMUM_CHARACTERS)
|
|
249
|
+
grid = QGridLayout()
|
|
250
|
+
grid.setContentsMargins(0, 0, 0, 0)
|
|
251
|
+
grid.setHorizontalSpacing(FORM_GRID_COLUMN_SPACING)
|
|
252
|
+
grid.setVerticalSpacing(FORM_GRID_ROW_SPACING)
|
|
253
|
+
grid.setColumnStretch(FIELD_COLUMN, 1)
|
|
254
|
+
self.caption_labels = [add_form_caption(grid, ACCOUNT_ROW, ACCOUNT_CAPTION, self.account_combo)]
|
|
255
|
+
grid.addWidget(account_section, ACCOUNT_ROW, FIELD_COLUMN, 1, ACCOUNT_COLUMN_SPAN)
|
|
256
|
+
for row, caption, field, accessible_name in ((REPOSITORY_ROW, REPOSITORY_CAPTION, self.url_edit, "Repository to clone"),
|
|
257
|
+
(DESTINATION_ROW, DESTINATION_CAPTION, self.destination_combo, "Destination"),
|
|
258
|
+
(SUBDIRECTORY_ROW, SUBDIRECTORY_CAPTION, self.name_edit, "Subdirectory to create"),
|
|
259
|
+
(BRANCH_ROW, BRANCH_CAPTION, self.branch_combo, "Branch")):
|
|
260
|
+
self.caption_labels.append(add_form_caption(grid, row, caption, field))
|
|
261
|
+
field.setAccessibleName(accessible_name)
|
|
262
|
+
grid.addWidget(field, row, FIELD_COLUMN)
|
|
263
|
+
grid.addLayout(branch_marker_row, BRANCH_ROW, BUTTON_COLUMN)
|
|
264
|
+
grid.addWidget(self.url_browse_button, REPOSITORY_ROW, BUTTON_COLUMN)
|
|
265
|
+
grid.addWidget(self.browse_button, DESTINATION_ROW, BUTTON_COLUMN)
|
|
266
|
+
self.target_frame = QFrame()
|
|
267
|
+
self.target_frame.setFrameShape(QFrame.Shape.Box)
|
|
268
|
+
self.target_frame.setFrameShadow(QFrame.Shadow.Plain)
|
|
269
|
+
self.target_frame.setAutoFillBackground(True)
|
|
270
|
+
target_layout = QVBoxLayout(self.target_frame)
|
|
271
|
+
target_layout.setContentsMargins(TARGET_PADDING)
|
|
272
|
+
self.target_label = WrappingLabel()
|
|
273
|
+
self.target_label.setWordWrap(True)
|
|
274
|
+
target_layout.addWidget(self.target_label)
|
|
275
|
+
self.repository_type_group = QGroupBox(REPOSITORY_TYPE_TITLE)
|
|
276
|
+
self.personal_radio = QRadioButton(PERSONAL_REPOSITORY_TEXT)
|
|
277
|
+
self.personal_radio.setChecked(True)
|
|
278
|
+
self.bare_radio = QRadioButton(BARE_REPOSITORY_TEXT)
|
|
279
|
+
type_layout = QVBoxLayout(self.repository_type_group)
|
|
280
|
+
type_layout.addWidget(self.personal_radio)
|
|
281
|
+
type_layout.addWidget(self.bare_radio)
|
|
282
|
+
type_row = QHBoxLayout()
|
|
283
|
+
type_row.setContentsMargins(TYPE_INDENT, 0, 0, 0)
|
|
284
|
+
type_row.addWidget(self.repository_type_group)
|
|
285
|
+
self.submodule_check = QCheckBox(SUBMODULE_CHECK_TEXT)
|
|
286
|
+
self.submodule_check.setChecked(True)
|
|
287
|
+
self.full_history_check = QCheckBox(FULL_HISTORY_CHECK_TEXT)
|
|
288
|
+
self.full_history_check.setChecked(True)
|
|
289
|
+
check_row = QHBoxLayout()
|
|
290
|
+
check_row.setContentsMargins(CHECKS_INDENT, 0, 0, 0)
|
|
291
|
+
check_row.setSpacing(CHECKS_SPACING)
|
|
292
|
+
check_row.addWidget(self.submodule_check)
|
|
293
|
+
check_row.addWidget(self.full_history_check)
|
|
294
|
+
check_row.addStretch(1)
|
|
295
|
+
self.account_status_label = plain_label("")
|
|
296
|
+
# Hidden while empty so it takes no room between the check boxes and the help text.
|
|
297
|
+
self.account_status_label.hide()
|
|
298
|
+
self.status_label = ResultLabel(EMPTY_HELP)
|
|
299
|
+
for label in (self.target_label, self.account_status_label, self.status_label):
|
|
300
|
+
# Wrapped labels must not ask for the width of their longest word, or a long path would widen the form.
|
|
301
|
+
policy = label.sizePolicy()
|
|
302
|
+
policy.setHorizontalPolicy(QSizePolicy.Policy.Ignored)
|
|
303
|
+
label.setSizePolicy(policy)
|
|
304
|
+
content = QWidget()
|
|
305
|
+
form = QVBoxLayout(content)
|
|
306
|
+
form.setContentsMargins(FORM_GRID_BODY_MARGINS)
|
|
307
|
+
form.setSpacing(0)
|
|
308
|
+
form.addLayout(grid)
|
|
309
|
+
form.addSpacing(FORM_GRID_ROWS_TO_BLOCK_SPACE)
|
|
310
|
+
form.addWidget(self.target_frame)
|
|
311
|
+
form.addSpacing(TARGET_TO_TYPE_SPACE)
|
|
312
|
+
form.addLayout(type_row)
|
|
313
|
+
form.addSpacing(TYPE_TO_CHECKS_SPACE)
|
|
314
|
+
form.addLayout(check_row)
|
|
315
|
+
form.addSpacing(CHECKS_TO_STATUS_SPACE)
|
|
316
|
+
form.addWidget(self.account_status_label)
|
|
317
|
+
form.addWidget(self.status_label)
|
|
318
|
+
form.addStretch(1)
|
|
319
|
+
self.scroll_area = QScrollArea()
|
|
320
|
+
self.scroll_area.setWidgetResizable(True)
|
|
321
|
+
self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
|
|
322
|
+
self.scroll_area.setWidget(content)
|
|
323
|
+
# The content gets taller after the form opened (a long destination path, an account message, a failure the clone
|
|
324
|
+
# controller writes, a refused submodule) and would be cut under the bottom edge; the form takes back its height
|
|
325
|
+
# when that content goes, never below the height it opened at or the one the user set. The content's layout
|
|
326
|
+
# requests follow each key press; the scroll bar range covers an overflow that changes without a layout request
|
|
327
|
+
# seen here. The capture's height is given rather than read: refresh_metrics may reduce the form on a small
|
|
328
|
+
# screen before it is shown.
|
|
329
|
+
self.install_content_growth(ScrolledContentMeasure(self.scroll_area), OpeningFit.GROW_ONLY, CLONE_HEIGHT)
|
|
330
|
+
body = QVBoxLayout()
|
|
331
|
+
body.addWidget(self.scroll_area)
|
|
332
|
+
self.open_button = QPushButton("Open repository")
|
|
333
|
+
self.open_button.clicked.connect(self.open_requested)
|
|
334
|
+
self.open_button.hide()
|
|
335
|
+
self.clone_button = QPushButton("Clone")
|
|
336
|
+
self.clone_button.clicked.connect(self._submit)
|
|
337
|
+
# Kept beside Clone because it is the only way to stop a running clone from the form; the console has Abort.
|
|
338
|
+
self.cancel_button = QPushButton("Close")
|
|
339
|
+
self.cancel_button.clicked.connect(self._cancel_or_close)
|
|
340
|
+
band_buttons = [self.open_button, self.clone_button, self.cancel_button]
|
|
341
|
+
for button in band_buttons:
|
|
342
|
+
button.setMinimumSize(FORM_BAND_BUTTON_MIN_SIZE)
|
|
343
|
+
self.button_band = install_banded_layout(self, body, band_buttons, body_margins=QMargins(), button_sizing=BandButtonSizing.WINDOW)
|
|
344
|
+
# Enter runs Clone from anywhere in the form; the role is set once the button belongs to the dialog.
|
|
345
|
+
self.clone_button.setDefault(True)
|
|
346
|
+
# The look comes before the width: the side buttons' sheet sets the width their column needs.
|
|
347
|
+
self.apply_theme_look()
|
|
348
|
+
self.resize(self.starting_width(), CLONE_HEIGHT)
|
|
349
|
+
self.target_check_timer = QTimer(self)
|
|
350
|
+
self.target_check_timer.setSingleShot(True)
|
|
351
|
+
self.target_check_timer.setInterval(TARGET_CHECK_DELAY_MS)
|
|
352
|
+
self.target_check_timer.timeout.connect(self._check_target)
|
|
353
|
+
self.branch_timer = QTimer(self)
|
|
354
|
+
self.branch_timer.setSingleShot(True)
|
|
355
|
+
self.branch_timer.setInterval(BRANCH_LIST_DELAY_MS)
|
|
356
|
+
self.branch_timer.timeout.connect(self.branch_list_requested)
|
|
357
|
+
# A closed form is only hidden and stays alive, so its timers are stopped: it never starts a branch list or a
|
|
358
|
+
# target check afterwards, not even for a form opened again within the delay.
|
|
359
|
+
self.finished.connect(self._stop_timers)
|
|
360
|
+
# The answer arrives on a worker thread; the queued connection fills the field on the form's own thread.
|
|
361
|
+
self.clipboard_repository_checked.connect(self._fill_clipboard_repository, Qt.ConnectionType.QueuedConnection)
|
|
362
|
+
self.finished.connect(self._close_clipboard_repository_check)
|
|
363
|
+
# A chosen account may see other branches, so they are read again with it.
|
|
364
|
+
self.account_combo.activated.connect(self.restart_branch_list)
|
|
365
|
+
self.account_combo.activated.connect(self._account_chosen_by_hand)
|
|
366
|
+
self.url_browse_button.clicked.connect(self._browse_repository)
|
|
367
|
+
self.browse_button.clicked.connect(self._browse)
|
|
368
|
+
self.name_edit.textEdited.connect(self._name_edited)
|
|
369
|
+
self.name_edit.textChanged.connect(self._update_target)
|
|
370
|
+
self.parent_edit.textChanged.connect(self._update_target)
|
|
371
|
+
self.url_edit.textChanged.connect(self._url_changed)
|
|
372
|
+
self.bare_radio.toggled.connect(self._repository_type_changed)
|
|
373
|
+
# Only user actions clear a shown failure: typed edits (not text set by the program), buttons, a menu choice and a check box click.
|
|
374
|
+
for edit in (self.url_edit, self.parent_edit, self.name_edit, self.branch_combo.lineEdit()):
|
|
375
|
+
edit.textEdited.connect(self.clear_result)
|
|
376
|
+
self.add_account_button.clicked.connect(self.clear_result)
|
|
377
|
+
for combo in (self.account_combo, self.destination_combo, self.branch_combo):
|
|
378
|
+
combo.activated.connect(self.clear_result)
|
|
379
|
+
for check in (self.submodule_check, self.full_history_check, self.personal_radio, self.bare_radio):
|
|
380
|
+
check.clicked.connect(self.clear_result)
|
|
381
|
+
# The URL starts empty; the clone controller updates the row as soon as one is typed or pasted.
|
|
382
|
+
self.show_account_state(ENTER_URL_FIRST_TEXT, False)
|
|
383
|
+
self._update_target()
|
|
384
|
+
follow_theme(self, self.apply_theme_look)
|
|
385
|
+
self.refresh_metrics()
|
|
386
|
+
self.url_edit.setFocus()
|
|
387
|
+
|
|
388
|
+
def refresh_metrics(self, available: QRect | None = None) -> None:
|
|
389
|
+
"""Keep the form on the usable screen area without turning its starting size into a minimum."""
|
|
390
|
+
fit_window(self, 0, 0, available)
|
|
391
|
+
|
|
392
|
+
def starting_width(self) -> int:
|
|
393
|
+
"""Return the capture's width, widened by what the caption and side-button columns need beyond the capture's.
|
|
394
|
+
|
|
395
|
+
With the captures' text every caption and the Browse… buttons fit their capture widths and the form keeps the
|
|
396
|
+
capture's width. A larger system text widens both columns; the form widens by the same amount, so the fields
|
|
397
|
+
keep the width they have on the capture instead of giving it up.
|
|
398
|
+
"""
|
|
399
|
+
caption_width = FORM_GRID_CAPTION_WIDTH
|
|
400
|
+
for caption in self.caption_labels:
|
|
401
|
+
caption_width = max(caption_width, caption.sizeHint().width())
|
|
402
|
+
side_button_width = max(FORM_GRID_SIDE_BUTTON_MIN_WIDTH, self.url_browse_button.width())
|
|
403
|
+
return CLONE_WIDTH + caption_width - FORM_GRID_CAPTION_WIDTH + side_button_width - FORM_GRID_SIDE_BUTTON_MIN_WIDTH
|
|
404
|
+
|
|
405
|
+
def repository_url(self) -> str:
|
|
406
|
+
"""Return the repository to clone as typed, without the blanks around it and with ``~`` read as the home folder.
|
|
407
|
+
|
|
408
|
+
A URL pasted from a web page often carries a trailing space. ``~`` alone or ``~/`` at the start stands for the
|
|
409
|
+
home folder where that is the usage; only a local folder can change, since no HTTPS, SSH or ``file://`` address
|
|
410
|
+
starts with ``~/``. The field keeps the typed text. The Account row, the branch list, the proposed subdirectory and
|
|
411
|
+
the clone itself all read the URL through here, so they always agree on the same address.
|
|
412
|
+
"""
|
|
413
|
+
return typed_path_value(self.url_edit.text(), self.typed_path)
|
|
414
|
+
|
|
415
|
+
def destination_dir(self) -> str:
|
|
416
|
+
"""Return the typed Destination folder without the blanks around it and with ``~`` read as the home folder.
|
|
417
|
+
|
|
418
|
+
A pasted path names the folder the user sees, and ``~`` is read where that is the usage. This is the folder the
|
|
419
|
+
clone starts in, kept as it is in the list of destinations, so that list only holds full paths.
|
|
420
|
+
"""
|
|
421
|
+
return typed_path_value(self.parent_edit.text(), self.typed_path)
|
|
422
|
+
|
|
423
|
+
def subdirectory_name(self) -> str:
|
|
424
|
+
"""Return the typed subdirectory to create without the blanks around it, so the clone never creates a folder named with a space."""
|
|
425
|
+
return typed_value(self.name_edit.text())
|
|
426
|
+
|
|
427
|
+
def target_dir(self) -> str:
|
|
428
|
+
"""Return the folder the clone will create, or an empty text while the destination or the subdirectory is missing."""
|
|
429
|
+
parent_dir, name = self.destination_dir(), self.subdirectory_name()
|
|
430
|
+
return os.path.join(parent_dir, name) if parent_dir and name else ""
|
|
431
|
+
|
|
432
|
+
def clone_options(self) -> CloneOptions:
|
|
433
|
+
"""Return what the user asked Git to do besides copying the repository: the branch, the submodules, the history depth and a bare repository.
|
|
434
|
+
|
|
435
|
+
A bare repository has no working tree, so its submodules are never initialized, whatever the greyed box shows.
|
|
436
|
+
"""
|
|
437
|
+
bare = self.bare_radio.isChecked()
|
|
438
|
+
return CloneOptions(branch=self._branch_name(), initialize_submodules=self.submodule_check.isChecked() and not bare,
|
|
439
|
+
full_history=self.full_history_check.isChecked(), bare=bare)
|
|
440
|
+
|
|
441
|
+
def set_destination_dirs(self, destination_dirs: list[str]) -> None:
|
|
442
|
+
"""Offer the parent folders of the last clones in the Destination list, the most recent in the field, without looking at the disk."""
|
|
443
|
+
self.destination_combo.clear()
|
|
444
|
+
for destination_dir in destination_dirs:
|
|
445
|
+
self.destination_combo.addItem(destination_dir)
|
|
446
|
+
self.parent_edit.setText(destination_dirs[MOST_RECENT_DESTINATION_INDEX] if destination_dirs else "")
|
|
447
|
+
|
|
448
|
+
def show_branches(self, branches: tuple[str, ...]) -> None:
|
|
449
|
+
"""Offer the remote HEAD entry then these branches, keep whatever the user already typed or chose, and clear what shows beside the Branch list."""
|
|
450
|
+
self._fill_branches(branches)
|
|
451
|
+
self._show_branch_read_state(BranchReadState.IDLE)
|
|
452
|
+
|
|
453
|
+
def set_branch_loading(self) -> None:
|
|
454
|
+
"""Turn the indicator beside the Branch list while the remote branches are read; the list stays usable meanwhile."""
|
|
455
|
+
self._show_branch_read_state(BranchReadState.READING)
|
|
456
|
+
|
|
457
|
+
def show_branch_failure(self) -> None:
|
|
458
|
+
"""Show the warning icon beside the Branch list, its tooltip saying the branches could not be read; cloning the remote HEAD or a typed branch stays possible."""
|
|
459
|
+
self._fill_branches(())
|
|
460
|
+
self._show_branch_read_state(BranchReadState.FAILED)
|
|
461
|
+
|
|
462
|
+
def _show_branch_read_state(self, state: BranchReadState) -> None:
|
|
463
|
+
"""Put the Branch row's indicator cell in one state, so the turning indicator and the warning icon never show together."""
|
|
464
|
+
if state is BranchReadState.READING:
|
|
465
|
+
self.branch_warning_label.hide()
|
|
466
|
+
self.branch_busy_indicator.start()
|
|
467
|
+
return
|
|
468
|
+
self.branch_busy_indicator.stop()
|
|
469
|
+
self.branch_warning_label.setVisible(state is BranchReadState.FAILED)
|
|
470
|
+
|
|
471
|
+
def _fill_branches(self, branches: tuple[str, ...]) -> None:
|
|
472
|
+
"""Rebuild the Branch list without losing the text in its field; signals are blocked so the change is never taken for a user choice.
|
|
473
|
+
|
|
474
|
+
The field gets back exactly what was typed, blanks included: only the branch sent to Git loses them.
|
|
475
|
+
"""
|
|
476
|
+
typed = self.branch_combo.currentText()
|
|
477
|
+
self.branch_combo.blockSignals(True)
|
|
478
|
+
self.branch_combo.clear()
|
|
479
|
+
self.branch_combo.addItem(DEFAULT_BRANCH_TEXT, "")
|
|
480
|
+
for branch in branches:
|
|
481
|
+
self.branch_combo.addItem(branch, branch)
|
|
482
|
+
index = self.branch_combo.findText(typed)
|
|
483
|
+
if not typed or index >= 0:
|
|
484
|
+
self.branch_combo.setCurrentIndex(max(index, DEFAULT_BRANCH_INDEX))
|
|
485
|
+
else:
|
|
486
|
+
self.branch_combo.setEditText(typed)
|
|
487
|
+
self.branch_combo.blockSignals(False)
|
|
488
|
+
|
|
489
|
+
def restart_branch_list(self) -> None:
|
|
490
|
+
"""Drop any branch list of the previous URL or account and plan a new one once the user stops changing them.
|
|
491
|
+
|
|
492
|
+
Also called once an account was added from the form: the new account may see branches the last list could not read.
|
|
493
|
+
"""
|
|
494
|
+
self.branch_list_outdated.emit()
|
|
495
|
+
self.branch_timer.start()
|
|
496
|
+
|
|
497
|
+
def set_running(self, running: bool) -> None:
|
|
498
|
+
"""Prevent duplicate submissions while leaving cancellation accessible."""
|
|
499
|
+
self.running = running
|
|
500
|
+
for widget in (self.url_edit, self.url_browse_button, self.destination_combo, self.browse_button, self.name_edit, self.branch_combo,
|
|
501
|
+
self.repository_type_group, self.submodule_check, self.full_history_check, self.account_section):
|
|
502
|
+
widget.setEnabled(not running and not self.partial)
|
|
503
|
+
if self.bare_radio.isChecked():
|
|
504
|
+
self.submodule_check.setEnabled(False)
|
|
505
|
+
self.cancel_button.setText("Cancel clone" if running else "Close")
|
|
506
|
+
self.cancel_button.setEnabled(True)
|
|
507
|
+
self.open_button.setEnabled(not running)
|
|
508
|
+
if self.authentication_endpoint:
|
|
509
|
+
self.account_section.setEnabled(not running)
|
|
510
|
+
if running:
|
|
511
|
+
self.status_label.setText(CLONE_RUNNING_TEXT)
|
|
512
|
+
self._update_target()
|
|
513
|
+
|
|
514
|
+
def show_failure(self, cause: str, partial: bool = False, *, explained: bool = False) -> None:
|
|
515
|
+
"""Show a failed clone or a failed opening in bold red, keeping the captured form for a retry.
|
|
516
|
+
|
|
517
|
+
After a clone that worked but could not be opened, only opening is offered again, never a second clone. An
|
|
518
|
+
``explained`` cause already starts with a sentence saying what went wrong and what to do, so the generic
|
|
519
|
+
sentence is not put in front of it.
|
|
520
|
+
"""
|
|
521
|
+
self._stop(partial)
|
|
522
|
+
if explained:
|
|
523
|
+
self.status_label.show_failure(cause)
|
|
524
|
+
return
|
|
525
|
+
self.status_label.show_failure((OPEN_FAILED_TEXT if partial else CLONE_FAILED_TEXT) + "\n" + cause)
|
|
526
|
+
|
|
527
|
+
def show_cancelled(self, cause: str, partial: bool = False) -> None:
|
|
528
|
+
"""Show a cancellation in ordinary text: the user asked for it, so it is not an error."""
|
|
529
|
+
self._stop(partial)
|
|
530
|
+
self.status_label.setText(cause)
|
|
531
|
+
|
|
532
|
+
def show_partial_submodules(self, cause: str, usable: bool, cancelled: bool = False, explained: bool = False) -> None:
|
|
533
|
+
"""Offer missing-child recovery separately from opening the preserved parent.
|
|
534
|
+
|
|
535
|
+
The state is shown in bold red, as a failure, unless the user cancelled the clone. An ``explained`` cause already
|
|
536
|
+
starts with the sentence that says which submodule Git could not check out, so it is shown as it is, without the
|
|
537
|
+
partial-clone sentence in front. Otherwise the partial-clone sentence comes first, once, even when the cause
|
|
538
|
+
already starts with it. Retry submodules stays offered either way.
|
|
539
|
+
"""
|
|
540
|
+
self.partial = True
|
|
541
|
+
self.recursive_partial = True
|
|
542
|
+
self.set_running(False)
|
|
543
|
+
self.clone_button.setText("Retry submodules")
|
|
544
|
+
self.open_button.setVisible(usable)
|
|
545
|
+
text = cause if explained else SUBMODULE_PARTIAL_TEXT + "\n" + cause.removeprefix(SUBMODULE_PARTIAL_TEXT).lstrip("\n")
|
|
546
|
+
self.status_label.show_result(text, ResultTone.NEUTRAL if cancelled else ResultTone.FAILURE)
|
|
547
|
+
|
|
548
|
+
def clear_result(self) -> None:
|
|
549
|
+
"""Put the ordinary help back once the user acts again, so an old error is never read as the answer to a new action.
|
|
550
|
+
|
|
551
|
+
A running clone keeps its wait text, and a partial clone keeps its message: it still describes what the form
|
|
552
|
+
offers (Retry submodules, Open repository).
|
|
553
|
+
"""
|
|
554
|
+
if self.running or self.partial or self.status_label.tone is not ResultTone.FAILURE:
|
|
555
|
+
return
|
|
556
|
+
self.status_label.setText(EMPTY_HELP)
|
|
557
|
+
|
|
558
|
+
def _stop(self, partial: bool) -> None:
|
|
559
|
+
"""Leave the running state and offer the action that fits what the clone left behind."""
|
|
560
|
+
self.partial = partial
|
|
561
|
+
self.set_running(False)
|
|
562
|
+
self.clone_button.setText("Open repository" if partial else "Clone")
|
|
563
|
+
|
|
564
|
+
def account_choice(self) -> str | None:
|
|
565
|
+
"""Return the account namespace chosen for the new repository, or None when it follows the global default.
|
|
566
|
+
|
|
567
|
+
Without an HTTPS address, or with a greyed menu (no connected account for the address), no account applies and
|
|
568
|
+
None is returned too. The menu's own state is read, not the row's: a running clone greys the whole row
|
|
569
|
+
without changing the choice.
|
|
570
|
+
"""
|
|
571
|
+
if not self.account_origin or not self.account_combo.isEnabledTo(self.account_section):
|
|
572
|
+
return None
|
|
573
|
+
return self.account_combo.currentData()
|
|
574
|
+
|
|
575
|
+
def _account_chosen_by_hand(self) -> None:
|
|
576
|
+
"""Remember that the user picked the account, and drop the line of an earlier switch the form made by itself."""
|
|
577
|
+
self.account_chosen_by_user = True
|
|
578
|
+
self.forget_owner_switch()
|
|
579
|
+
|
|
580
|
+
def choose_owner_account(self, namespace: str, owner: str, note: str) -> None:
|
|
581
|
+
"""Switch the menu to the account of the address's owner, say so under the check boxes and read the branches again with it.
|
|
582
|
+
|
|
583
|
+
The choice made before and the owner are kept, so that an address naming another owner can put that choice back.
|
|
584
|
+
Nothing changes when the menu does not offer that account. No signal is blocked: ``setCurrentIndex`` never emits
|
|
585
|
+
``activated``, the only signal through which a choice by hand arrives.
|
|
586
|
+
"""
|
|
587
|
+
index = self.account_combo.findData(namespace)
|
|
588
|
+
if index < 0:
|
|
589
|
+
return
|
|
590
|
+
self.account_before_owner_switch = self.account_choice()
|
|
591
|
+
self.owner_switch_namespace = namespace
|
|
592
|
+
self.owner_switch_owner = owner.casefold()
|
|
593
|
+
self.account_combo.setCurrentIndex(index)
|
|
594
|
+
self.show_account_status(note)
|
|
595
|
+
self.owner_account_note = note
|
|
596
|
+
self.restart_branch_list()
|
|
597
|
+
|
|
598
|
+
def forget_owner_switch(self) -> None:
|
|
599
|
+
"""Drop what the form kept of its switch to the owner's account, and the line that said so while it is still shown."""
|
|
600
|
+
if self.owner_account_note and self.account_status_label.text() == self.owner_account_note:
|
|
601
|
+
self.show_account_status("")
|
|
602
|
+
self.owner_account_note = ""
|
|
603
|
+
self.owner_switch_namespace = ""
|
|
604
|
+
self.owner_switch_owner = ""
|
|
605
|
+
self.account_before_owner_switch = None
|
|
606
|
+
|
|
607
|
+
def show_account_state(self, text: str, add_account_enabled: bool) -> None:
|
|
608
|
+
"""Grey the account menu with one line saying why no account can be chosen, and allow Add account… only when it can add one here.
|
|
609
|
+
|
|
610
|
+
The row stays in place whatever the URL, so the form never changes size. Signals are blocked so the change is
|
|
611
|
+
never taken for a user choice.
|
|
612
|
+
"""
|
|
613
|
+
self.account_combo.blockSignals(True)
|
|
614
|
+
self.account_combo.clear()
|
|
615
|
+
self.account_combo.addItem(text, None)
|
|
616
|
+
self.account_combo.setEnabled(False)
|
|
617
|
+
self.account_combo.blockSignals(False)
|
|
618
|
+
self.account_combo.setToolTip("")
|
|
619
|
+
self.add_account_button.setEnabled(add_account_enabled)
|
|
620
|
+
|
|
621
|
+
def show_account_status(self, text: str) -> None:
|
|
622
|
+
"""Write the line under the check boxes that reports on the account (sign-in result, unreadable accounts, submodule sign-in).
|
|
623
|
+
|
|
624
|
+
The line is shown only while it has something to say: an empty text hides it, so the help text moves back
|
|
625
|
+
right under the check boxes instead of leaving a blank line in the form.
|
|
626
|
+
"""
|
|
627
|
+
self.account_status_label.setText(text)
|
|
628
|
+
self.account_status_label.setVisible(bool(text))
|
|
629
|
+
|
|
630
|
+
def reject(self) -> None:
|
|
631
|
+
"""Request cancellation before closing any pending clone intention, including an open sign-in window."""
|
|
632
|
+
self.cancel_requested.emit()
|
|
633
|
+
add_dialog = self.add_account_dialog
|
|
634
|
+
if add_dialog is not None and isValid(add_dialog):
|
|
635
|
+
add_dialog.abandon()
|
|
636
|
+
add_dialog.reject()
|
|
637
|
+
super().reject()
|
|
638
|
+
|
|
639
|
+
def _build_account_section(self) -> QWidget:
|
|
640
|
+
"""Build the Account row's field part: the account menu, with Add account… on its right where the other rows have Browse…."""
|
|
641
|
+
self.account_section = QWidget()
|
|
642
|
+
section_layout = QHBoxLayout(self.account_section)
|
|
643
|
+
section_layout.setContentsMargins(0, 0, 0, 0)
|
|
644
|
+
section_layout.setSpacing(FORM_GRID_COLUMN_SPACING)
|
|
645
|
+
self.account_combo = QComboBox()
|
|
646
|
+
self.account_combo.setAccessibleName("Account for this repository")
|
|
647
|
+
section_layout.addWidget(self.account_combo, 1)
|
|
648
|
+
self.add_account_button = self._side_button(ADD_ACCOUNT_TEXT)
|
|
649
|
+
section_layout.addWidget(self.add_account_button)
|
|
650
|
+
return self.account_section
|
|
651
|
+
|
|
652
|
+
def _side_button(self, text: str) -> QPushButton:
|
|
653
|
+
"""Build a button of the right-hand column, which Enter never triggers: Enter is kept for Clone."""
|
|
654
|
+
button = QPushButton(text)
|
|
655
|
+
button.setAutoDefault(False)
|
|
656
|
+
return button
|
|
657
|
+
|
|
658
|
+
def _build_branch_marker_row(self) -> QHBoxLayout:
|
|
659
|
+
"""Build the cell right of the Branch list, where the other rows have Browse…: a turning indicator and a warning icon of the same size.
|
|
660
|
+
|
|
661
|
+
Keeping them in the button column leaves the Branch list as wide as the other fields and adds no line under it.
|
|
662
|
+
The indicator takes no mouse input, so the reading text is only its accessible name; the icon carries the
|
|
663
|
+
failure text as a tooltip.
|
|
664
|
+
"""
|
|
665
|
+
self.branch_busy_indicator = BusyIndicator()
|
|
666
|
+
self.branch_busy_indicator.setAccessibleName(BRANCH_LOADING_TEXT)
|
|
667
|
+
marker_size = self.branch_busy_indicator.sizeHint()
|
|
668
|
+
self.branch_warning_label = StandardIconLabel(marker_size, QStyle.StandardPixmap.SP_MessageBoxWarning)
|
|
669
|
+
self.branch_warning_label.setToolTip(BRANCH_FAILED_TEXT)
|
|
670
|
+
self.branch_warning_label.setAccessibleName(BRANCH_FAILED_TEXT)
|
|
671
|
+
self.branch_warning_label.hide()
|
|
672
|
+
marker_row = QHBoxLayout()
|
|
673
|
+
marker_row.setContentsMargins(0, 0, 0, 0)
|
|
674
|
+
marker_row.addWidget(self.branch_busy_indicator, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
675
|
+
marker_row.addWidget(self.branch_warning_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
|
676
|
+
marker_row.addStretch(1)
|
|
677
|
+
return marker_row
|
|
678
|
+
|
|
679
|
+
def _apply_windows_look(self) -> None:
|
|
680
|
+
"""Give fields, lists and side buttons the Windows 10 look and height of the capture, and the side buttons one common width."""
|
|
681
|
+
for edit in (self.url_edit, self.name_edit):
|
|
682
|
+
apply_windows_line_edit_look(edit)
|
|
683
|
+
for combo, editable in ((self.account_combo, False), (self.destination_combo, True), (self.branch_combo, True)):
|
|
684
|
+
apply_windows_combo_look(combo, editable=editable)
|
|
685
|
+
folder_icon = action_icon("folder", self.devicePixelRatioF())
|
|
686
|
+
for button in (self.url_browse_button, self.browse_button):
|
|
687
|
+
button.setIcon(folder_icon)
|
|
688
|
+
apply_side_button_look((self.url_browse_button, self.browse_button, self.add_account_button))
|
|
689
|
+
|
|
690
|
+
def apply_theme_look(self) -> None:
|
|
691
|
+
"""Lay everything of Clone's look that depends on the light or dark appearance; the one place that lays it again after a switch.
|
|
692
|
+
|
|
693
|
+
The fields, lists and side buttons take their light sheets or lose them, and the information box takes its own
|
|
694
|
+
yellow and shows its last line again in the color of the new appearance. Nothing the user typed or chose is
|
|
695
|
+
touched.
|
|
696
|
+
"""
|
|
697
|
+
self._apply_windows_look()
|
|
698
|
+
self._paint_target_frame()
|
|
699
|
+
self._show_target(*self.shown_target)
|
|
700
|
+
|
|
701
|
+
def _paint_target_frame(self) -> None:
|
|
702
|
+
"""Paint the target box in its own yellow, pale in a light theme and dark in a dark one, decided on the application palette.
|
|
703
|
+
|
|
704
|
+
The application palette tells the appearance the user chose; the form's own palette can be pinned by its layout.
|
|
705
|
+
"""
|
|
706
|
+
dark = is_dark_palette(QApplication.palette())
|
|
707
|
+
palette = self.target_frame.palette()
|
|
708
|
+
palette.setColor(QPalette.ColorRole.Window, QColor(TARGET_BOX_DARK_BACKGROUND_COLOR if dark else TARGET_BOX_LIGHT_BACKGROUND_COLOR))
|
|
709
|
+
# A plain box draws its line in the text color, so the frame's text color is the border and the label keeps its own.
|
|
710
|
+
palette.setColor(QPalette.ColorRole.WindowText, QColor(TARGET_BOX_DARK_BORDER_COLOR if dark else TARGET_BOX_LIGHT_BORDER_COLOR))
|
|
711
|
+
self.target_frame.setPalette(palette)
|
|
712
|
+
|
|
713
|
+
def _show_target(self, target_dir: str, exists: bool | None) -> None:
|
|
714
|
+
"""Say where the clone will go and, once checked, whether that folder is new; an existing folder is written in red.
|
|
715
|
+
|
|
716
|
+
``exists`` is None while the check has not run yet for this path. The text takes the box's own color for the
|
|
717
|
+
appearance of the application palette, and what was shown is kept so the box can be shown again after the
|
|
718
|
+
appearance changes.
|
|
719
|
+
"""
|
|
720
|
+
self.shown_target = (target_dir, exists)
|
|
721
|
+
line = target_dir
|
|
722
|
+
if target_dir and exists is not None:
|
|
723
|
+
line = target_dir + " " + (TARGET_EXISTS_TEXT if exists else TARGET_NEW_TEXT)
|
|
724
|
+
self.target_label.setText(TARGET_INTRO_TEXT + "\n" + line)
|
|
725
|
+
dark = is_dark_palette(QApplication.palette())
|
|
726
|
+
color = QColor(TARGET_BOX_DARK_TEXT_COLOR if dark else TARGET_BOX_LIGHT_TEXT_COLOR)
|
|
727
|
+
if exists:
|
|
728
|
+
color = QColor(ERROR_DARK_COLOR if dark else ERROR_LIGHT_COLOR)
|
|
729
|
+
palette = self.target_label.palette()
|
|
730
|
+
palette.setColor(QPalette.ColorRole.WindowText, color)
|
|
731
|
+
self.target_label.setPalette(palette)
|
|
732
|
+
|
|
733
|
+
def _stop_timers(self) -> None:
|
|
734
|
+
"""Stop the pending branch list, the target check and the Branch row's turning indicator of a form that just closed.
|
|
735
|
+
|
|
736
|
+
A closed form stays alive, hidden, and its branch list is dropped by the clone controller, so nothing would ever stop
|
|
737
|
+
the indicator otherwise. A warning icon already shown stays: it costs nothing and is still true.
|
|
738
|
+
"""
|
|
739
|
+
self.branch_timer.stop()
|
|
740
|
+
self.target_check_timer.stop()
|
|
741
|
+
self.branch_busy_indicator.stop()
|
|
742
|
+
|
|
743
|
+
def _check_target(self) -> None:
|
|
744
|
+
"""Look whether the target folder exists, once the destination stopped changing.
|
|
745
|
+
|
|
746
|
+
Only this one path is looked at, and only after a pause: reading the disk while the form is filled or at each
|
|
747
|
+
key press would freeze the window on a disconnected network drive. A running clone creates the folder itself,
|
|
748
|
+
so it is not checked then.
|
|
749
|
+
"""
|
|
750
|
+
target_dir = self.target_dir()
|
|
751
|
+
if self.running or not target_dir:
|
|
752
|
+
return
|
|
753
|
+
self._show_target(target_dir, self.target_exists(target_dir))
|
|
754
|
+
|
|
755
|
+
def _update_target(self) -> None:
|
|
756
|
+
"""Keep the target box, the accessible action and the Clone button in step with the form, and plan a new existence check."""
|
|
757
|
+
target_dir = self.target_dir()
|
|
758
|
+
self.target_check_timer.stop()
|
|
759
|
+
self._show_target(target_dir, None)
|
|
760
|
+
if target_dir:
|
|
761
|
+
self.target_check_timer.start()
|
|
762
|
+
self.clone_button.setAccessibleDescription("Clone repository into " + target_dir)
|
|
763
|
+
self.clone_button.setEnabled(not self.running and bool(self.repository_url() and target_dir))
|
|
764
|
+
|
|
765
|
+
def _branch_name(self) -> str:
|
|
766
|
+
"""Return the branch typed or chosen, without the blanks around it, or an empty text for the remote HEAD entry."""
|
|
767
|
+
text = typed_value(self.branch_combo.currentText())
|
|
768
|
+
index = self.branch_combo.findText(text)
|
|
769
|
+
if index < 0:
|
|
770
|
+
return text
|
|
771
|
+
data = self.branch_combo.itemData(index)
|
|
772
|
+
return text if data is None else str(data)
|
|
773
|
+
|
|
774
|
+
def _cancel_or_close(self) -> None:
|
|
775
|
+
"""Keep a cancelled clone's form visible while its process finishes stopping."""
|
|
776
|
+
if self.running:
|
|
777
|
+
self.cancel_button.setEnabled(False)
|
|
778
|
+
self.status_label.setText(STOPPING_TEXT)
|
|
779
|
+
self.cancel_requested.emit()
|
|
780
|
+
return
|
|
781
|
+
self.reject()
|
|
782
|
+
|
|
783
|
+
def _submit(self) -> None:
|
|
784
|
+
"""Emit one explicit action for the currently retained clone result."""
|
|
785
|
+
if not self.clone_button.isEnabled():
|
|
786
|
+
return
|
|
787
|
+
self.clear_result()
|
|
788
|
+
if self.recursive_partial:
|
|
789
|
+
self.retry_requested.emit()
|
|
790
|
+
elif self.partial:
|
|
791
|
+
self.open_requested.emit()
|
|
792
|
+
else:
|
|
793
|
+
self.clone_requested.emit()
|
|
794
|
+
|
|
795
|
+
def _name_edited(self, text: str) -> None:
|
|
796
|
+
self.custom_name = True
|
|
797
|
+
|
|
798
|
+
def _url_changed(self, text: str) -> None:
|
|
799
|
+
"""Propose the last part of the URL or folder as the subdirectory until the user types one, and read the new URL's branches after a pause.
|
|
800
|
+
|
|
801
|
+
The branches of the previous URL are removed at once: they would describe another repository.
|
|
802
|
+
"""
|
|
803
|
+
self._propose_name()
|
|
804
|
+
self._update_target()
|
|
805
|
+
self.show_branches(())
|
|
806
|
+
self.restart_branch_list()
|
|
807
|
+
|
|
808
|
+
def _repository_type_changed(self, bare: bool) -> None:
|
|
809
|
+
"""Grey Initialize all submodules for a bare repository, which has no working tree, and propose the subdirectory with or without .git."""
|
|
810
|
+
self.submodule_check.setEnabled(not bare and not self.running and not self.partial)
|
|
811
|
+
self._propose_name()
|
|
812
|
+
|
|
813
|
+
def _propose_name(self) -> None:
|
|
814
|
+
"""Propose the subdirectory from the URL, ending in .git for a bare repository, unless the user typed a name."""
|
|
815
|
+
if self.custom_name:
|
|
816
|
+
return
|
|
817
|
+
try:
|
|
818
|
+
name = suggested_name(self.repository_url())
|
|
819
|
+
except ValueError:
|
|
820
|
+
self.name_edit.clear()
|
|
821
|
+
return
|
|
822
|
+
if name and self.bare_radio.isChecked():
|
|
823
|
+
name += BARE_SUFFIX
|
|
824
|
+
self.name_edit.setText(name)
|
|
825
|
+
|
|
826
|
+
def _browse(self) -> None:
|
|
827
|
+
"""Choose the destination folder asynchronously and preserve the typed text when cancelled."""
|
|
828
|
+
self._open_folder_dialog("Destination", self.destination_dir(), self.parent_edit)
|
|
829
|
+
|
|
830
|
+
def _browse_repository(self) -> None:
|
|
831
|
+
"""Choose a local repository folder to clone, asynchronously, starting from the folder already typed if any.
|
|
832
|
+
|
|
833
|
+
The start folder follows the typed address, ``~`` read where that is the usage; an address that is not an
|
|
834
|
+
absolute folder starts from the destination instead.
|
|
835
|
+
"""
|
|
836
|
+
start_dir = typed_start_dir(self.url_edit.text(), self.typed_path) or self.destination_dir()
|
|
837
|
+
self._open_folder_dialog("Repository to clone", start_dir, self.url_edit)
|
|
838
|
+
|
|
839
|
+
def _open_folder_dialog(self, title: str, start_dir: str, edit: QLineEdit) -> None:
|
|
840
|
+
"""Open a folder chooser that writes the chosen folder into ``edit`` with the system's separators; cancelling changes nothing."""
|
|
841
|
+
dialog = QFileDialog(self, title, start_dir)
|
|
842
|
+
dialog.setFileMode(QFileDialog.FileMode.Directory)
|
|
843
|
+
dialog.setOption(QFileDialog.Option.ShowDirsOnly, True)
|
|
844
|
+
dialog.fileSelected.connect(lambda chosen_dir: edit.setText(os.path.normpath(chosen_dir)))
|
|
845
|
+
dialog.fileSelected.connect(self.clear_result)
|
|
846
|
+
dialog.finished.connect(dialog.deleteLater)
|
|
847
|
+
dialog.open()
|
|
848
|
+
|
|
849
|
+
def check_clipboard_repository(self, directory: str) -> None:
|
|
850
|
+
"""Ask on a background thread whether a folder path found on the clipboard is a repository, and write it into the field if so.
|
|
851
|
+
|
|
852
|
+
The disk is never read on the form's thread: a sleeping disk or a dead share must not freeze the form. Nothing
|
|
853
|
+
is started from the path, and a path that is not a repository is dropped without a word, like any other text.
|
|
854
|
+
No mount table is read for the grouping: there is only one folder to ask about, so there is nothing to group.
|
|
855
|
+
"""
|
|
856
|
+
if self.clipboard_repository_check is None:
|
|
857
|
+
self.clipboard_repository_check = RecentAvailabilityCheck(weak_signal_emitter(self, lambda form: form.clipboard_repository_checked),
|
|
858
|
+
is_reachable=self.is_clipboard_repository, read_mount_table=None)
|
|
859
|
+
self.clipboard_repository_check.request([directory])
|
|
860
|
+
|
|
861
|
+
def _fill_clipboard_repository(self, directory: str, is_repository: bool) -> None:
|
|
862
|
+
"""Write a clipboard path found to be a repository into the field, only while the form is open and the field still empty.
|
|
863
|
+
|
|
864
|
+
The user may have typed or pasted an address while the disk answered: that text is never replaced.
|
|
865
|
+
"""
|
|
866
|
+
check = self.clipboard_repository_check
|
|
867
|
+
if not is_repository or check is None or check.closed or self.url_edit.text():
|
|
868
|
+
return
|
|
869
|
+
self.url_edit.setText(directory)
|
|
870
|
+
|
|
871
|
+
def _close_clipboard_repository_check(self) -> None:
|
|
872
|
+
"""Stop the clipboard path check of a finished form: an answer still to come is dropped."""
|
|
873
|
+
if self.clipboard_repository_check is not None:
|
|
874
|
+
self.clipboard_repository_check.close()
|
|
875
|
+
|
|
876
|
+
def _restore_focus(self) -> None:
|
|
877
|
+
"""Return keyboard focus to the original welcome action when it still exists."""
|
|
878
|
+
if self.return_focus is not None and isValid(self.return_focus):
|
|
879
|
+
self.return_focus.setFocus()
|