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()