Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR: Color code files by version control status in Project Explorer #8415

Open
wants to merge 35 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9d172f9
color files in project editor based on vcs status
alexschw Dec 11, 2018
13a6f29
PEP8 issues and fix of the mercurial version
alexschw Dec 11, 2018
e478788
Revert whitespace removals
alexschw Dec 11, 2018
54dc6e7
Fix Windows Color highlighting (still error in safe-mode)
alexschw Dec 17, 2018
e89360f
Use config for the colors and fix breaking tests
alexschw Dec 18, 2018
4ba8ee5
use os.path to compare paths
alexschw Jan 7, 2019
cd60d3d
Fix on_file_saved action
alexschw Jan 7, 2019
87c3a7d
Remove CamelCase and revert unrelated lines
alexschw Jan 9, 2019
41b8d03
Update the color of new and moved files, even from outside
alexschw Jan 9, 2019
f921c35
Add test of the vcs-colors
alexschw Jan 14, 2019
045ba80
Add support for subdirectory repos and test them
alexschw Jan 15, 2019
9728c2d
Update only single files on saving.
alexschw Feb 22, 2019
3885010
Merge conflict as orange state and check just one file on click
alexschw Mar 19, 2019
2e4ce0c
Fix Tests on windows
alexschw Mar 21, 2019
eedb27c
Add a project configpage
alexschw Mar 21, 2019
7c2b1f3
Remove commented sourcecode
Mar 21, 2019
a4278bf
Fix git vcs
Mar 21, 2019
80e696d
Fix tests and pylint
alexschw Mar 22, 2019
03aa956
Another try to fix the tests
alexschw Mar 22, 2019
dd2ddf5
Project: Clarify the confpage and fix issues
alexschw Mar 22, 2019
96e53b7
Revert the VCS-configpage
alexschw May 17, 2019
2415f51
Use a timer to check for vcs changes every 2s
alexschw May 17, 2019
faea332
Fix Unicode coercion issue
alexschw May 17, 2019
846d3d8
Another try to fix
alexschw May 21, 2019
8e0e705
Merge from master
alexschw Jun 11, 2019
82a044b
Every 8s will be enough & Fixed Unicode in vcs.py
alexschw Jun 11, 2019
4c85e71
...again
alexschw Jun 11, 2019
e8dc190
Merge remote-tracking branch 'upstream/master' into vcs_support
dalthviz Oct 23, 2019
8086318
Projects: Initial update of the vcs coloring for files
dalthviz Oct 29, 2019
141c38a
Projects: Handle None root_path and escape octal path representations
dalthviz Oct 29, 2019
ca0b5bf
Revert None root_path change (handled in the next lines)
alexschw Oct 30, 2019
cffec0a
use '.' in os.listdir for backwards compatibility
alexschw Nov 1, 2019
c394539
Color subdirectories and subfiles too
alexschw Nov 5, 2019
8ff4a0e
Merge branch 'master' into vcs_supp and cleanup
alexschw Nov 5, 2019
274cb44
Merge remote-tracking branch 'upstream/master' into vcs_support
dalthviz Jan 6, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified bootstrap.py
100755 → 100644
Empty file.
3 changes: 2 additions & 1 deletion spyder/config/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@
'name_filters': NAME_FILTERS,
'show_all': True,
'show_hscrollbar': True,
'visible_if_project_open': True
'visible_if_project_open': True,
'use_version_control': True,
}),
('explorer',
{
Expand Down
6 changes: 6 additions & 0 deletions spyder/plugins/editor/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,7 @@ def register_editorstack(self, editorstack):
editorstack.sig_new_file[()].connect(self.new)
editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks)
editorstack.file_saved.connect(self.file_saved_in_editorstack)
editorstack.file_saved.connect(self.update_vcs_status)
editorstack.file_renamed_in_data.connect(
self.file_renamed_in_data_in_editorstack)
editorstack.opened_files_list_changed.connect(
Expand Down Expand Up @@ -1399,6 +1400,11 @@ def file_renamed_in_data_in_editorstack(self, editorstack_id_str,
if str(id(editorstack)) != editorstack_id_str:
editorstack.rename_in_data(original_filename, filename)

@Slot(str, str)
def update_vcs_status(self, _, filename):
if self.projects is not None:
self.projects.update_vcs_status(filename)

#------ Handling editor windows
def setup_other_windows(self):
"""Setup toolbars and menus for 'New window' instances"""
Expand Down
101 changes: 98 additions & 3 deletions spyder/plugins/explorer/widgets/explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@

# Third party imports
from qtpy.compat import getexistingdirectory, getsavefilename
from qtpy.QtCore import (QDir, QFileInfo, QMimeData, QSize,
from qtpy.QtCore import (QDir, QFileInfo, QMimeData, QModelIndex, QSize,
QSortFilterProxyModel, Qt, QTimer, QUrl, Signal, Slot)
from qtpy.QtGui import QDrag, QKeySequence
from qtpy.QtGui import QColor, QDrag, QKeySequence
from qtpy.QtWidgets import (QApplication, QFileIconProvider, QFileSystemModel,
QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QMenu, QMessageBox, QToolButton, QTreeView,
Expand Down Expand Up @@ -139,6 +139,101 @@ def icon(self, icontype_or_qfileinfo):
return icon


class ColorModel(QFileSystemModel):
"""FileSystemModel providing a color-code for different commit-status."""
def __init__(self, *args, **kwargs):
self.vcs_state = {}
self.vcs_dirs = {}
self.color_array = [
QColor('#ff0000'), # untracked
QColor('#808080'), # ignored
QColor('#0099ff'), # modified
QColor('#00ff00'), # added
QColor('#ff7700'), # conflict
QColor(ima.MAIN_FG_COLOR)] # Base
self.root_path = ''
self.use_vcs = CONF.get('project_explorer', 'use_version_control')
self.func = lambda p, f, l: self.new_row(p, f, l)
super(ColorModel, self).__init__(*args, **kwargs)
self.vcs_state_timer = QTimer(self)
self.vcs_state_timer.timeout.connect(self.set_vcs_state)
if self.use_vcs:
self.vcs_state_timer.start(8000)

def set_highlighting(self, state):
"""Enable/Disable the highlighting"""
self.use_vcs = state
if self.use_vcs:
self.vcs_state_timer.start(8000)
else:
self.vcs_state_timer.stop()
if state and not self.vcs_state:
self.set_vcs_state()
self.dataChanged.emit(QModelIndex(), QModelIndex())

def new_row(self, parent, first, last):
"""Checks the contents of a new row when enabled."""
if not self.use_vcs or first != last:
return
data = parent.child(last, 0).data()
if data:
self.set_vcs_state()

def on_project_loaded(self):
"""Get a new file list and enable checks on a loaded project."""
self.set_vcs_state()
self.rowsInserted.connect(self.func)
self.dataChanged.emit(QModelIndex(), QModelIndex())

def on_project_closed(self):
"""Clear file list and disable checks."""
self.vcs_state = {}
self.vcs_dirs = {}
self.rowsInserted.disconnect(self.func)

@Slot()
@Slot(str)
def set_vcs_state(self, root_path=None):
"""Set the vcs state dictionary."""
if not self.use_vcs:
return
if root_path is None:
if self.root_path is None:
return
self.vcs_state, self.vcs_dirs = vcs.get_vcs_status(self.root_path)
elif osp.isfile(root_path):
# root_path is a file -> add the new state to vcs_state
status = vcs.get_vcs_file_status(root_path)
filename = osp.abspath(root_path)
if status != 0:
self.vcs_state[filename] = status
else:
# root_path is a directory -> get a new vcs_state
self.root_path = osp.abspath(root_path)
self.vcs_state, vcs_dirs = vcs.get_vcs_status(self.root_path)
self.dataChanged.emit(QModelIndex(), QModelIndex())

def data(self, index, role=Qt.DisplayRole):
"""Set the colors of the elements in the Treeview."""
if role == Qt.TextColorRole and self.use_vcs and self.vcs_state:
filename = osp.abspath(self.filePath(index))
if filename in self.vcs_state:
color_index = self.vcs_state[filename]
elif filename in self.vcs_dirs:
color_index = self.vcs_dirs[filename]
else:
found = False
for folder in self.vcs_dirs:
if folder in filename:
color_index = self.vcs_dirs[folder]
found = True
break
if not found:
color_index = -1
return self.color_array[color_index]
return super(ColorModel, self).data(index, role)


class DirView(QTreeView):
"""Base file/directory tree view"""
sig_edit = Signal(str)
Expand Down Expand Up @@ -185,7 +280,7 @@ def __init__(self, parent=None):
def setup_fs_model(self):
"""Setup filesystem model"""
filters = QDir.AllDirs | QDir.Files | QDir.Drives | QDir.NoDotAndDotDot
self.fsmodel = QFileSystemModel(self)
self.fsmodel = ColorModel(self)
self.fsmodel.setFilter(filters)
self.fsmodel.setNameFilterDisables(False)

Expand Down
20 changes: 20 additions & 0 deletions spyder/plugins/projects/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ def get_plugin_actions(self):
self.delete_project_action = create_action(self,
_("Delete Project"),
triggered=self.delete_project)
self.toggle_vcs_action = create_action(self, _("Show git/hg status"),
toggled=self.set_vcs_state)
self.clear_recent_projects_action =\
create_action(self, _("Clear this list"),
triggered=self.clear_recent_projects)
Expand All @@ -115,6 +117,7 @@ def get_plugin_actions(self):
self.delete_project_action,
MENU_SEPARATOR,
self.recent_project_menu,
self.toggle_vcs_action,
self._toggle_view_action]

self.setup_menu_actions()
Expand Down Expand Up @@ -159,6 +162,8 @@ def register_plugin(self):
self.sig_project_loaded.connect(
lambda v: self.main.editor.setup_open_files())
self.sig_project_loaded.connect(self.update_explorer)
self.sig_project_loaded.connect(
self.explorer.treewidget.fsmodel.on_project_loaded)
self.sig_project_closed[object].connect(
lambda v: self.main.workingdirectory.chdir(
self.get_last_working_dir()))
Expand All @@ -169,6 +174,8 @@ def register_plugin(self):
update_kind='deletion'))
self.sig_project_closed.connect(
lambda v: self.main.editor.setup_open_files())
self.sig_project_closed.connect(
self.explorer.treewidget.fsmodel.on_project_closed)
self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions)

self.main.pythonpath_changed()
Expand Down Expand Up @@ -256,6 +263,9 @@ def update_project_actions(self):
self.close_project_action.setEnabled(active)
self.delete_project_action.setEnabled(active)

use_vcs = self.get_option('use_version_control')
self.toggle_vcs_action.setChecked(use_vcs)

@Slot()
def create_new_project(self):
"""Create new project"""
Expand Down Expand Up @@ -444,6 +454,16 @@ def get_last_working_dir(self):
return self.main.editor.get_option('last_working_dir',
default=getcwd_or_home())

def update_vcs_status(self, filename):
"""Update state for the given filename."""
self.explorer.treewidget.fsmodel.set_vcs_state(filename)
self.explorer.treewidget.update()

def set_vcs_state(self, value):
self.set_option('use_version_control', value)
self.explorer.treewidget.fsmodel.set_highlighting(value)
self.explorer.treewidget.update()

def save_config(self):
"""
Save configuration: opened projects & tree widget state.
Expand Down
6 changes: 4 additions & 2 deletions spyder/plugins/projects/projecttypes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ def set_recent_files(self, recent_files):
def get_recent_files(self):
"""Return a list of files opened by the project."""
recent_files = self.config.get('main', 'recent_files', default=[])
recent_files = [recent_file if os.path.isabs(recent_file)
else os.path.join(self.root_path, recent_file)
recent_files = [to_text_string(recent_file)
if os.path.isabs(to_text_string(recent_file))
else os.path.join(self.root_path,
to_text_string(recent_file))
for recent_file in recent_files]
for recent_file in recent_files[:]:
if not os.path.isfile(recent_file):
Expand Down
65 changes: 65 additions & 0 deletions spyder/plugins/projects/widgets/tests/test_project_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@
# Test library imports
import pytest

# Third Party imports
from qtpy.QtCore import Qt

# Local imports
from spyder.plugins.projects.widgets.explorer import ProjectExplorerTest
from spyder.py3compat import to_text_string
from spyder.utils import programs


@pytest.fixture
Expand Down Expand Up @@ -64,5 +68,66 @@ def test_project_explorer(project_explorer, qtbot):
assert project


@pytest.mark.change_directory
@pytest.mark.skipif(os.name == 'nt' and os.environ.get('AZURE') is not None,
reason="Fails on Windows/Azure")
def test_project_vcs_color(project_explorer, qtbot):
"""Test that files are colored according to their commit state."""
# Create project
project_explorer.show()
project_dir = project_explorer.directory
test_dir = os.getcwd()
os.chdir(str(project_dir))

# Create files for the repository
files = []
for n in range(5):
files.append(osp.join(project_dir, 'file%i.py' % n))
if n > 0:
open(files[n], 'w').close()

# Init the repo and set some files to different states
gitcmds = [['init', '.'],
['config', '--local', 'user.email', '"john@doe.com"'],
['add', 'file2.py', 'file4.py'],
['commit', '-m', 'test']]
for g_cmd in gitcmds:
p = programs.run_program('git', g_cmd, cwd=project_dir)
p.communicate()
# change file 3 and add the changes
f = open(files[2], 'a')
f.writelines('text')
f.close()
p = programs.run_program('git', ['add', 'file3.py'], cwd=project_dir)
p.communicate()
# Write file1 into .gitignore
gitign = open(osp.join(project_dir, '.gitignore'), 'a')
gitign.writelines('file1.py')
gitign.close()

# Check that the files have their according colors
tree = project_explorer.explorer.treewidget
pcolors = tree.fsmodel.color_array
# Conflict is not tested
pcolors.remove(pcolors[4])

# Check if the correct colors are set
tree.expandAll()
tree.setup_view()
tree.fsmodel.set_vcs_state(project_dir)
qtbot.waitForWindowShown(project_explorer.explorer)
with qtbot.waitSignal(tree.fsmodel.layoutChanged, raising=False):
tree.fsmodel.on_project_loaded()
with qtbot.waitSignal(tree.fsmodel.layoutChanged, raising=False):
open(files[0], 'w').close()
ind0 = tree.fsmodel.index(tree.fsmodel.rootPath()).child(0, 0)
qtbot.waitUntil(lambda: tree.fsmodel.index(0, 0, ind0) is not None)
qtbot.waitSignal(tree.fsmodel.vcs_state_timer.timeout)
for n in range(5):
file_index = tree.fsmodel.index("file{0}.py".format(n), 0)
assert file_index.data(Qt.TextColorRole).name() == pcolors[n].name()
os.chdir(test_dir)


if __name__ == "__main__":
pytest.main()
24 changes: 20 additions & 4 deletions spyder/utils/tests/test_vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
import sys

# Test library imports
from spyder.utils import programs
import pytest

# Local imports
from spyder.utils.programs import run_program
from spyder.utils.vcs import (ActionToolNotFound, get_git_refs,
get_git_remotes, get_git_revision, get_vcs_root,
remote_to_url, run_vcs_tool)
get_vcs_status, remote_to_url, run_vcs_tool)
from spyder.utils import programs


HERE = os.path.abspath(os.path.dirname(__file__))
Expand All @@ -39,8 +40,8 @@ def test_vcs_tool():

def test_vcs_root(tmpdir):
directory = tmpdir.mkdir('foo')
assert get_vcs_root(str(directory)) == None
assert get_vcs_root(osp.dirname(__file__)) != None
assert get_vcs_root(str(directory)) is None
assert get_vcs_root(osp.dirname(__file__)) is not None


@pytest.mark.skipif(os.name == 'nt' and os.environ.get('AZURE') is not None,
Expand Down Expand Up @@ -74,6 +75,21 @@ def test_get_git_refs():
assert any(['master' in b for b in branch_tags])


def test_vcs_state(tmpdir):
"""Test the vcs state of a directory and subdirectories."""
test_dir = os.getcwd()
tmpdir.chdir()
subdir = str(tmpdir.mkdir('subdir'))
print(repr(subdir))
proc = run_program('git', ['init'], cwd=subdir)
proc.communicate()
file = osp.join(subdir, 'test.py')
open(file, 'w').close()
assert get_vcs_status(subdir) != ({}, {})
assert get_vcs_status(str(tmpdir)) != ({}, {})
os.chdir(test_dir)


def test_get_git_remotes():
remotes = get_git_remotes(HERE)
assert 'origin' in remotes
Expand Down
Loading