diff --git a/spyder/config/main.py b/spyder/config/main.py index 3cd2e358f2c..9fe164efa2d 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -314,6 +314,10 @@ 'enable': True, 'max_entries': 30, }), + ('vcs', + { + 'enable': True, + }), ('workingdir', { 'working_dir_adjusttocontents': False, @@ -626,6 +630,7 @@ 'history_filenames', ] ), + ] } diff --git a/spyder/plugins/vcs/utils/__init__.py b/spyder/plugins/vcs/backend/__init__.py similarity index 100% rename from spyder/plugins/vcs/utils/__init__.py rename to spyder/plugins/vcs/backend/__init__.py diff --git a/spyder/plugins/vcs/utils/api.py b/spyder/plugins/vcs/backend/api.py similarity index 90% rename from spyder/plugins/vcs/utils/api.py rename to spyder/plugins/vcs/backend/api.py index 52d6ad297c3..d3385510c39 100644 --- a/spyder/plugins/vcs/utils/api.py +++ b/spyder/plugins/vcs/backend/api.py @@ -9,21 +9,20 @@ """Backend specifications and utilities for VCSs.""" # Standard library imports +import typing import builtins import itertools -import typing +from functools import partial # Local imports -from .errors import VCSError, VCSBackendFail, VCSAuthError - -_generic_func = typing.Callable[..., object] +from .errors import VCSError, VCSAuthError, VCSBackendFail def feature( name: str = None, enabled: bool = True, - extra: typing.Optional[typing.Dict[object, object]] = None, -) -> _generic_func: + extra: typing.Optional[typing.Dict[object, object]] = None +) -> typing.Callable[..., object]: """ Decorate a function to become a feature. @@ -92,13 +91,13 @@ def feature( if extra is None: extra = {} - def _decorator(func: _generic_func) -> _generic_func: + def _decorator(func: typing.Callable[..., object]): if name is not None: # Set the new function name func.__name__ = name func.enabled = enabled func.extra = extra - func._is_feature = True + func._is_feature = True # pylint: disable=W0212 return func return _decorator @@ -178,10 +177,17 @@ def to_string(cls, state: int) -> str: raise AttributeError("Given state {} does not exists".format(state)) +# TODO: Complete backend detailed description class VCSBackendBase(object): """ Uniforms VCS fundamental operations across different VCSs. + **Instance usage** + + Unlike backend implementators, backend users should use only + features (methods marked with :func:`~feature`) + and common attributes. + **Error handling** By default, if a generic error occurred, @@ -241,12 +247,11 @@ class VCSBackendBase(object): "unstage", "stage_all", "unstage_all", - "undo_stage", ), "commit": ("commit", "undo_commit"), "remote": ("fetch", "push", "pull"), "undo": - ("undo_commit", "undo_stage", "undo_change", "undo_change_all"), + ("unstage", "undo_commit", "undo_change", "undo_change_all"), "history": ( ("tags", "fget"), "get_last_commits", @@ -340,8 +345,8 @@ class VCSBackendBase(object): Upload the local revision to the remote repository. - undo: Allows to undo an operation in the repository - :meth:`~VCSBackendBase.undo_stage` - Unstage a file (same as :meth:`~VCSBackendBase.unstage`) + :meth:`~VCSBackendBase.unstage` + Unstage a file. :meth:`~VCSBackendBase.undo_commit` Undo the last commit. @@ -412,18 +417,19 @@ def _name_to_feature( featurename: typing.Union[typing.Tuple[str, str], str]) -> object: if isinstance(featurename, str): - feature = getattr(cls, featurename, None) + feature_func = getattr(cls, featurename, None) elif len(featurename) > 1: # Only the first and the second element are considered - feature = getattr(getattr(cls, featurename[0], None), - featurename[1], None) + feature_func = getattr(getattr(cls, featurename[0], None), + featurename[1], None) else: - feature = None + feature_func = None - if (feature is None or getattr(feature, "_is_feature", False)): + if (feature_func is None + or getattr(feature, "_is_feature", False)): raise TypeError("Invalid feature name {}".format(featurename)) - return feature + return feature_func group = cls.GROUPS_MAPPING.get(group) if group is None: @@ -465,16 +471,22 @@ def _dummy_feature(self, *_, **__): # check if property is a feature is_feature = getattr(attr.fget, "_is_feature", None) if is_feature: - # Add disabled feature for fget and fdel if they does not exists. + # Add disabled features for fset and fdel + # if they do not exists. if attr.fset is None: - attr.setter(feature(name=key, enabled=False)(_dummy_feature)) + attr.setter( + feature(name=key, enabled=False)(_dummy_feature)) elif not getattr(attr.fset, "_is_feature", None): - attr.setter(feature(name=key, enabled=False)(attr.fset)) + attr.setter( + feature(name=key, enabled=False)(attr.fset)) if attr.fdel is None: - attr.deleter(feature(name=key, enabled=False)(_dummy_feature)) + attr.deleter( + feature(name=key, enabled=False)(_dummy_feature)) elif not getattr(attr.fdel, "_is_feature", None): - attr.deleter(feature(name=key, enabled=False)(attr.fdel)) + attr.deleter( + feature(name=key, enabled=False)(attr.fdel)) + @property def type(self) -> type: """ @@ -482,7 +494,7 @@ def type(self) -> type: Useful when the backend object is hidden by the manager and you need to access to the property objects - for them features. + for their features. """ return type(self) @@ -618,12 +630,12 @@ def changes(self) -> typing.Sequence[typing.Dict[str, object]]: def change( self, path: str, - prefer_unstaged: bool = False, + prefer_unstaged: bool = False ) -> typing.Optional[typing.Dict[str, object]]: """ Get the state dict associated of path. - The state dict can have several optional fields (all of them optional): + The state dict can have several optional fields: path : :class:`str` The path to the file. @@ -651,8 +663,8 @@ def change( The relative path. prefer_unstaged : bool - If True, if the path has both the staged status - and the unstaged one, the latter will be returned, + If True, if the path has both a staged status + and a unstaged one, the latter will be returned, otherwise the first will be returned. The default is False. @@ -916,9 +928,8 @@ def pull(self, fetch: bool = True) -> bool: # pylint: disable=W0613 Returns ------- - bool - True if the pull was done correcly. - False otherwise. + True if the pull was done correcly and there are no commits to pull, + False otherwise. Raises ------ @@ -934,7 +945,7 @@ def push(self) -> bool: Returns ------- - True if the push was done correctly and there is no commits to pull, + True if the push was done correctly and there are no commits to push, False otherwise. Raises @@ -945,31 +956,11 @@ def push(self) -> bool: """ # Undo group - @feature(enabled=False) - def undo_stage(self, path: str) -> bool: - """ - Unstage a file. - - Parameters - ---------- - path : str - The path to remove from the stage area. - - Returns - ------- - True if the path is unstaged (or the file is already unstaged), - False otherwise. - - See Also - -------- - unstage - """ @feature(enabled=False) def undo_commit( - self, - commits: int = 1, - ) -> typing.Optional[typing.Dict[str, object]]: + self, + commits: int = 1) -> typing.Optional[typing.Dict[str, object]]: """ Undo a commit or some of them. @@ -1016,8 +1007,7 @@ def undo_change(self, path: str) -> bool: See Also -------- undo_change_all - - undo_stage + unstage """ @feature(enabled=False) @@ -1035,16 +1025,14 @@ def undo_change_all(self) -> bool: See Also -------- undo_change - - undo_stage + unstage_all """ # history group @feature(enabled=False, extra={"attrs": ()}) def get_last_commits( - self, - commits: int = 1, - ) -> typing.Sequence[typing.Dict[str, object]]: + self, + commits: int = 1) -> typing.Sequence[typing.Dict[str, object]]: """ Get a list of old commits and its attributes. @@ -1171,7 +1159,6 @@ def __delattr__(self, name: str) -> None: elif name in dir(self._backend): delattr(self, name) - # Properties @property def vcs_types(self) -> typing.Sequence[str]: """A list of available VCS types.""" @@ -1307,6 +1294,67 @@ def register_backend(self, backend: typing.Type[VCSBackendBase]) -> None: else: self._backends[vcsname] = [backend] + def safe_check( + self, feature_name: typing.Union[str, typing.Sequence[str]] + ) -> typing.Optional[typing.Callable[..., object]]: + """ + Check in a safe manner if a feature is enabled. + + Unlike direct check, this method controls if the feature + can be checked or not. + + Parameters + ---------- + feature_name : str or tuple of str. + The feature to check. + Can be a string if the feature is a method + or a tuple of 2 strings if the feature is a property operation + (get, set, del). + + Returns + ------- + bool + True if the feature can be checked and + its enabled attribute is True, False otherwise. + """ + if self.repodir is None: + return None + + is_property = False + if isinstance(feature_name, str): + feature_inst = getattr(self._backend, feature_name, None) + else: + is_property = True + feature_name, operation, *_ = feature_name + + if operation in ("fget", "fset", "fdel"): + # Nothing to do + pass + elif operation in ("get", "getter"): + operation = "fget" + elif operation in ("set", "setter"): + operation = "fset" + elif operation in ("del", "deleter"): + operation = "fdel" + else: + raise ValueError("Unknown operation {}".format(operation)) + + feature_inst = getattr( + getattr( + type(self._backend), + feature_name, + None, + ), + operation, + ) + + if (getattr(feature_inst, "_is_feature", False) + and feature_inst.enabled): + if is_property: + return partial(feature_inst, self._backend) + return feature_inst + return None + # Debug API def force_use(self, backend: type, path: str) -> None: """ diff --git a/spyder/plugins/vcs/utils/errors.py b/spyder/plugins/vcs/backend/errors.py similarity index 100% rename from spyder/plugins/vcs/utils/errors.py rename to spyder/plugins/vcs/backend/errors.py diff --git a/spyder/plugins/vcs/utils/backend.py b/spyder/plugins/vcs/backend/git.py similarity index 94% rename from spyder/plugins/vcs/utils/backend.py rename to spyder/plugins/vcs/backend/git.py index a70e1c63cf4..5299065085c 100644 --- a/spyder/plugins/vcs/utils/backend.py +++ b/spyder/plugins/vcs/backend/git.py @@ -6,34 +6,37 @@ # Distributed under the terms of the MIT License # (see spyder/__init__.py for details) # ----------------------------------------------------------------------------- -"""Builtin backends for Git and Mercurial.""" +"""Git backend module.""" -# Standard library imports -from concurrent.futures.thread import ThreadPoolExecutor -from datetime import datetime, timezone -import platform import os -import os.path as osp -from pathlib import Path import re import shutil -import subprocess import typing +import os.path as osp +import platform +import subprocess +from pathlib import Path +from datetime import datetime, timezone from urllib.parse import urlparse +# Standard library imports +from concurrent.futures.thread import ThreadPoolExecutor # Third party imports import pexpect # Local imports from spyder.utils import programs -from spyder.utils.vcs import (is_hg_installed, get_hg_revision) -from .api import VCSBackendBase, ChangedStatus, feature -from .errors import (VCSAuthError, VCSPropertyError, VCSBackendFail, - VCSFeatureError) +from .api import ChangedStatus, VCSBackendBase, feature +from .errors import ( + VCSAuthError, + VCSBackendFail, + VCSFeatureError, + VCSPropertyError +) from .mixins import CredentialsKeyringMixin -__all__ = ("GitBackend", "MercurialBackend") +__all__ = ("GitBackend", ) _git_bases = [VCSBackendBase] if platform.system() != "Windows": @@ -82,7 +85,7 @@ def __init__(self, *args): @property def credential_context(self): # Use current remote as credential context - retcode, remote, err = self._run( + retcode, remote, _ = self._run( ["config", "--get", "remote.origin.url"]) if not retcode and remote: return remote.decode().strip("\n") @@ -269,9 +272,9 @@ def create_branch(self, branchname: str, empty: bool = False) -> bool: else: args.extend(("-b", branchname)) - create_retcode, _, err = self._run(args, git=git) + create_retcode, _, _ = self._run(args, git=git) if empty and os.listdir(self.repodir) != [".git"]: - remove_retcode, _, err = self._run(["rm", "-rf", "."], git=git) + remove_retcode, _, _ = self._run(["rm", "-rf", "."], git=git) return not (create_retcode or remove_retcode) return not create_retcode @@ -283,11 +286,14 @@ def delete_branch(self, branchname: str) -> bool: @property @feature(extra={"states": ("path", "kind", "staged")}) def changes(self) -> typing.Sequence[typing.Dict[str, object]]: + self._run(["status"], dry_run=True) filestates = get_git_status(self.repodir, nobranch=True)[2] if filestates is None: - raise VCSPropertyError(name="changes", - operation="get", - error="Failed to get git changes") + raise VCSPropertyError( + name="changes", + operation="get", + error="Failed to get git changes", + ) changes = [] for record in filestates: changes.extend(self._parse_change_record(record)) @@ -315,7 +321,11 @@ def change(self, @feature() def stage(self, path: str) -> bool: - retcode, _, err = self._run(["add", _escape_path(path)]) + retcode, _, _ = self._run(["add", _escape_path(path)]) + if not osp.exists(osp.join(self.repodir, path)): + # Assume that the file may be already staged + retcode = 0 + if not retcode: change = self.change(path, prefer_unstaged=True) if change and change["staged"]: @@ -324,8 +334,8 @@ def stage(self, path: str) -> bool: @feature() def unstage(self, path: str) -> bool: - retcode, _, err = self._run(["reset", "--", _escape_path(path)]) - if retcode == 0: + retcode, _, _ = self._run(["reset", "--", _escape_path(path)]) + if not retcode: change = self.change(path, prefer_unstaged=False) if change and not change["staged"]: return True @@ -374,10 +384,6 @@ def pull(self) -> bool: def push(self) -> bool: return self._remote_operation("push") - @feature() - def undo_stage(self, path: str) -> bool: - return self.unstage(path) - @feature() def undo_commit( self, @@ -610,22 +616,36 @@ def _remote_operation(self, operation: str, *args): ) raise VCSFeatureError( - method=getattr(self, operation), + feature=getattr(self, operation), error="Failed to {} from remote".format(operation), ) def _run(self, - args, - env=None, - git=None) -> typing.Tuple[int, bytes, bytes]: + args: typing.Sequence[str], + env: typing.Optional[typing.Dict[str, str]] = None, + git: typing.Optional[str] = None, + dry_run: bool = False) -> typing.Tuple[int, bytes, bytes]: if git is None: git = programs.find_program("git") + + if git is None: + # If programs.find_program fails + raise VCSBackendFail( + self.repodir, + type(self), + programs=("git", ), + ) + if not osp.exists(osp.join(self.repodir, ".git")): raise VCSBackendFail( self.repodir, type(self), is_valid_repository=False, ) + + if dry_run: + return None, None, None + retcode, out, err = run_helper(git, args, cwd=self.repodir, env=env) # Integrity check @@ -635,30 +655,6 @@ def _run(self, return retcode, out, err -class MercurialBackend(VCSBackendBase): # pylint: disable=W0223 - """An implementation of VCSBackendBase for mercurial (hg).""" - - VCSNAME = "mercurial" - - def __init__(self, *args): - super().__init__(*args) - if not is_hg_installed(): - raise VCSBackendFail(self.repodir, type(self), programs=("hg", )) - - @property - @feature() - def branch(self) -> str: - revision = get_hg_revision(self.repodir) - if revision: - return revision[2] - raise VCSPropertyError("branch", "get") - - @branch.setter - @feature(enabled=False) - def branch(self, branch: str) -> None: - pass - - # --- VCS operation functions --- _GIT_STATUS_MAP = { @@ -809,7 +805,7 @@ def git_get_branches(repopath, branch=True, tag=False, remote=False) -> list: proc = programs.run_program( git, ["branch", "--format", "%(refname:lstrip=2)"], cwd=repopath) - output, err = proc.communicate() + output, _ = proc.communicate() if proc.returncode == 0 and output: branches["branch"] = output.decode().splitlines() diff --git a/spyder/plugins/vcs/utils/mixins.py b/spyder/plugins/vcs/backend/mixins.py similarity index 95% rename from spyder/plugins/vcs/utils/mixins.py rename to spyder/plugins/vcs/backend/mixins.py index 8a620d29369..0e1d2bbe929 100644 --- a/spyder/plugins/vcs/utils/mixins.py +++ b/spyder/plugins/vcs/backend/mixins.py @@ -14,6 +14,7 @@ # Third party imports import keyring +# Local imports from .errors import VCSAuthError @@ -40,7 +41,7 @@ class CredentialsKeyringMixin(object): After implemetation you have a ready-to-use credentials implementation. .. note:: - This mixin does not follow all the backends + This mixin is not strictly respecting the backends specifications in the credentials property. In particular, the setter can raise VCSAuthError if there is no credentials stored in the keyring. @@ -84,7 +85,8 @@ def credentials(self, credentials: typing.Dict[str, object]) -> None: if i == -1: raise VCSAuthError( credentials={"token", None}, - credentials_callback=lambda x: setattr(self, "credentials", x), + credentials_callback=lambda x: setattr( + self, "credentials", x), required_credentials=self.REQUIRED_CREDENTIALS, error="No token is found for {}".format( self.credential_context), @@ -131,7 +133,8 @@ def credentials(self, credentials: typing.Dict[str, object]) -> None: else: raise VCSAuthError( credentials={type_: user}, - credentials_callback=lambda x: setattr(self, "credentials", x), + credentials_callback=lambda x: setattr( + self, "credentials", x), required_credentials=self.REQUIRED_CREDENTIALS, error="The given {} is not found for {}".format( type_, self.credential_context)) diff --git a/spyder/plugins/vcs/plugin.py b/spyder/plugins/vcs/plugin.py index f6140d272e7..82c0affa5b4 100644 --- a/spyder/plugins/vcs/plugin.py +++ b/spyder/plugins/vcs/plugin.py @@ -14,19 +14,18 @@ # Third party imports import qtawesome as qta -from qtpy.QtCore import Signal, Slot -from qtpy.QtWidgets import QAction +from qtpy.QtCore import Slot, Signal # Local imports +from spyder.utils import icon_manager as ima from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.translations import get_translation from spyder.config.manager import CONF -from spyder.utils import icon_manager as ima from spyder.utils.qthelpers import toggle_actions +from spyder.api.translations import get_translation -from .utils.api import VCSBackendManager -from .utils.backend import GitBackend, MercurialBackend -from .utils.errors import VCSError +from .backend.api import VCSBackendManager +from .backend.git import GitBackend +from .backend.errors import VCSError from .widgets.vcsgui import VCSWidget # Localization @@ -72,60 +71,6 @@ class VCS(SpyderDockablePlugin): # pylint: disable=W0201 The current branch name in the VCS. """ - # Actions defintion - stage_all_action: QAction - """ - Action for stage all the unstaged changes. - - When triggered, - the :py:meth:`~VCSBackendBase.stage_all` method is called. - """ - - unstage_all_action: QAction - """ - Action for unstage all the staged changes. - - When triggered, - the :py:meth:`~VCSBackendBase.unstage_all` method is called. - """ - - commit_action: QAction - """ - Action for commit. - - When triggered and there is a commit message, - the :py:meth:`~VCSBackendBase.commit` method is called. - """ - - fetch_action: QAction - """ - Action for fetch. - - When triggered, the :py:meth:`~VCSBackendBase.fetch` method is called. - """ - - pull_action: QAction - """ - Action for pull. - - When triggered, the :py:meth:`~VCSBackendBase.pull` method is called. - """ - - push_action: QAction - """ - Action for push. - - When triggered, the :py:meth:`~VCSBackendBase.push` method is called. - """ - - create_vcs_action: QAction - """ - Action for create an empty repository. - - When triggered, a dialog box is showed, - then :py:meth:`~VCSBackendBase.create` is called. - """ - # Other attributes definition vcs_manager: VCSBackendManager @@ -134,17 +79,17 @@ class VCS(SpyderDockablePlugin): # pylint: disable=W0201 This can be used to get information about the repository. - .. warning:: - Any call to the manager blocks the current thread and - may invoke subprocess or other long-running operation. - It is better to run those calls in separate threads - and wait results asynchronously. - The :class:`~ThreadWrapper` may help you. - - .. danger:: - Do any operation that changes the repository state - will break the VCS pane UI. - Use actions where possible. + Notes + ----- + This is usually referred as "backend". + + Warnings + -------- + Any backend call blocks the current thread and + may invoke subprocess or other long-running operation. + It is better to run those calls in separate threads + and wait results asynchronously. + The class :class:`~ThreadWrapper` may help you. """ def __init__(self, *args, **kwargs): self.vcs_manager = VCSBackendManager(None) @@ -164,17 +109,13 @@ def get_icon(self): return qta.icon("mdi.source-branch") def register(self): - # register backends + # Register backends self.vcs_manager.register_backend(GitBackend) - self.vcs_manager.register_backend(MercurialBackend) - - # Create actions - self._create_actions() - self.get_widget().setup_slots() + # Hide view self.toggle_view(False) - # connect external signals + # Connect external signals project = self.get_plugin(Plugins.Projects) project.sig_project_loaded.connect(self.set_repository) project.sig_project_loaded.connect(partial(self.toggle_view, True)) @@ -241,7 +182,6 @@ def set_repository(self, repository_dir: str) -> str: self.refresh_action.setEnabled(True) self.sig_repository_changed.emit(self.vcs_manager.repodir, self.vcs_manager.VCSNAME) - self.create_vcs_action.setEnabled(True) return self.get_repository() repository_path = property( @@ -262,11 +202,20 @@ def select_branch(self, branchname: str) -> None: ------ AttributeError If changing branch is not supported. + + Notes + ----- + Branch selection is done through the properly widget. + If you just want to change the branch, call the backend. """ + branch_list = self.get_widget().branch_list + if (not branch_list.isEnabled() and not branch_list.isVisible()): + raise AttributeError("Cannot change branch in the current VCS") + branch_list.select(branchname) + self.get_widget().select_branch(branchname) - # Private methods - def _create_actions(self): + def create_actions(self): # TODO: Add tips create_action = self.create_action @@ -274,7 +223,7 @@ def _create_actions(self): "vcs_create_vcs_action", _("create new repository"), # icon=qta.icon("fa.long-arrow-down", color=ima.MAIN_FG_COLOR), - icon_text=_("create new repository"), + icon_text=_("Create a new repository"), # tip=_("ADD TIP HERE"), shortcut_context=self.NAME, triggered=lambda: None, @@ -301,7 +250,7 @@ def _create_actions(self): "vcs_commit_action", _("commit"), icon=qta.icon("mdi.source-commit", color=ima.MAIN_FG_COLOR), - icon_text=_("commit"), + icon_text=_("Commit changes"), # tip=_("ADD TIP HERE"), shortcut_context=self.NAME, triggered=lambda: None, diff --git a/spyder/plugins/vcs/widgets/auth.py b/spyder/plugins/vcs/widgets/auth.py new file mode 100755 index 00000000000..3e3324bb06f --- /dev/null +++ b/spyder/plugins/vcs/widgets/auth.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Components and widgets with auth handling.""" + +# pylint: disable=W0102 + +# Standard library imports +import os +import shutil +import typing +import os.path as osp +from tempfile import TemporaryDirectory +from functools import partial +from concurrent.futures import ThreadPoolExecutor + +# Third party imports +from qtpy.QtCore import Qt, Slot, Signal +from qtpy.QtWidgets import ( + QLabel, + QAction, + QDialog, + QWidget, + QComboBox, + QLineEdit, + QFormLayout, + QHBoxLayout, + QMessageBox, + QSizePolicy, + QVBoxLayout, + QPlainTextEdit, + QDialogButtonBox +) + +# Local imports +from spyder.utils.qthelpers import action2button as spy_action2button +from spyder.api.translations import get_translation +from spyder.widgets.comboboxes import UrlComboBox + +from .utils import THREAD_ENABLED, LoginDialog, ThreadWrapper, action2button +from .common import BaseComponent +from ..backend.errors import VCSAuthError + +_ = get_translation('spyder') + + +class AuthComponent(BaseComponent): + """An abstract component to manage :class:`~VCSBackendBase` auth.""" + + sig_auth_operation_success = Signal(str, object) + """ + This signal is emitted when an auth operation was done successfully. + + Parameters + ---------- + operation : str + The operation done. + It is usually the backend method name. + + result : object + The result of operation. + It is always a non-zero object. + """ + @Slot(str) + @Slot(str, tuple, dict) + def auth_operation( # pylint: disable=W0102 + self, + operation: str, + args: tuple = (), + kwargs: dict = {}, + ): + """ + A helper to do operations that can requires authentication. + + Parameters + ---------- + operation : str + The method name to call. + This will be used to get the corresponding backend error. + args : tuple, optional + Extra positional parameters to pass to the method. + kwargs : dict, optional + Extra keyword parameters to pass to the method. + """ + @Slot(object) + def _handle_result(result): + if result: + self.sig_auth_operation_success.emit(operation, result) + # Maybe it's better to show an error here? + + def _error(ex): + if isinstance(ex, VCSAuthError): + self.handle_auth_error(ex, operation, args, kwargs) + else: + self.error_handler(ex, raise_=True) + + feature = self.manager.safe_check(operation) + if feature is not None: + ThreadWrapper( + self, + partial(feature, *args, **kwargs), + result_slots=(_handle_result, ), + error_slots=(_error, ), + nothread=not THREAD_ENABLED, + ).start() + + def handle_auth_error(self, + ex: VCSAuthError, + operation: str, + args: tuple = (), + kwargs: dict = {}) -> None: + """Handle authentication errors by showing an input dialog.""" + def _accepted(): + try: + ex.credentials = dialog.to_credentials() + except ValueError: + _rejected() + else: + self.auth_operation(operation, args, kwargs) + + def _rejected(): + QMessageBox.critical( + self, + _("Authentication failed"), + _("Failed to authenticate to the {} remote server.".format( + manager.VCSNAME)), + ) + + manager = self.get_plugin().vcs_manager + credentials = ex.credentials + + # UNSAFE: Use backend credentials if error credentials are not given. + credentials.update((key, manager.credentials.get(key)) + for key in ex.required_credentials + if credentials.get(key) is None) + + if credentials: + dialog = LoginDialog(self, **credentials) + dialog.accepted.connect(_accepted) + dialog.rejected.connect(_rejected) + dialog.show() + + +class CommitComponent(AuthComponent, QWidget): + """A widget for committing.""" + def __init__(self, *args, commit_action: QAction, **kwargs): + super().__init__(*args, **kwargs) + + self.commit_message = QPlainTextEdit() + commit_button = spy_action2button(commit_action, parent=self) + + self.commit_message.setPlaceholderText(_("Commit message ...")) + + commit_button.setToolButtonStyle(Qt.ToolButtonTextOnly) + + commit_button.setSizePolicy( + QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)) + + # FIXME: change color + commit_button.setStyleSheet("background-color: #1122cc;") + + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(self.commit_message) + layout.addWidget(commit_button) + + # Slots + commit_button.triggered.connect(self.commit) + self.sig_auth_operation_success.connect(self.commit_message.clear) + + @Slot() + def setup(self): + with self.block_timer(): + # BUG: this does not preserve undo history + self.commit_message.clear() + + self.setVisible(bool(self.manager.safe_check("commit"))) + + @Slot() + def commit(self) -> None: + """Do a commit.""" + text = self.commit_message.toPlainText() + + if text: + self.auth_operation("commit", (text, ), dict(is_path=False)) + + # Allow to commit by calling the instance + __call__ = commit + + +class RemoteComponent(AuthComponent, QWidget): + """A widget for remove operations.""" + + REFRESH_TIME = 4000 + + def __init__(self, *args, fetch_action: QAction, pull_action: QAction, + push_action: QAction, **kwargs): + super().__init__(*args, **kwargs) + + self._old_nums = (None, None) + + # Widgets + self.fetch_button = action2button( + fetch_action, + text_beside_icon=True, + parent=self, + ) + + self.pull_button = action2button( + pull_action, + text_beside_icon=True, + parent=self, + ) + + self.push_button = action2button( + push_action, + text_beside_icon=True, + parent=self, + ) + + # Layout + layout = QHBoxLayout() + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(self.fetch_button) + layout.addWidget(self.pull_button) + layout.addWidget(self.push_button) + + self.setLayout(layout) + + # Slots + self.sig_auth_operation_success.connect(self.refresh) + self.fetch_button.triggered.connect(lambda: self.auth_operation( + "fetch", + kwargs=dict(sync=True), + )) + self.pull_button.triggered.connect(lambda: self.auth_operation("pull")) + self.push_button.triggered.connect(lambda: self.auth_operation("push")) + + @Slot() + def setup(self): + with self.block_timer(): + pass + + manager = self.manager + if manager.repodir: + self.fetch_button.setEnabled(manager.fetch.enabled) + self.pull_button.setEnabled(manager.pull.enabled) + self.push_button.setEnabled(manager.push.enabled) + + self.setVisible(manager.fetch.enabled or manager.fetch.enabled + or manager.push.enabled) + else: + self.hide() + + @Slot() + def refresh(self) -> None: + """ + Show the numbers of commits to pull and push compared to remote. + """ + def _handle_result(commits_nums): + if commits_nums: + # FIXME: Don't edit button text through actions + for i, action in enumerate(( + self.pull_button.defaultAction(), + self.push_button.defaultAction(), + )): + if commits_nums[i] != self._old_nums[i]: + label = action.text().rsplit(" ", 1) + if (len(label) == 2 + and label[1][0] + label[1][-1] == "()"): + # Found existing number + del label[1] + + if commits_nums[i] > 0: + action.setText("{} ({})".format( + " ".join(label), commits_nums[i])) + else: + action.setText(" ".join(label)) + + self._old_nums = commits_nums + else: + self.pull_button.defaultAction().setText(_("pull")) + self.push_button.defaultAction().setText(_("push")) + self._old_nums = (None, None) + + self.do_call("fetch", result_slots=_handle_result) + + +class CreateDialog(AuthComponent, QDialog): + """A dialog to manage cloning operation.""" + + sig_repository_ready = Signal(str) + """ + This signal is emitted when repository creation is done. + + Parameters + ---------- + repodir : str + The repository directory. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.credentials = {} + self.tempdir = None + + # Widgets + self.vcs_select = QComboBox() + self.directory = QLineEdit() + self.url_select = UrlComboBox(self) + buttonbox = QDialogButtonBox(QDialogButtonBox.Ok + | QDialogButtonBox.Cancel) + + # Currently, only opened projects can create repositories, + # so it is forbidden to change the repository path. + self.directory.setReadOnly(True) + + # Layout + rootlayout = QFormLayout(self) + rootlayout.addRow(QLabel(_("

Create new repository

"))) + rootlayout.addRow(_("VCS Type"), self.vcs_select) + rootlayout.addRow(_("Destination"), self.directory) + rootlayout.addRow(_("Source repository"), self.url_select) + rootlayout.addRow(buttonbox) + + # Slots + buttonbox.accepted.connect(self.accept) + buttonbox.rejected.connect(self.reject) + buttonbox.rejected.connect(self.cleanup) + + @Slot() + def setup(self, rootpath: str): + with self.block_timer(): + self.vcs_select.clear() + self.directory.clear() + self.url_select.clear() + + create_vcs_types = self.manager.create_vcs_types + + if create_vcs_types: + self.vcs_select.addItems(create_vcs_types) + self.directory.setText(rootpath) + else: + # Force dialog to be hidden + self.hide() + + # Public slots + @Slot() + def cleanup(self, fail: bool = False) -> None: + """Remove the temporary directory.""" + self.tempdir = None + if fail: + # Inform the parent that the clone is failed. + self.rejected.emit() + + # Qt overrides + @Slot() + def accept(self) -> None: + url = self.url_select.currentText() + if url: + if not self.url_select.is_valid(url): + QMessageBox.critical( + self, _("Invalid URL"), + _("Creating a repository from an existing" + "one requires a valid URL.")) + return + + if os.listdir(self.directory.text()): + ret = QMessageBox.warning( + self, + _("File override"), + _("Local files will be overriden by cloning.\n" + "Would you like to continue anyway?"), + QMessageBox.Ok | QMessageBox.Cancel, + ) + if ret != QMessageBox.Ok: + return + else: + url = None + self._try_create( + self.vcs_select.currentText(), + self.directory.text(), + url, + ) + + super().accept() + + def show(self) -> None: + self.tempdir = TemporaryDirectory() + super().show() + + # Private methods + def _try_create(self, vcs_type: str, path: str, + from_: typing.Optional[str]) -> None: + def _error(ex): + if isinstance(ex, VCSAuthError): + self._handle_auth_error(ex) + else: + QMessageBox.critical( + self, _("Create failed"), + _("Repository creation failed unexpectedly.")) + self.cleanup(True) + + def _handle_result(result): + if result: + ThreadWrapper( + self, + _move, + result_slots=( + lambda _: self.sig_repository_ready.emit(path), + self.cleanup), + error_slots=(_error, ), + ).start() + else: + QMessageBox.critical( + self, _("Create failed"), + _("Repository creation failed unexpectedly.")) + self.cleanup(True) + + def _move(): + tempdir = self.tempdir.name + with ThreadPoolExecutor() as pool: + pool.map(lambda x: shutil.move(osp.join(tempdir, x), path), + os.listdir(tempdir)) + + ThreadWrapper( + self, + partial( + self.manager.create_with, + vcs_type, + self.tempdir.name, + from_=from_, + credentials=self.credentials, + ), + result_slots=(_handle_result, ), + error_slots=(_error, ), + nothread=not THREAD_ENABLED, + ).start() + + @Slot(VCSAuthError) + def _handle_auth_error(self, ex: VCSAuthError): + def _accepted(): + self.credentials = dialog.to_credentials() + self._try_create(self.vcs_select.currentText(), + self.directory.text(), + self.url_select.currentText()) + + def _rejected(): + QMessageBox.critical( + self, + _("Authentication failed"), + _("Failed to authenticate to the {} remote server.").format( + self.vcs_select.currentText()), + ) + self.cleanup() + + credentials = { + # Use stored credentials if the error + # does not give them. + key: getattr(ex, key, None) or self.credentials.get(key) + for key in ex.required_credentials + } + + if credentials: + dialog = LoginDialog(self, **credentials) + dialog.accepted.connect(_accepted) + dialog.rejected.connect(_rejected) + dialog.show() diff --git a/spyder/plugins/vcs/widgets/branch.py b/spyder/plugins/vcs/widgets/branch.py new file mode 100644 index 00000000000..8162ca9b154 --- /dev/null +++ b/spyder/plugins/vcs/widgets/branch.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Widgets for branch management.""" + +# Standard library imports +import typing +from functools import partial + +# Third party imports +from qtpy.QtCore import Slot, Signal +from qtpy.QtWidgets import ( + QLabel, + QDialog, + QComboBox, + QCompleter, + QMessageBox, + QVBoxLayout, + QAbstractButton, + QDialogButtonBox +) + +# Local imports +from spyder.api.translations import get_translation + +from .utils import THREAD_ENABLED, ThreadWrapper +from .common import BaseComponent +from ..backend.errors import VCSPropertyError + +_ = get_translation('spyder') + + +class BranchesComponent(BaseComponent, QComboBox): + """ + An editable combo box that manages branches. + """ + + sig_branch_changed = Signal(str) + """ + This signal is emitted when the current branch change + + Parameters + ---------- + branchname: str + The current branch name in the VCS. + + See Also + -------- + BranchesComboBox.select + To change the current branch. + """ + + REFRESH_TIME = 2500 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setEditable(True) + # branches cache + self._branches: typing.Set[str] = set() + + self.sig_branch_changed.connect(self.refresh) + self.currentIndexChanged.connect(self.select) + + @Slot() + def setup(self): + with self.block_timer(): + self._branches.clear() + self.clear() + + if self.manager.repodir is not None: + backend_type = self.manager.type + self.setVisible(backend_type.branches.fget.enabled + or backend_type.branch.fget.enabled) + self.setEnabled(backend_type.branch.fset.enabled) + else: + self.hide() + + @Slot() + def refresh(self) -> None: + """Clear and re-add branches.""" + def _task(): + """Task executed in another thread.""" + branches = set() + manager = self.manager + if manager.type.branch.fget.enabled: + current_branch = manager.branch + else: + current_branch = None + + if manager.type.editable_branches.fget.enabled: + try: + branches.update(manager.editable_branches) + except VCSPropertyError: + # Suppress editable_branches fail + pass + elif current_branch is not None: + branches.add(manager.branch) + return (current_branch, branches) + + @Slot(object) + def _handle_result(result): + if any(result): + current_branch, branches = result + new_branches = branches - self._branches + old_branches = self._branches - branches + self._branches = branches + + # Block signals to reduce useless signal emissions + oldstate = self.blockSignals(True) + + # Add/Remove branches + self.addItems(sorted(new_branches)) + for branch in old_branches: + index = self.findText(branch) + if index != -1: + self.removeItem(index) + + if (current_branch is not None + and not self.lineEdit().hasFocus()): + index = self.findText(current_branch) + if index == -1: + self.setCurrentText(current_branch) + else: + self.setCurrentIndex(index) + + self.blockSignals(oldstate) + else: + # Reset branch list + self.clear() + self._branches.clear() + + self.setCompleter(QCompleter(self._branches, self)) + self.setDisabled(not self._branches) + + if self.manager.repodir is not None: + ThreadWrapper( + self, + _task, + result_slots=(_handle_result, ), + error_slots=(lambda: self.setCompleter(QCompleter(self)), + partial(self.error_handler, raise_=True)), + nothread=not THREAD_ENABLED, + ).start() + + @Slot() + @Slot(str) + @Slot(int) + def select(self, branchname: typing.Union[str, int] = None) -> None: + """ + Select a branch given its name. + + Parameters + ---------- + branchname : str or int, optional + The branch name. + Can be also an integer that will be used + to get the corresponding value. + """ + if self.isEnabled(): + # Ignore the event when the widget is disabled + + if branchname is None: + branchname = self.currentText() + + elif isinstance(branchname, int): + if branchname == -1: + branchname = "" + else: + branchname = self.currentText() + + if branchname: + branch_prop = self.manager.type.branch + if branch_prop.fget.enabled and branch_prop.fset.enabled: + ThreadWrapper( + self, + partial(setattr, self.manager, "branch", branchname), + result_slots=(partial(self.sig_branch_changed.emit, + branchname), ), + error_slots=(partial(self._handle_select_error, + branchname), ), + nothread=not THREAD_ENABLED, + ).start() + + @Slot(Exception) + def _handle_select_error(self, branchname: str, ex: Exception) -> None: + @Slot() + def _show_error(): + reason = ex.raw_error if ex.error is None else ex.error + with self.block_timer(): + self.refresh() + QMessageBox.critical( + self, + _("Failed to change branch"), + _("Cannot switch to branch {}." + + ("\nReason: {}" if reason else "")).format( + branchname, reason), + ) + + @Slot(QAbstractButton) + def _handle_buttons(widget): + create = empty = False + if widget == empty_button: + create = empty = True + else: + role = buttonbox.buttonRole(widget) + if role == QDialogButtonBox.YesRole: + create = True + + if create: + ThreadWrapper( + self, + partial(self.manager.create_branch, + branchname, + empty=empty), + result_slots=(lambda result: + (self.sig_branch_changed.emit(branchname) + if result else _show_error()), ), + error_slots=(_show_error, ), + nothread=not THREAD_ENABLED, + ).start() + dialog.accept() + else: + dialog.reject() + + if not isinstance(ex, VCSPropertyError): + self.refresh() + self.error_handler(ex, raise_=True) + + if (self.manager.create_branch.enabled + and branchname not in self._branches): + dialog = QDialog(self) + dialog.setModal(True) + + rootlayout = QVBoxLayout(dialog) + rootlayout.addWidget( + QLabel( + _("The branch {} does not exist.\n" + "Would you like to create it?").format(branchname))) + + buttonbox = QDialogButtonBox() + buttonbox.addButton(QDialogButtonBox.Yes) + if self.manager.create_branch.extra["empty"]: + # Allow the empty branch creation only if supported + empty_button = buttonbox.addButton( + _("Yes, create empty branch"), + QDialogButtonBox.YesRole, + ) + else: + empty_button = None + buttonbox.addButton(QDialogButtonBox.No) + buttonbox.clicked.connect(_handle_buttons) + + rootlayout.addWidget(buttonbox) + dialog.show() + else: + with self.block_timer(): + self.refresh() + _show_error() diff --git a/spyder/plugins/vcs/widgets/changes.py b/spyder/plugins/vcs/widgets/changes.py index 892d57d7da6..20061f11406 100644 --- a/spyder/plugins/vcs/widgets/changes.py +++ b/spyder/plugins/vcs/widgets/changes.py @@ -9,21 +9,32 @@ """Widgets for change areas.""" # Standard library imports -from collections.abc import Iterable -from functools import partial import typing +from functools import partial +from collections.abc import Iterable +from concurrent.futures import ThreadPoolExecutor # Third party imports -from qtpy.QtCore import Qt, Signal, Slot, QCoreApplication, QPoint -from qtpy.QtWidgets import (QTreeWidgetItem, QTreeWidget, QMenu) import qtawesome as qta +from qtpy.QtCore import Qt, Slot, QPoint, Signal +from qtpy.QtWidgets import ( + QMenu, + QLabel, + QAction, + QWidget, + QHBoxLayout, + QTreeWidget, + QVBoxLayout, + QTreeWidgetItem +) # Local imports -from spyder.api.translations import get_translation from spyder.config.gui import is_dark_interface +from spyder.api.translations import get_translation -from .common import ThreadWrapper, THREAD_ENABLED, PAUSE_CYCLE -from ..utils.api import ChangedStatus, VCSBackendManager +from .utils import action2button +from .common import BaseComponent +from ..backend.api import ChangedStatus _ = get_translation('spyder') @@ -115,6 +126,25 @@ def __init__(self, repodir: str, *args: object): self.state: typing.Optional[int] = None self.repodir: str = repodir + def __eq__(self, other: QTreeWidgetItem) -> bool: + """Check equality between two items.""" + return self.state == other.state and self.text(0) == other.text(0) + + def __lt__(self, other: QTreeWidgetItem) -> bool: + """Compare two items, prioritizing state over path.""" + if self.state == other.state: + # compare with paths if the states are the same + return bool(self.text(0) < other.text(0)) + + return _STATE_LIST.index(self.state) < _STATE_LIST.index(other.state) + + def __hash__(self) -> int: + return hash(self.state) + hash(self.text(0)) + + def __repr__(self) -> str: + return "".format( + ChangedStatus.to_string(self.state), self.text(0)) + def setup(self, state: int, path: str) -> None: """ Set up the item UI. Can be called multiple times. @@ -137,44 +167,22 @@ def setup(self, state: int, path: str) -> None: else: self.setText(0, "") - def __lt__(self, other: QTreeWidgetItem) -> bool: - """ - Compare two treewidget items, prioritizing state over path. - - Parameters - ---------- - other : QTreeWidgetItem - The item to compare. - - Returns - ------- - bool - True if self is less than other, False otherwise. - """ - if self.state == other.state: - # compare with paths if the states are the same - return bool(self.text(0) < other.text(0)) - - return _STATE_LIST.index(self.state) < _STATE_LIST.index(other.state) - @property def path(self) -> typing.Optional[str]: - """Return a path suitable for VCS backend calls.""" + """Return a path suitable for backend calls.""" text = self.text(0) if text: return text return None -class ChangesTree(QTreeWidget): +class ChangesTreeComponent(BaseComponent, QTreeWidget): """ - A tree widget for vcs changes. + A tree widget for VCS changes. Parameters ---------- - manager : VCSBackendManager - The VCS manager. - staged : bool, optional + staged : bool or None If True, all the changes listed should be staged. If False, all the changes listed should be unstaged. If None, the staged field in changes is ignored. @@ -198,13 +206,16 @@ class ChangesTree(QTreeWidget): See Also -------- VCSBackendBase.stage + VCSBackendBase.stage_all VCSBackendBase.unstage + VCSBackendBase.unstage_all """ - def __init__(self, manager: VCSBackendManager, *args, - staged: typing.Optional[bool], **kwargs): + + REFRESH_TIME = 1500 + + def __init__(self, *args, staged: typing.Optional[bool], **kwargs): super().__init__(*args, **kwargs) - self.manager = manager - self.staged = staged + self._staged = staged self.setHeaderHidden(True) self.setRootIsDecorated(False) @@ -214,11 +225,34 @@ def __init__(self, manager: VCSBackendManager, *args, self.sortItems(0, Qt.AscendingOrder) # Slots - self.sig_stage_toggled.connect(self.clear) self.customContextMenuRequested.connect(self.show_ctx_menu) + self.sig_stage_toggled.connect(self.clear) + + @property + def staged(self) -> bool: + """Return staged status.""" + return self._staged - if ((staged is True and manager.unstage.enabled) - or (staged is False and manager.stage.enabled)): + @Slot() + def setup(self): + with self.block_timer(): + self.clear() + + manager = self.manager + if not manager.safe_check(("changes", "fget")): + self.hide() + self.timer.stop() + return + self.show() + + # Slots + try: + self.itemDoubleClicked.disconnect(self.toggle_stage) + except (RuntimeError, TypeError): + pass + + if ((self._staged is True and manager.unstage.enabled) + or (self._staged is False and manager.stage.enabled)): self.itemDoubleClicked.connect(self.toggle_stage) @Slot(QPoint) @@ -230,19 +264,19 @@ def show_ctx_menu(self, pos: QPoint) -> None: manager = self.manager # TODO: Add jump to file - if self.staged is True and manager.unstage.enabled: + if self._staged is True and manager.unstage.enabled: action = menu.addAction(_("Unstage")) action.triggered.connect(partial(self.toggle_stage, item)) - elif self.staged is False and manager.stage.enabled: + elif self._staged is False and manager.stage.enabled: action = menu.addAction(_("Stage")) action.triggered.connect(partial(self.toggle_stage, item)) - if not self.staged and manager.undo_change.enabled: + if not self._staged and manager.undo_change.enabled: action = menu.addAction(_("Discard")) action.triggered.connect(partial(self.discard, item)) - menu.exec(self.mapToGlobal(pos)) + menu.exec_(self.mapToGlobal(pos)) # Refreshes @Slot() @@ -272,10 +306,8 @@ def refresh( @Slot(object) def _handle_result(result): if isinstance(result, Iterable): - if isinstance(self.staged, bool): - result = filter(lambda x: x.get("staged") is self.staged, - result) - for i, change in enumerate(result): + + def _task(change): kind = change.get("kind", ChangedStatus.UNKNOWN) if kind not in (ChangedStatus.UNCHANGED, ChangedStatus.IGNORED): @@ -283,21 +315,36 @@ def _handle_result(result): kind = ChangedStatus.UNKNOWN item = ChangeItem(self.manager.repodir) - self.addTopLevelItem(item) item.setup(kind, change["path"]) + return item + return None + + if isinstance(self._staged, bool): + result = filter(lambda x: x.get("staged") is self._staged, + result) + + # OPTIMIZE: Don't spawn ChangesItem objects + # if they will not be added to self. + with ThreadPoolExecutor() as pool: + old_items = set( + pool.map(lambda i: self.topLevelItem(i), + range(self.topLevelItemCount()))) + items = set(pool.map(_task, result)) + + new_items = items - old_items + old_items -= items + for item in new_items: + if item.treeWidget() is None: + self.addTopLevelItem(item) - if i % PAUSE_CYCLE == 0: - QCoreApplication.processEvents() + for item in old_items: + if item.treeWidget() == self: + self.invisibleRootItem().removeChild(item) - self.clear() if changes: _handle_result(changes) else: - ThreadWrapper(self, - lambda: self.manager.changes, - result_slots=(_handle_result, ), - error_slots=(_raise, ), - nothread=not THREAD_ENABLED).start() + self.do_call(("changes", "get"), result_slots=_handle_result) @Slot(str) def refresh_one(self, path: str): @@ -322,8 +369,8 @@ def refresh_one(self, path: str): """ @Slot(object) def _handle_result(result): - if (isinstance(result, dict) - and result.get("staged", self.staged) is self.staged): + if (isinstance(result, dict)) and result.get( + "staged", self._staged) is self._staged: item = ChangeItem(self.manager.repodir) self.addTopLevelItem(item) @@ -335,17 +382,12 @@ def _handle_result(result): self.invisibleRootItem().removeChild(items[0]) path = items[0].path - ThreadWrapper( - self, - partial( - self.manager.change, - path, - prefer_unstaged=not self.staged, - ), - result_slots=(_handle_result, ), - error_slots=(_raise, ), - nothread=not THREAD_ENABLED, - ).start() + self.do_call( + "change", + path, + prefer_unstaged=not self._staged, + result_slots=_handle_result, + ) # Stage operations @Slot(QTreeWidgetItem) @@ -371,7 +413,7 @@ def toggle_stage(self, item: ChangeItem) -> None: If the staged attribute is None. NotImplementedError - If the required feature is not supported by the current backend. + If the required feature is not supported by the backend. """ @Slot(object) def _handle_result(result): @@ -379,26 +421,16 @@ def _handle_result(result): path = item.path self.invisibleRootItem().removeChild(item) item.setup(item.state, path) - self.sig_stage_toggled[bool, str].emit(not self.staged, path) - - if (self.staged is True and self.manager.unstage.enabled): - # unstage item - operation = self.manager.unstage - elif (self.staged is False and self.manager.stage.enabled): - # stage item - operation = self.manager.stage - elif self.staged is None: + self.sig_stage_toggled[bool, str].emit(not self._staged, path) + + if self._staged is None: raise AttributeError("The staged attribute is not set.") - else: - raise NotImplementedError( - "The current VCS does not support {}".format( - "stage" if self.staged else "unstage")) - ThreadWrapper(self, - partial(operation, item.path), - result_slots=(_handle_result, ), - error_slots=(_raise, ), - nothread=not THREAD_ENABLED).start() + self.do_call( + "unstage" if self._staged else "stage", + item.path, + result_slots=_handle_result, + ) @Slot() def toggle_stage_all(self) -> None: @@ -410,31 +442,23 @@ def toggle_stage_all(self) -> None: - If staged is True, :meth:`~VCSBackendBase.unstage_all` is called. - If staged is False, :meth:`~VCSBackendBase.stage_all` is called. - - If staged is None, an AttributeError will be raised. + - If staged is None, AttributeError is raised. Raises ------ AttributeError If the staged attribute is None. NotImplementedError - If the required feature is not supported by the current backend. + If the required feature is not supported by the backend. """ - if self.staged is None: + if self._staged is None: raise AttributeError("The staged attribute is not set.") - operation = (self.manager.unstage_all - if self.staged else self.manager.stage_all) - if not operation.enabled: - raise NotImplementedError( - "The current VCS does not support {}".format( - operation.__name__)) - - ThreadWrapper(self, - operation, - result_slots=(lambda result: result and self. - sig_stage_toggled.emit(not self.staged), ), - error_slots=(_raise, ), - nothread=not THREAD_ENABLED).start() + self.do_call( + "unstage_all" if self._staged else "stage_all", + result_slots=lambda res: res and self.sig_stage_toggled.emit( + not self._staged), + ) @Slot(QTreeWidgetItem) def discard(self, item: ChangeItem) -> None: @@ -446,16 +470,112 @@ def discard(self, item: ChangeItem) -> None: item : ChangeItem The change item. """ - if self.manager.undo_change.enabled: - ThreadWrapper( - self, - partial(self.manager.undo_change, item.path), - result_slots=(lambda res: res and self.invisibleRootItem(). - removeChild(item), ), - error_slots=(_raise, ), - nothread=not THREAD_ENABLED, - ).start() - - -def _raise(ex): - raise ex + self.do_call( + "undo_change", + item.path, + result_slots=lambda res: res and self.invisibleRootItem(). + removeChild(item), + ) + + +class ChangesComponent(BaseComponent, QWidget): + """ + A widget for VCS changes management. + + Parameters + ---------- + staged : bool or None + If True, all the changes listed should be staged. + If False, all the changes listed should be unstaged. + If None, the staged field in changes is ignored. + The default is None. + + stage_all_action : QAction or None + The action to use for creating the stage all/unstage all button. + Must be given if staged is not None. + + Raises + ------ + TypeError + If staged is not None and stage_all_action is None. + """ + def __init__(self, + *args, + staged: typing.Optional[bool], + stage_all_action: QAction = None, + **kwargs): + super().__init__(*args, **kwargs) + + # Widgets + self.title = QLabel() + if staged is None: + self.stage_all_button = QWidget() + elif stage_all_action is None: + raise TypeError( + "The stage_all_action parameter must be specified.") + else: + self.stage_all_button = action2button( + stage_all_action, + parent=self, + text_beside_icon=True, + ) + + self.changes_tree = ChangesTreeComponent( + self.manager, + staged=staged, + parent=self, + ) + + # Widgets setup + font = self.title.font() + font.setPointSize(11) + font.setBold(True) + self.title.setFont(font) + + if staged is True: + self.title.setText(_("Staged changes")) + elif staged is False: + self.title.setText(_("Unstaged changes")) + else: + self.title.setText(_("Changes")) + + # Layout + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + header_layout = QHBoxLayout() + + header_layout.addWidget(self.title) + header_layout.addStretch(1) + header_layout.addWidget(self.stage_all_button) + + layout.addLayout(header_layout) + layout.addWidget(self.changes_tree) + + # Slot + self.changes_tree.sig_vcs_error.connect(self.sig_vcs_error) + if stage_all_action is not None: + stage_all_action.triggered.connect( + self.changes_tree.toggle_stage_all) + + @Slot() + def setup(self) -> None: + manager = self.manager + staged = self.changes_tree.staged + + self.changes_tree.setup() + if manager.repodir is not None and self.changes_tree.isEnabled(): + if staged is True: + self.stage_all_button.setVisible(manager.unstage_all.enabled) + elif staged is False: + self.stage_all_button.setVisible(manager.stage_all.enabled) + + states = manager.type.changes.fget.extra.get("states", ()) + self.setVisible(("staged" in states) ^ (staged is None)) + else: + self.hide() + + @Slot() + def refresh(self) -> None: + with self.changes_tree.block_timer(): + self.changes_tree.refresh() diff --git a/spyder/plugins/vcs/widgets/common.py b/spyder/plugins/vcs/widgets/common.py index 18bbc9c1785..cb87b069574 100644 --- a/spyder/plugins/vcs/widgets/common.py +++ b/spyder/plugins/vcs/widgets/common.py @@ -1,425 +1,170 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- -"""Common widgets for the VCS GUI.""" +"""Common stuff for VCS UI components.""" # Standard library imports import typing from functools import partial +from contextlib import contextmanager +from collections.abc import Sequence # Third party imports -from qtpy.QtCore import Slot, Signal, QThread, QObject -from qtpy.QtWidgets import (QDialog, QWidget, QLineEdit, QVBoxLayout, - QFormLayout, QLabel, QDialogButtonBox, - QAbstractButton, QComboBox, QCompleter, - QMessageBox) +from qtpy.QtCore import Slot, QTimer, Signal # Local imports -from spyder.api.translations import get_translation +from .utils import SLOT, ThreadWrapper +from ..backend.api import VCSBackendManager +from ..backend.errors import VCSError -from ..utils.api import VCSBackendManager -from ..utils.errors import VCSPropertyError +# Singleton +NO_VALUE = type("NO_VALUE", (), {}) -_ = get_translation('spyder') -# TODO: move these to configs for debug purposes -THREAD_ENABLED = True -PAUSE_CYCLE = 16 +class BaseComponent(object): + """ + The base of all components. -SLOT = typing.Union[( - typing.Callable[..., typing.Optional[object]], - Signal, - Slot, -)] + It cannot be instanced direcly. Always subclass it + with a QObject subclass (e.g. QWidget) in the bases list + to allows signals and the refresh timer. + Warnings + -------- + Due to Qt/C++ inhiterance, when subclassing + always put first this class, then the desired subclass of QObject. + If you don't do that, an unexpected TypeError will be raised. + """ -class BranchesComboBox(QComboBox): + # Signal definitions + sig_vcs_error = Signal(VCSError) """ - An editable combo box that manages branches. + This signal is emitted when VCSError is raised. Parameters ---------- - manager : VCSBackendManager - The VCS manager. + ex : VCSError + The raised exception. """ - sig_branch_changed = Signal(str) + # TODO: Add it to config + REFRESH_TIME: typing.Optional[int] = None """ - This signal is emitted when the current branch change - - Parameters - ---------- - branchname: str - The current branch name in the VCS. + The interval (in ms) when an automatic refresh is done. - Notes - ----- - Emit this signal does not change the branch - as it is changed before normal emission. - To change the branch use - :py:meth:`BranchesComboBox.select` instead. + If it is None or the refresh method is not implemented, + automatic refresh is disabled. """ - def __init__(self, manager: VCSBackendManager, *args: object, - **kwargs: object): + def __init__(self, manager: VCSBackendManager, *args, **kwargs): super().__init__(*args, **kwargs) - self._manager = manager - self.setEditable(True) - # branches cache - self._branches: typing.Set[str] = set() + self.manager = manager - self.sig_branch_changed.connect(self.refresh) + if self.REFRESH_TIME is not None and self.refresh != BaseComponent.refresh: + self.timer = QTimer(self) + self.timer.setInterval(self.REFRESH_TIME) + self.timer.timeout.connect(self.refresh) + self.timer.start() + else: + self.timer = None + # Optional methods @Slot() - def refresh(self) -> None: - """Clear and re-add branches.""" - def _task(): - """Task executed in another thread.""" - branches = set() - if manager.type.branch.fget.enabled: - current_branch = manager.branch - else: - current_branch = None - - if manager.type.editable_branches.fget.enabled: - try: - branches.update(manager.editable_branches) - except VCSPropertyError: - # Suppress editable_branches fail - pass - elif current_branch is not None: - branches.add(manager.branch) - return (current_branch, branches) - - @Slot(object) - def _handle_result(result): - if any(result): - current_branch, self._branches = result - # Block signals to reduce useless signal emissions - oldstate = self.blockSignals(True) - self.clear() - self.addItems(tuple(self._branches)) - if current_branch is not None: - index = self.findText(current_branch) - if index == -1: - self.setCurrentText(current_branch) - else: - self.setCurrentIndex(index) - - self.blockSignals(oldstate) - self.setDisabled(False) - self.setCompleter(QCompleter(self._branches, self)) - - # If there are not any branches, keep the widget disabled. - - @Slot(Exception) - def _raise(ex): - raise ex - - manager = self._manager - - # Disable the widget during changes - self.setDisabled(True) - self.clear() - self._branches = [] - self.setCompleter(QCompleter(self)) + def setup(self) -> None: + """ + Setup the component when the repository changes. - ThreadWrapper( - self, - _task, - result_slots=(_handle_result, ), - error_slots=(_raise, ), - nothread=not THREAD_ENABLED, - ).start() + This method may be called many times. + """ @Slot() - @Slot(str) - @Slot(int) - def select(self, branchname: typing.Union[str, int] = None) -> None: + def refresh(self) -> None: + """ + Do a component refresh by querying the backend. """ - Select a branch given its name. - Parameters - ---------- - branchname : str or int, optional - The branch name. - Can be also an integer that will be used - to get the corresponding value. + # Utilities + def do_call(self, + feature_name: typing.Union[str, typing.Sequence[str]], + *args, + result_slots: typing.Sequence[SLOT] = (), + **kwargs) -> typing.Optional[ThreadWrapper]: """ - if self.isEnabled(): - # Ignore the event when the widget is disabled + Call a backend method. - if branchname is None: - branchname = self.currentText() + Parameters + ---------- + feature_name : str, or tuple of str + The feature name to get and call. + Has te same meaning of :meth:`~VCSBackendManager.safe_check` + feature_name parameter. - elif isinstance(branchname, int): - if branchname == -1: - branchname = "" - else: - branchname = self.currentText() + result_slots : Sequence[SLOT], optional + The slots to call when the call is done successfully. + Will be passed to :class:`~ThreadWrapper` constructor. - if branchname: - branch_prop = self._manager.type.branch - if branch_prop.fget.enabled and branch_prop.fset.enabled: - ThreadWrapper( - self, - partial(setattr, self._manager, "branch", branchname), - result_slots=(partial(self.sig_branch_changed.emit, - branchname), ), - error_slots=(partial(self._handle_select_error, - branchname), ), - nothread=not THREAD_ENABLED, - ).start() + Returns + ------- + ThreadWrapper + The thread instance. + """ + if not isinstance(result_slots, Sequence): + result_slots = (result_slots, ) - @Slot(Exception) - def _handle_select_error(self, branchname: str, ex: Exception) -> None: - @Slot() - def _show_error(): - reason = ex.raw_error if ex.error is None else ex.error - QMessageBox.critical( + feature = self.manager.safe_check(feature_name) + if feature: + thread = ThreadWrapper( self, - _("Failed to change branch"), - _("Cannot switch to branch {}." + - ("\nReason: {}" if reason else "")).format( - branchname, reason), + partial(feature, *args, **kwargs), + result_slots=result_slots, + error_slots=(partial(self.error_handler, raise_=True), ), ) + thread.start() + return thread + return None - @Slot(QAbstractButton) - def _handle_buttons(widget): - create = empty = False - if widget == empty_button: - create = empty = True - else: - role = buttonbox.buttonRole(widget) - if role == QDialogButtonBox.YesRole: - create = True - - if create: - ThreadWrapper( - self, - partial(self._manager.create_branch, - branchname, - empty=empty), - result_slots=( - self.refresh, - lambda result: - (self.sig_branch_changed.emit(branchname) - if result else _show_error()), - ), - error_slots=(self.refresh, _show_error), - nothread=not THREAD_ENABLED, - ).start() - dialog.accept() - else: - dialog.reject() - - if not isinstance(ex, VCSPropertyError): - self.refresh() - raise ex - - if (self._manager.create_branch.enabled - and branchname not in self._branches): - dialog = QDialog(self) - dialog.setModal(True) - - rootlayout = QVBoxLayout(dialog) - rootlayout.addWidget( - QLabel( - _("The branch {} does not exist.\n" - "Would you like to create it?").format(branchname))) - - buttonbox = QDialogButtonBox() - buttonbox.addButton(QDialogButtonBox.Yes) - if self._manager.create_branch.extra["empty"]: - # Allow the empty branch creation only if supported - empty_button = buttonbox.addButton( - _("Yes, create empty branch"), - QDialogButtonBox.YesRole, - ) - else: - empty_button = None - buttonbox.addButton(QDialogButtonBox.No) - buttonbox.clicked.connect(_handle_buttons) - - rootlayout.addWidget(buttonbox) - dialog.show() - else: - self.refresh() - _show_error() - - -class LoginDialog(QDialog): - """ - A modeless dialog for VCS credentials. - - Parameters - ---------- - parent : QWidget, optional - The parent widget. The default is None. - **credentials - A valid :attr:`.VCSBackendBase.credentials` mapping. - To respect the specifications, an input field - is created for each key in credentials. - Credential keys with value will be automatically - set as initial text for input fields. - - .. tip:: - This dialog can be used for any credentials prompt - as it does not depend on :class:`~VCSBackendBase`. - """ - def __init__(self, parent: QWidget = None, **credentials: object): - super().__init__(parent=parent) - - rootlayout = QFormLayout(self) - self._old_credentials: typing.Dict[str, object] = credentials - self.credentials_edit: typing.Dict[str, QLineEdit] = {} - - for key in ("username", "email", "token"): - if key in credentials: - rootlayout.addRow( - *self._create_credentials_field(key, credentials[key])) - - if "password" in credentials: - rootlayout.addRow(*self._create_credentials_field( - "password", - credentials["password"], - hide=True, - )) - - self.buttonbox = buttonbox = QDialogButtonBox( - QDialogButtonBox.Ok - | QDialogButtonBox.Reset - | QDialogButtonBox.Cancel) - buttonbox.clicked.connect(self.handle_reset) - buttonbox.accepted.connect(self.accept) - buttonbox.rejected.connect(self.reject) - - rootlayout.addRow(buttonbox) + @Slot(Exception) + def error_handler(self, ex: Exception, raise_: bool = False) -> bool: + """ + An utility method to handle backend exceptions. - @Slot(QAbstractButton) - def handle_reset(self, widget: QAbstractButton) -> None: - """Handle all the buttons in button box.""" - role = self.buttonbox.buttonRole(widget) - if role == self.buttonbox.ResetRole: - for key, val in self._old_credentials.items(): - self.credentials_edit[key].setText(val if val else "") + It checks if the given exception is an instance of :class:`~VCSError`. - def to_credentials(self) -> typing.Dict[str, object]: - """ - Get credentials from the dialog. + Parameters + ---------- + ex : Exception + The exception to filter. + raise_ : bool, optional + If True and the exception is not an instance of + :class:`~VCSError`, then that exception will be raised. + The default is False. + + Raises + ------ + Exception + If raise_ is True and the exception is not an instance of + :class:`~VCSError`. Returns ------- - dict - A valid VCSBackendBase.credentials mapping. + bool + If raise_ is False, returns True if the error is an instance of + :class:`~VCSError`, False otherwise. + It is the same of `isinstance(ex, VCSError)`. """ - credentials = {} - for key, lineedit in self.credentials_edit.items(): - credentials[key] = lineedit.text() - - return credentials - - def _create_credentials_field( - self, - key: str, - default: typing.Optional[object] = None, - hide: bool = False, - ) -> typing.Tuple[str, QWidget]: - if not default: - default = "" - label = _(key.capitalize()) + ":" - self.credentials_edit[key] = lineedit = QLineEdit(str(default)) - - if hide: - lineedit.setEchoMode(QLineEdit.Password) - - return label, lineedit - - -class ThreadWrapper(QThread): - """ - Wrap QThread. - - Parameters - ---------- - parent : QObject - The parent object. - This is must be a valid QObject for emitting signals. - - func : callable - The function to be called in the thread. - result_slots : tuple, optional - The slots to connect to the sig_result signal. - error_slots : tuple, optional - The slots to connect to the sig_error signal. - - pass_self : bool, optional - If True, internal result/exception handling is disabled, - the result_slots and error_slots parameters have no effects - and this object will be passed as parameter to func. - The default is False. - - nothread : bool, optional - If True, the run method is called direcly in `__init__` - and the start method will do nothing. - The default is False. - """ - - sig_result = Signal(object) - """ - This signal is emitted if func is executed without exceptions. - - Parameters - ---------- - result: object - The object returned from func. - """ - - sig_error = Signal(Exception) - """ - This signal is emitted if func raises an exception. - - Parameters - ---------- - ex: Exception - The exception raised. - """ - def __init__(self, - parent: QObject, - func: typing.Callable[..., None], - result_slots: typing.Iterable[SLOT] = (), - error_slots: typing.Iterable[SLOT] = (), - pass_self: bool = False, - nothread: bool = False): - super().__init__(parent) - self.func = func - self.pass_self = pass_self - - # bind slots - for slot in result_slots: - self.sig_result.connect(slot) - for slot in error_slots: - self.sig_error.connect(slot) - - if nothread: - self.run() - self.func = None - - def run(self) -> None: - if self.pass_self: - self.func(self) + if isinstance(ex, VCSError): + self.sig_vcs_error.emit(ex) + return True else: - try: - result = self.func() - except Exception as ex: - self.sig_error.emit(ex) - else: - self.sig_result.emit(result) + if raise_: + raise ex + return False - def start(self, *args, **kwargs) -> None: - if self.func is not None: - super().start(*args, **kwargs) + @contextmanager + def block_timer(self) -> None: + """ + A context utility to temporary block automatic refreshing. + """ + if self.timer is not None: + self.timer.stop() + yield + if self.timer is not None: + self.timer.start() diff --git a/spyder/plugins/vcs/widgets/history.py b/spyder/plugins/vcs/widgets/history.py new file mode 100755 index 00000000000..8b353e2a3ed --- /dev/null +++ b/spyder/plugins/vcs/widgets/history.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Widgets for the history area.""" + +# Standard library imports +from datetime import datetime, timezone +from functools import partial + +# Third party imports +from qtpy.QtCore import Slot, Signal, QCoreApplication +from qtpy.QtWidgets import ( + QHeaderView, + QToolButton, + QTreeWidget, + QTreeWidgetItem +) + +# Local imports +from spyder.utils import icon_manager as ima +from spyder.api.translations import get_translation + +from .utils import PAUSE_CYCLE +from .common import BaseComponent + +_ = get_translation('spyder') + +# TODO: move this to configs +MAX_HISTORY_ROWS = 8 + + +class CommitHistoryComponent(BaseComponent, QTreeWidget): + """A tree widget for commit history.""" + + sig_last_commit = Signal(dict) + """ + This signal is emitted when the last commit changed. + + Parameters + ---------- + commit : dict + The dict returned by :meth:`~VCSBackendBase.undo_commit`. + """ + REFRESH_TIME = 5000 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setHeaderHidden(True) + self.setRootIsDecorated(False) + self.setColumnCount(3) + + header = self.header() + header.setStretchLastSection(False) + header.setSectionResizeMode(QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + + self._old_commits = None + + @Slot() + def setup(self): + with self.block_timer(): + self._old_commits = None + self.clear() + + self.setVisible(bool(self.manager.safe_check("get_last_commits"))) + + @Slot() + def refresh(self) -> None: + def _handle_result(result): + if result is None: + if self._old_commits is not None: + self.clear() + elif result != self._old_commits: + self.clear() + undo_enabled = bool(self.manager.undo_commit.enabled) + for i, commit in enumerate(result): + + item = QTreeWidgetItem() + # Keep the commit attributes in the item. + item.commit = commit + + # Set commit title + if commit.get("title"): + title = commit["title"] + elif commit.get("description"): + title = commit["description"].lstrip().splitlines()[0] + else: + title = None + + # TODO: Tell the user that the title is missing + # (e.g. with an icon) + item.setText(1, title.strip() if title else "") + item.setToolTip(1, item.text(1)) + + # Set commit date + if commit.get("commit_date") is not None: + # TODO: update times + delta = (datetime.now(tz=timezone.utc) - + commit["commit_date"]) + # FIXME: Suffixes should be translated + if delta.days: + item.setText(2, "{}d".format(abs(delta.days))) + elif delta.seconds >= 3600: + item.setText( + 2, "{}h".format(int(delta.seconds / 3600))) + elif delta.seconds >= 60: + item.setText( + 2, + "{}m".format(int(delta.seconds / 60)), + ) + else: + item.setText(2, "<1m") + + else: + # TODO: Use an icon + item.setText(2, "?") + + self.addTopLevelItem(item) + if undo_enabled: + button = QToolButton() + button.setIcon(ima.icon("undo")) + button.setToolTip(_("Undo commit")) + button.clicked.connect( + partial( + self.undo_commits, + i + 1, + )) + self.setItemWidget(item, 0, button) + + # Reduce lag when inserting commits. + if not i % PAUSE_CYCLE: + QCoreApplication.processEvents() + + self._old_commits = result + + self.do_call( + "get_last_commits", + MAX_HISTORY_ROWS, + result_slots=_handle_result, + ) + + @Slot() + @Slot(int) + def undo_commits(self, commits: int = 1) -> None: + """ + Undo commits. + + Parameters + ---------- + commits : int, optional + The number of commits to undo. The default is 1. + """ + self.do_call( + "undo_commit", + commits, + result_slots=(lambda commit: self.sig_last_commit.emit(commit) + if commit else None, self.refresh), + ) diff --git a/spyder/plugins/vcs/widgets/utils.py b/spyder/plugins/vcs/widgets/utils.py new file mode 100644 index 00000000000..0776bd75418 --- /dev/null +++ b/spyder/plugins/vcs/widgets/utils.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Common widgets for the VCS UI.""" + +# Standard library imports +import typing + +# Third party imports +from qtpy.QtCore import Slot, Signal, QObject, QThread +from qtpy.QtWidgets import ( + QDialog, + QWidget, + QLineEdit, + QFormLayout, + QAbstractButton, + QDialogButtonBox +) + +from spyder.utils.qthelpers import action2button as spy_action2button +# Local imports +from spyder.api.translations import get_translation + +_ = get_translation('spyder') + +__all__ = ("THREAD_ENABLED", "PAUSE_CYCLE", "SLOT", "LoginDialog", + "ThreadWrapper", "action2button") + +# TODO: move these to configs for debug purposes +THREAD_ENABLED = True +PAUSE_CYCLE = 16 + +SLOT = typing.Union[( + typing.Callable[..., typing.Optional[object]], + Signal, + Slot, +)] + + +class LoginDialog(QDialog): + """ + A modeless dialog for VCS credentials. + + Parameters + ---------- + parent : QWidget, optional + The parent widget. The default is None. + **credentials + A valid :attr:`.VCSBackendBase.credentials` mapping. + To respect the specifications, an input field + is created for each key in credentials. + Credential keys with value will be automatically + set as initial text for input fields. + + .. tip:: + This dialog can be used for any credentials prompt + as it does not depend on :class:`~VCSBackendBase`. + """ + def __init__(self, parent: QWidget = None, **credentials: object): + super().__init__(parent=parent) + + rootlayout = QFormLayout(self) + self._old_credentials: typing.Dict[str, object] = credentials + self.credentials_edit: typing.Dict[str, QLineEdit] = {} + + for key in ("username", "email", "token"): + if key in credentials: + rootlayout.addRow( + *self._create_credentials_field(key, credentials[key])) + + if "password" in credentials: + rootlayout.addRow(*self._create_credentials_field( + "password", + credentials["password"], + hide=True, + )) + + self.buttonbox = buttonbox = QDialogButtonBox( + QDialogButtonBox.Ok + | QDialogButtonBox.Reset + | QDialogButtonBox.Cancel) + buttonbox.clicked.connect(self.handle_reset) + buttonbox.accepted.connect(self.accept) + buttonbox.rejected.connect(self.reject) + + rootlayout.addRow(buttonbox) + + @Slot(QAbstractButton) + def handle_reset(self, widget: QAbstractButton) -> None: + """Handle all the buttons in button box.""" + role = self.buttonbox.buttonRole(widget) + if role == self.buttonbox.ResetRole: + for key, val in self._old_credentials.items(): + self.credentials_edit[key].setText(val if val else "") + + def to_credentials(self) -> typing.Dict[str, object]: + """ + Get credentials from the dialog. + + Returns + ------- + dict + A valid VCSBackendBase.credentials mapping. + """ + credentials = {} + for key, lineedit in self.credentials_edit.items(): + credentials[key] = lineedit.text() + + return credentials + + def _create_credentials_field( + self, + key: str, + default: typing.Optional[object] = None, + hide: bool = False, + ) -> typing.Tuple[str, QWidget]: + if not default: + default = "" + label = _(key.capitalize()) + ":" + self.credentials_edit[key] = lineedit = QLineEdit(str(default)) + + if hide: + lineedit.setEchoMode(QLineEdit.Password) + + return label, lineedit + + +class ThreadWrapper(QThread): + """ + Wrap QThread. + + Parameters + ---------- + parent : QObject + The parent object. + This is must be a valid QObject for emitting signals. + + func : callable + The function to be called in the thread. + result_slots : tuple, optional + The slots to connect to the sig_result signal. + error_slots : tuple, optional + The slots to connect to the sig_error signal. + + pass_self : bool, optional + If True, internal result/exception handling is disabled, + the result_slots and error_slots parameters have no effects + and this object will be passed as parameter to func. + The default is False. + + nothread : bool, optional + If True, the run method is called direcly in `__init__` + and the start method will do nothing. + The default is False. + """ + + sig_result = Signal(object) + """ + This signal is emitted if func is executed without exceptions. + + Parameters + ---------- + result: object + The object returned from :attr:`~ThreadWrapper.func`. + """ + + sig_error = Signal(Exception) + """ + This signal is emitted if func raises an exception. + + Parameters + ---------- + ex: Exception + The exception raised by :attr:`~ThreadWrapper.func`. + """ + def __init__(self, + parent: QObject, + func: typing.Callable[..., None], + result_slots: typing.Iterable[SLOT] = (), + error_slots: typing.Iterable[SLOT] = (), + pass_self: bool = False, + nothread: bool = False): + super().__init__(parent) + self.func = func + self.pass_self = pass_self + + # bind slots + for slot in result_slots: + self.sig_result.connect(slot) + for slot in error_slots: + self.sig_error.connect(slot) + + if nothread: + self.run() + self.func = None + + def run(self) -> None: + if self.pass_self: + self.func(self) + else: + try: + result = self.func() + except Exception as ex: + self.sig_error.emit(ex) + else: + self.sig_result.emit(result) + + def start(self, *args, **kwargs) -> None: + if self.func is not None: + super().start(*args, **kwargs) + + +def action2button(action, *args, **kwargs): + """ + A wrapper around action2button that update button properties + when they changes in the action. + """ + def _update(): + button.setText(action.text()) + + action.changed.connect(_update) + button = spy_action2button(action, *args, **kwargs) + return button diff --git a/spyder/plugins/vcs/widgets/vcsgui.py b/spyder/plugins/vcs/widgets/vcsgui.py index 19a2de73283..d009ce77057 100644 --- a/spyder/plugins/vcs/widgets/vcsgui.py +++ b/spyder/plugins/vcs/widgets/vcsgui.py @@ -6,80 +6,57 @@ # Distributed under the terms of the MIT License # (see spyder/__init__.py for details) # ----------------------------------------------------------------------------- -"""VCS widgets.""" +"""VCS main widget.""" # pylint: disable = W0201 # Standard library imports -from collections.abc import Sequence -from concurrent.futures.thread import ThreadPoolExecutor -from datetime import datetime, timezone -from functools import partial -import os -import os.path as osp -import shutil -from tempfile import TemporaryDirectory import typing +from functools import partial # Third party imports -from qtpy.QtWidgets import (QVBoxLayout, QHBoxLayout, QLabel, QTreeWidget, - QTreeWidgetItem, QPlainTextEdit, QSizePolicy, - QMessageBox, QLayout, QToolButton, QHeaderView, - QDialog, QFormLayout, QComboBox, QDialogButtonBox, - QLineEdit) - -from qtpy.QtGui import QIcon -from qtpy.QtCore import Signal, Slot, QCoreApplication +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import ( + QLabel, + QAction, + QWidget, + QHBoxLayout, + QMessageBox, + QSizePolicy, + QVBoxLayout +) # Local imports from spyder.api.plugins import Plugins -from spyder.api.translations import get_translation from spyder.api.widgets import PluginMainWidget -import spyder.utils.icon_manager as ima -from spyder.utils.qthelpers import action2button -from spyder.widgets.comboboxes import UrlComboBox +from spyder.api.translations import get_translation -from .common import (BranchesComboBox, LoginDialog, ThreadWrapper, - THREAD_ENABLED, PAUSE_CYCLE) -from .changes import ChangesTree -from ..utils.api import VCSBackendManager -from ..utils.errors import VCSAuthError, VCSFeatureError +from .auth import CreateDialog, CommitComponent, RemoteComponent +from .utils import action2button +from .branch import BranchesComponent +from .common import BaseComponent +from .changes import ChangesComponent +from .history import CommitHistoryComponent +from ..backend.errors import VCSBackendFail _ = get_translation('spyder') -# TODO: move this to configs -MAX_HISTORY_ROWS = 10 - class VCSWidget(PluginMainWidget): """VCS main widget.""" DEFAULT_OPTIONS = {} - sig_auth_operation = Signal((str, ), (str, tuple, dict)) - """ - This signal is emitted when an auth operation is requested. - - It is intended to be used only internally. Use plugin's actions instead. - """ - - sig_auth_operation_success = Signal(str, object) - """ - This signal is emitted when an auth operation was done successfully. - - It is intended to be used only internally and can corrupt widget's UI. - """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - layout = QVBoxLayout() - # For debugging purposes - layout.setObjectName("vcs_widget_layout") - self.setLayout(layout) + self.components = [] - # define all the widget variables - self.branch_combobox = self.unstaged_files = self.staged_files = None - self.commit_message = self.history = None + self.branch_list = None + self.changes = self.unstaged_changes = self.staged_changes = None + self.commit = self.history = self.remote = None + + self.setLayout(QVBoxLayout()) # Reimplemented APIs def get_title(self): @@ -92,402 +69,160 @@ def update_actions(self): def on_option_update(self, option, value): pass - @Slot() + # Setups def setup(self, options=DEFAULT_OPTIONS) -> None: - """Set up the GUI for the current repository.""" - # get the required stuff + """Initialize components and slots.""" plugin = self.get_plugin() + parent = self.parent() manager = plugin.vcs_manager - # remove the old layout - clear_layout(self.layout()) + # HACK: Should be called in the plugin and not here. + plugin.create_actions() + + # Widgets + toolbar = QHBoxLayout() + self.branch_list = BranchesComponent(manager, parent=parent) + self.branch_list.setSizePolicy( + QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)) + + refresh_button = action2button(plugin.refresh_action, parent=parent) + + self.changes = ChangesComponent( + manager, + staged=None, + parent=parent, + ) + self.unstaged_changes = ChangesComponent( + manager, + staged=False, + stage_all_action=plugin.stage_all_action, + parent=parent, + ) + self.staged_changes = ChangesComponent( + manager, + staged=True, + stage_all_action=plugin.unstage_all_action, + parent=parent, + ) + + self.commit = CommitComponent( + manager, + commit_action=plugin.commit_action, + parent=parent, + ) + + self.history = CommitHistoryComponent(manager, parent=parent) - # unset all the widgets - self.branch_combobox = self.unstaged_files = self.staged_files = None - self.commit_message = self.history = None + self.remote = RemoteComponent( + manager, + fetch_action=plugin.fetch_action, + pull_action=plugin.pull_action, + push_action=plugin.push_action, + parent=parent, + ) + self.repo_not_found = RepoNotFoundComponent( + manager, + create_vcs_action=plugin.create_vcs_action, + parent=parent, + ) + # Layout rootlayout = self.layout() - if plugin.get_repository(): - - # --- Toolbar --- - toolbar = QHBoxLayout() - - self.branch_combobox = BranchesComboBox(plugin.vcs_manager, None) - self.branch_combobox.setEnabled(manager.type.branch.fset.enabled) - self.branch_combobox.setSizePolicy( - QSizePolicy( - QSizePolicy.Expanding, - QSizePolicy.Preferred, - )) - toolbar.addWidget(self.branch_combobox) - - toolbar.addStretch(0) - toolbar.addWidget(action2button(plugin.refresh_action, - parent=self)) - # toolbar.addWidget(plugin.options_button) - - rootlayout.addLayout(toolbar) - - # --- Changes --- - changes_feature = manager.type.changes.fget - if changes_feature.enabled: - is_stage_supported = ("staged" - in changes_feature.extra["states"]) - - # header - header_layout = QHBoxLayout() - header_layout.addWidget( - QLabel("

Unstaged changes

") - if is_stage_supported else QLabel("

Changes

")) - - header_layout.addStretch(1) - rootlayout.addLayout(header_layout) - - # --- Untaged changes (or just changes) --- - self.unstaged_files = ChangesTree( - manager, - staged=False if is_stage_supported else None, - ) - rootlayout.addWidget(self.unstaged_files) - - if is_stage_supported: - if manager.stage_all.enabled: - header_layout.addWidget( - action2button( - plugin.stage_all_action, - parent=self, - text_beside_icon=True, - )) - - # --- Staged changes --- - - # header - header_layout = QHBoxLayout() - header_layout.addWidget(QLabel("

Staged changes

")) - header_layout.addStretch(1) - - if manager.unstage_all.enabled: - header_layout.addWidget( - action2button( - plugin.unstage_all_action, - parent=self, - text_beside_icon=True, - )) - - rootlayout.addLayout(header_layout) - - self.staged_files = ChangesTree(manager, staged=True) - rootlayout.addWidget(self.staged_files) - - # --- Commit --- - if manager.commit.enabled: - # commit message - self.commit_message = QPlainTextEdit() - self.commit_message.setPlaceholderText(_("Commit message ...")) - rootlayout.addWidget(self.commit_message) - - # commit button - commit_button = action2button(plugin.commit_action, - parent=self) - commit_button.setIcon(QIcon()) - commit_button.setText(_("Commit changes")) - - commit_button.setSizePolicy( - QSizePolicy( - QSizePolicy.Preferred, - QSizePolicy.Fixed, - )) - - # FIXME: change color if dark or white - commit_button.setStyleSheet("background-color: #1122cc;") - rootlayout.addWidget(commit_button) - - # intermediary step - QCoreApplication.processEvents() - - # --- History --- - rootlayout.addStretch(0) - if manager.get_last_commits.enabled: - - self.history = QTreeWidget() - self.history.setHeaderHidden(True) - self.history.setRootIsDecorated(False) - self.history.setColumnCount(3) - self.history.header().setStretchLastSection(False) - self.history.header().setSectionResizeMode( - QHeaderView.ResizeToContents) - self.history.header().setSectionResizeMode( - 1, QHeaderView.Stretch) - rootlayout.addWidget(self.history) - - # --- Commands --- - commandslayout = QHBoxLayout() - if manager.fetch.enabled: - commandslayout.addWidget( - action2button(plugin.fetch_action, - text_beside_icon=True, - parent=self)) - if manager.pull.enabled: - commandslayout.addWidget( - action2button(plugin.pull_action, - text_beside_icon=True, - parent=self)) - - if manager.push.enabled: - commandslayout.addWidget( - action2button(plugin.push_action, - text_beside_icon=True, - parent=self)) - - rootlayout.addLayout(commandslayout) - - # --- Slots --- - if (getattr(self, "branch_combobox", None) is not None - and manager.type.branch.fset.enabled): - # Branch slots - self.branch_combobox.currentIndexChanged.connect( - self.branch_combobox.select) - - self.branch_combobox.sig_branch_changed.connect( - self.refresh_all) - - self.branch_combobox.sig_branch_changed.connect( - plugin.sig_branch_changed) - - if getattr(self, "unstaged_files", None) is not None: - # Unstage slots - self.unstaged_files.sig_stage_toggled.connect(self.post_stage) - self.unstaged_files.sig_stage_toggled[bool, str].connect( - self.post_stage) - - plugin.stage_all_action.triggered.connect( - self.unstaged_files.toggle_stage_all) - - if getattr(self, "staged_files", None): - # Stage slots - self.staged_files.sig_stage_toggled.connect(self.post_stage) - self.staged_files.sig_stage_toggled[bool, str].connect( - self.post_stage) - - plugin.unstage_all_action.triggered.connect( - self.staged_files.toggle_stage_all) - - # Show the whole UI before refreshes - QCoreApplication.processEvents() - - elif getattr(plugin, "create_vcs_action", None) is not None: - project_path = plugin.get_plugin( - Plugins.Projects).get_active_project_path() - - # Show No repository available only if there is an active project - if project_path: - rootlayout.addStretch(1) - rootlayout.addWidget( - QLabel( - _("

No repository available


" - "in ") + str(project_path))) - - create_button = action2button( - plugin.create_vcs_action, - text_beside_icon=True, - parent=self, - ) - create_button.setEnabled(bool(manager.create_vcs_types)) - # FIXME: change color if dark or white - create_button.setStyleSheet("background-color: #1122cc;") - rootlayout.addWidget(create_button) - - rootlayout.addStretch(1) - - # Public methods - def setup_slots(self) -> None: - """Connect all the common slots, including the plugins actions.""" - plugin = self.get_plugin() - # Plugin signals - plugin.sig_repository_changed.connect(self.setup) - plugin.sig_repository_changed.connect(self.refresh_all) - - # Plugin actions - plugin.create_vcs_action.triggered.connect(self.show_create_dialog) - plugin.commit_action.triggered.connect(self.commit) - plugin.fetch_action.triggered.connect( - partial( - self.sig_auth_operation[str, tuple, dict].emit, - "fetch", - (), - dict(sync=True), - )) - plugin.pull_action.triggered.connect( - partial(self.sig_auth_operation.emit, "pull")) - plugin.push_action.triggered.connect( - partial(self.sig_auth_operation.emit, "push")) + self.components.append(self.branch_list) - plugin.refresh_action.triggered.connect(self.refresh_all) + rootlayout.addWidget(self.changes) + rootlayout.addWidget(self.unstaged_changes) + rootlayout.addWidget(self.staged_changes) + self.components.extend(( + self.changes, + self.unstaged_changes, + self.staged_changes, + )) - # Auth actions - self.sig_auth_operation.connect(self.auth_operation) - self.sig_auth_operation[str, tuple, dict].connect(self.auth_operation) + rootlayout.addWidget(self.commit) + self.components.append(self.commit) - # Post auth slots - self.sig_auth_operation_success.connect(self.post_commit) - self.sig_auth_operation_success.connect( - Slot(str)(lambda operation: ( - self.refresh_changes(), - self.refresh_history(), - ) if operation == "pull" else None)) + rootlayout.addWidget(self.history) + self.components.append(self.history) - # refreshes slots - @Slot() - @Slot(bool) - def refresh_changes(self) -> None: - """Clear and re-add items in unstaged and staged changes.""" - @Slot(object) - def _handle_result(result): - if isinstance(result, Sequence): - self.unstaged_files.refresh(result) - if getattr(self, "staged_files", None) is not None: - self.staged_files.refresh(result) - - manager = self.get_plugin().vcs_manager - - # Only one changes call is done for both the widgets. - if manager.type.changes.fget.enabled: - ThreadWrapper( - self, - lambda: manager.changes, - result_slots=(_handle_result, ), - error_slots=(_raise_if, ), - nothread=not THREAD_ENABLED, - ).start() + rootlayout.addWidget(self.remote) + self.components.append(self.remote) - @Slot() - @Slot(tuple) - def refresh_commit_difference( - self, - commit_difference: typing.Optional[typing.Tuple[int, int]] = None, - ) -> None: - """ - Show the numbers of commits to pull and push compared to remote. + # TIP: Don't add repo_not_found to components list - Parameters - ---------- - commit_difference : tuple of int, optional - A tuple of 2 integers. - The first one is the amount of commit to pull, - the second one is the amount of commit to push. - Can be None, that allows this method to call the backend's - ':meth:`~VCSBackendBase.fetch` method. - The default is None. - """ + rootlayout.addWidget( + self.repo_not_found, + # Allows the widget to be expanded + 100, + ) - # FIXME: This method definitely needs a better name - # commit difference is ugly and probably wrong. - def _handle_result(differences): - # pull - if differences: - for difference, action in zip( - differences, (plugin.pull_action, plugin.push_action)): - - label = action.text().rsplit(" ", 1) - if len(label) == 2 and label[1][0] + label[1][-1] == "()": - # found existing number - del label[1] - if difference > 0: - action.setText("{} ({})".format( - " ".join(label), difference)) - else: - action.setText(" ".join(label)) - else: - plugin.pull_action.setText(_("pull")) - plugin.push_action.setText(_("push")) + rootlayout.addStretch(1) - plugin = self.get_plugin() - if commit_difference is None: - ThreadWrapper( - self, - plugin.vcs_manager.fetch, # pylint:disable=W0108 - result_slots=(_handle_result, ), - error_slots=(_raise_if, ), - nothread=not THREAD_ENABLED, - ).start() - else: - _handle_result(commit_difference) + # Slots + self.branch_list.sig_branch_changed.connect(self.refresh_all) + self.branch_list.sig_branch_changed.connect(plugin.sig_branch_changed) + + self.unstaged_changes.changes_tree.sig_stage_toggled.connect( + self._post_stage) + self.unstaged_changes.changes_tree.sig_stage_toggled[ + bool, str].connect(self._post_stage) + + self.staged_changes.changes_tree.sig_stage_toggled.connect( + self._post_stage) + self.staged_changes.changes_tree.sig_stage_toggled[bool, str].connect( + self._post_stage) + + self.commit.sig_auth_operation_success.connect(self.history.refresh) + self.commit.sig_auth_operation_success.connect(self.remote.refresh) + + self.history.sig_last_commit.connect(self._post_undo) + self.history.sig_last_commit.connect(self.remote.refresh) + + self.repo_not_found.create_dialog.sig_repository_ready.connect( + plugin.set_repository) + + plugin.sig_repository_changed.connect(self.setup_repo) + plugin.refresh_action.triggered.connect(self.refresh_all) + + # Toolbar + toolbar = self.get_main_toolbar() + toolbar.addWidget(self.branch_list) + self.add_item_to_toolbar( + refresh_button, + toolbar=toolbar, + section="main_section", + ) + + # Extra setup + self.repo_not_found.hide() + for component in self.components: + component.hide() + component.sig_vcs_error.connect(self.handle_error) @Slot() - def refresh_history(self) -> None: - """Populate history widget with old commits.""" - def _handle_result(result): - if result: - undo_enabled = bool(manager.undo_commit.enabled) - for i, commit in enumerate(result): - - item = QTreeWidgetItem() - # Keep the commit attributes in the item. - item.commit = commit - - # Set commit title - if commit.get("title"): - title = commit["title"] - elif commit.get("description"): - title = commit["description"].lstrip().splitlines()[0] - else: - title = None - - # TODO: Tell the user that there is no title - # (e.g. with an icon) - item.setText(1, title.strip() if title else "") - - # Set commit date - if commit.get("commit_date") is not None: - # TODO: update times - delta = (datetime.now(tz=timezone.utc) - - commit["commit_date"]) - - # FIXME: Suffixes should be translated - if delta.days: - item.setText(2, "{}d".format(abs(delta.days))) - elif delta.seconds >= 3600: - item.setText( - 2, "{}h".format(int(delta.seconds / 3600))) - elif delta.seconds >= 60: - item.setText( - 2, - "{}m".format(int(delta.seconds / 60)), - ) - else: - item.setText(2, "<1m") - - else: - # TODO: Use an icon - item.setText(2, "?") - - self.history.addTopLevelItem(item) - if undo_enabled: - button = QToolButton() - button.setIcon(ima.icon("undo")) - button.clicked.connect( - partial( - self.undo_commit, - i + 1, - )) - self.history.setItemWidget(item, 0, button) - - if i % PAUSE_CYCLE == 0: - QCoreApplication.processEvents() - - manager = self.get_plugin().vcs_manager - if manager.get_last_commits.enabled: - self.history.clear() - ThreadWrapper( - self, - partial(manager.get_last_commits, MAX_HISTORY_ROWS), - result_slots=(_handle_result, ), - error_slots=(lambda ex: _raise_if(ex, VCSFeatureError, True) - and self.history.addTopLevelItem( - QTreeWidgetItem([None, ex.error, None])), ), - nothread=not THREAD_ENABLED, - ).start() + def setup_repo(self): + """Set up the GUI for the current repository.""" + plugin = self.get_plugin() + + # Components setup + for component in self.components: + component.setup() + + if plugin.get_repository() is None: + if getattr(plugin, "create_vcs_action", None) is not None: + project_path = plugin.get_plugin( + Plugins.Projects).get_active_project_path() + + # Show No repository available only if there is an active project + if project_path: + self.repo_not_found.setup(project_path) + self.repo_not_found.show() + return + self.repo_not_found.hide() + else: + self.refresh_all() + self.repo_not_found.hide() @Slot() @Slot(str) @@ -501,39 +236,49 @@ def refresh_all(self, path: str = ...) -> None: The repository path. The default is ..., which means the path is unchanged. """ - # ... is used when this slot invoked by plugin.refresh_action + # ... is used when this slot is invoked by the refresh button if path: - self.refresh_changes() - if getattr(self, "branch_combobox", None) is not None: - self.branch_combobox.refresh() - self.refresh_commit_difference() - # self.refresh_commit() - self.refresh_history() - - # VCS edit slots - @Slot(str) - def select_branch(self, branchname: str) -> None: + for component in self.components: + component.refresh() + + @Slot(Exception) + def handle_error(self, ex: Exception) -> None: """ - Select a branch given its name. + A centralized method where exceptions are handled. Parameters ---------- - branchname : str, optional - The branch name. - - Raises - ------ - AttributeError - If changing branch is not supported. + ex : Exception + The exception to handle. """ - if getattr(self, "branch_combobox", - None) is None or not self.branch_combobox.isEnabled(): - raise AttributeError("Cannot change branch in the current VCS") - self.branch_combobox.select(branchname) + if isinstance(ex, VCSBackendFail): + plugin = self.get_plugin() + if not ex.is_valid_repository: + if not plugin.get_repository(): + # Suppress errors raised by other operations + # in the backend (running in another threads) + return + # Set broken repository to refresh the pane + # and show "No repository found" + plugin.set_repository(ex.directory) + + if not plugin.get_repository(): + # Prevent show the error if the backend is buggy and + # the repository is not really broken + QMessageBox.critical( + self, _("Broken repository"), + _("The repository is broken and cannot be used anymore." + )) + return + + # TODO: use issue reporter + raise ex + + # Private slots @Slot(bool) @Slot(bool, str) - def post_stage( + def _post_stage( self, staged: bool, path: typing.Optional[str] = None, @@ -543,376 +288,70 @@ def post_stage( See Also -------- - ChangesTree.sig_stage_toggled + ChangesTreeComponent.sig_stage_toggled For a description of parameters. """ if staged: - treewid = self.staged_files + treewid = self.staged_changes else: - treewid = self.unstaged_files + treewid = self.unstaged_changes + if treewid is not None: if path is None: - treewid.refresh() + treewid.changes_tree.refresh() else: - treewid.refresh_one(path) - - @Slot() - def commit(self) -> None: - """ - Commit all the changes in the VCS. - - If the VCS has a staging area, - only the staged file will be committed. - """ - text = self.commit_message.toPlainText() + treewid.changes_tree.refresh_one(path) - if self.staged_files is not None: - changes_to_commit = self.staged_files - elif self.unstaged_files is not None: - changes_to_commit = self.unstaged_files - else: - changes_to_commit = None - - if (text and (changes_to_commit is None - or changes_to_commit.invisibleRootItem().childCount())): - self.sig_auth_operation[str, tuple, dict].emit( - "commit", - (text, ), - dict(is_path=False), - ) - - @Slot(str) - def post_commit(self, operation: str) -> None: - """Update the UI after commit operation.""" - if operation == "commit": - manager = self.get_plugin().vcs_manager + @Slot(dict) + def _post_undo(self, commit: dict) -> None: + """Update commit message.""" + if not self.commit.commit_message.toPlainText(): + text = (commit.get("content") or commit.get("description") + or commit.get("title", "")) + self.commit.commit_message.setPlainText(text) - # FIXME: Preserve undo history of commit textedit - self.commit_message.clear() - self.refresh_history() - if manager.type.changes.fget.enabled: - if (manager.stage.enabled and manager.unstage.enabled): - self.staged_files.clear() - else: - self.unstaged_files.clear() +class RepoNotFoundComponent(BaseComponent, QWidget): + """A widget to show when no repository is found.""" + def __init__(self, *args, create_vcs_action: QAction, **kwargs): + super().__init__(*args, **kwargs) - @Slot(str) - @Slot(str, tuple, dict) - def auth_operation( # pylint: disable=W0102 - self, - operation: str, - args: tuple = (), - kwargs: dict = {}, - ): - """ - A helper to do operations that can requires authentication. + self.project_path = None - Parameters - ---------- - operation : str - The method name to call. - This will be used to get the corresponding backend error. - args : tuple, optional - Extra positional parameters to pass to the method. - kwargs : dict, optional - Extra keyword parameters to pass to the method. - """ - @Slot(object) - def _handle_result(result): - if result: - self.sig_auth_operation_success.emit(operation, result) - - manager = self.get_plugin().vcs_manager - func = getattr(manager, operation, None) - if func is not None and func.enabled: - func = partial(func, *args, **kwargs) - ThreadWrapper( - self, - func, - result_slots=(_handle_result, - lambda res: self.refresh_commit_difference() - if res else None), - error_slots=( - lambda ex: _raise_if(ex, VCSAuthError, True) or self. - handle_auth_error(ex, operation, args, kwargs), ), - nothread=not THREAD_ENABLED, - ).start() - - def handle_auth_error( # pylint: disable=W0102 - self, - ex: VCSAuthError, - operation: str, - args: tuple = (), - kwargs: dict = {} # pylint: disable=W0102 - ) -> None: - """Handle authentication errors by showing an input dialog.""" - def _accepted(): - ex.credentials = dialog.to_credentials() - self.sig_auth_operation[str, tuple, dict].emit( - operation, - args, - kwargs, - ) - - def _rejected(): - QMessageBox.critical( - self, - _("Authentication failed"), - _("Failed to authenticate to the {} remote server.".format( - manager.VCSNAME)), - ) - - manager = self.get_plugin().vcs_manager - credentials = ex.credentials - credentials.update( - **{ - # Use backend credentials if error credentials are not given - key: manager.credentials.get(key) - for key in ex.required_credentials - if credentials.get(key) is None - }) - - if credentials: - dialog = LoginDialog(self, **credentials) - dialog.accepted.connect(_accepted) - dialog.rejected.connect(_rejected) - dialog.show() - - @Slot() - def show_create_dialog(self) -> None: - """Show a :class:`CreateDialog` dialog for creating a repository.""" - plugin = self.get_plugin() - plugin.create_vcs_action.setEnabled(False) - dialog = CreateDialog( - plugin.vcs_manager, - plugin.get_plugin(Plugins.Projects).get_active_project_path(), + self.no_repo_found_label = QLabel(parent=self) + self.create_button = action2button( + create_vcs_action, + text_beside_icon=True, parent=self, ) - dialog.rejected.connect( - partial(plugin.create_vcs_action.setEnabled, True)) - dialog.sig_repository_ready.connect(plugin.set_repository) - dialog.show() - - @Slot() - @Slot(int) - def undo_commit(self, commits: int = 1) -> None: - """ - Undo commit and refresh the UI. + self.create_dialog = CreateDialog(self.manager, parent=self) - Parameters - ---------- - commits : int, optional - DESCRIPTION. The default is 1. - """ - @Slot() - @Slot(dict) - def _refresh_commit_message(commit=None): - if commit: - text = (commit.get("content") or commit.get("description") - or commit.get("title", "")) - self.commit_message.setPlainText(text) - - manager = self.get_plugin().vcs_manager - if manager.undo_commit.enabled: - slots = [ - self.refresh_changes, - lambda _: self.refresh_commit_difference(), - self.refresh_history, - ] - slots.append(_refresh_commit_message) - ThreadWrapper( - self, - partial(manager.undo_commit, commits), - result_slots=slots, - error_slots=(_raise_if, ), - nothread=not THREAD_ENABLED, - ).start() - - -class CreateDialog(QDialog): - """A dialog to manage cloning operation.""" - - sig_repository_ready = Signal(str) - """ - This signal is emitted when repository creation is done. - - Parameters - ---------- - repodir : str - The repository directory. - """ - def __init__(self, manager: VCSBackendManager, rootpath: str, parent=None): - super().__init__(parent=parent) - self.credentials = {} - self.tempdir = None - self.manager = manager + self.no_repo_found_label.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + self.create_button.setStyleSheet("background-color: #1122cc;") + self.create_button.setSizePolicy( + QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)) - # Widgets - self.vcs_select = QComboBox() - self.directory = QLineEdit() - self.url_select = UrlComboBox(self) - buttonbox = QDialogButtonBox(QDialogButtonBox.Ok - | QDialogButtonBox.Cancel) + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - # Widget setup - self.vcs_select.addItems(manager.create_vcs_types) - self.directory.setText(rootpath) - - # TODO: Currently, only opened projects can create repositories, - # so it is forbidden to change the repository path. - self.directory.setReadOnly(True) - - # Layout - rootlayout = QFormLayout(self) - rootlayout.addRow(QLabel(_("

Create new repository

"))) - rootlayout.addRow(_("VCS Type"), self.vcs_select) - rootlayout.addRow(_("Destination"), self.directory) - rootlayout.addRow(_("Source repository"), self.url_select) - rootlayout.addRow(buttonbox) + layout.addWidget(self.no_repo_found_label) + layout.addWidget(self.create_button) # Slots - buttonbox.accepted.connect(self.accept) - buttonbox.rejected.connect(self.cleanup) - buttonbox.rejected.connect(self.reject) + self.create_button.triggered.connect(self.show_create_dialog) + self.create_dialog.rejected.connect( + partial(self.create_button.setEnabled, True)) - # Public slots - @Slot() - def cleanup(self) -> None: - """Remove the temporary directory.""" - self.tempdir = None + def setup(self, project_path: str): + self.no_repo_found_label.setText( + _("

No repository available

\nin ") + str(project_path)) - # Qt overrides - @Slot() - def accept(self) -> None: - url = self.url_select.currentText() - if url: - if not self.url_select.is_valid(url): - QMessageBox.critical( - self, _("Invalid URL"), - -("Creating a repository from an existing" - "one requires a valid URL.")) - return - - if os.listdir(self.directory.text()): - ret = QMessageBox.warning( - self, - _("File override"), - _("Local files will be overriden by cloning.\n" - "Would you like to continue anyway?"), - QMessageBox.Ok | QMessageBox.Cancel, - ) - if ret != QMessageBox.Ok: - return - else: - url = None - self._try_create( - self.vcs_select.currentText(), - self.directory.text(), - url, - ) + self.create_dialog.setup(project_path) + self.create_button.setEnabled(bool(self.manager.create_vcs_types)) - super().accept() - - def show(self) -> None: - self.tempdir = TemporaryDirectory() - super().show() - - # Private methods - def _try_create(self, vcs_type: str, path: str, - from_: typing.Optional[str]) -> None: - def _handle_result(result): - if result: - ThreadWrapper( - self, - _move, - result_slots=( - lambda _: self.sig_repository_ready.emit(path), - self.cleanup), - error_slots=(_raise_if, self.cleanup), - ).start() - else: - QMessageBox.critical(self, _("Create failed"), - _("Repository creation failed unexpectedly.")) - self.cleanup() - self.rejected.emit() - - def _move(): - tempdir = self.tempdir.name - with ThreadPoolExecutor() as pool: - pool.map(lambda x: shutil.move(osp.join(tempdir, x), path), - os.listdir(tempdir)) - - ThreadWrapper( - self, - partial( - self.manager.create_with, - vcs_type, - self.tempdir.name, - from_=from_, - credentials=self.credentials, - ), - result_slots=(_handle_result, ), - error_slots=(lambda ex: _raise_if(ex, VCSAuthError, True) or self. - _handle_auth_error(ex), ), - nothread=not THREAD_ENABLED, - ).start() - - @Slot(VCSAuthError) - def _handle_auth_error(self, ex: VCSAuthError): - def _accepted(): - self.credentials = dialog.to_credentials() - self._try_create(self.vcs_select.currentText(), - self.directory.text(), - self.url_select.currentText()) - - def _rejected(): - QMessageBox.critical( - self, - _("Authentication failed"), - _("Failed to authenticate to the {} remote server.").format( - self.vcs_select.currentText()), - ) - # Inform the main widget that the operation had failed. - self.cleanup() - self.rejected.emit() - - credentials = { - # Use stored credentials if the error - # does not give them. - key: getattr(ex, key, None) or self.credentials.get(key) - for key in ex.required_credentials - } - - if credentials: - dialog = LoginDialog(self, **credentials) - dialog.accepted.connect(_accepted) - dialog.rejected.connect(_rejected) - dialog.show() - - -@Slot(Exception) -@Slot(Exception, type) -@Slot(Exception, type, bool) -def _raise_if(ex: Exception, - required_type: type = Exception, - inverse: bool = False): - condition = isinstance(ex, required_type) ^ inverse - if condition: - raise ex - - -def clear_layout(layout: QLayout) -> None: - """ - Clear the given layout from all the widgets and layouts. - - From https://stackoverflow.com/a/9383780/ - """ - if layout is not None: - while layout.count(): - item = layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - else: - clear_layout(item.layout()) + @Slot() + def show_create_dialog(self) -> None: + """Show a :class:`~CreateDialog` for creating a repository.""" + self.create_button.setEnabled(False) + self.create_dialog.show() + self.create_dialog.show()