qtpy: make git-cola pyside-compatible
[git-cola.git] / cola / models / browse.py
blob1d311a9a270de8aeefbe2bb4120972988bd1435d
1 from __future__ import division, absolute_import, unicode_literals
3 import collections
4 import time
6 from qtpy import QtCore
7 from qtpy import QtGui
8 from qtpy import QtWidgets
9 from qtpy.QtCore import Qt
10 from qtpy.QtCore import Signal
12 from cola import gitcfg
13 from cola import gitcmds
14 from cola import core
15 from cola import icons
16 from cola import utils
17 from cola import qtutils
18 from cola.git import STDOUT
19 from cola.i18n import N_
20 from cola.models import main
23 # Custom event type for GitRepoInfoEvents
24 INFO_EVENT_TYPE = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
27 class Columns(object):
28 """Defines columns in the worktree browser"""
30 NAME = 'name'
31 STATUS = 'status'
32 AGE = 'age'
33 MESSAGE = 'message'
34 AUTHOR = 'author'
35 ALL = (NAME, STATUS, MESSAGE, AUTHOR, AGE)
37 @classmethod
38 def text(cls, column):
39 if column == cls.NAME:
40 return N_('Name')
41 elif column == cls.STATUS:
42 return N_('Status')
43 elif column == cls.MESSAGE:
44 return N_('Message')
45 elif column == cls.AUTHOR:
46 return N_('Author')
47 elif column == cls.AGE:
48 return N_('Age')
49 else:
50 raise NotImplementedError('Mapping required for "%s"' % column)
53 class GitRepoEntryStore(object):
55 entries = {}
57 @classmethod
58 def entry(cls, path, parent, runtask):
59 """Return the shared GitRepoEntry for a path."""
60 try:
61 e = cls.entries[path]
62 except KeyError:
63 e = cls.entries[path] = GitRepoEntry(path, parent, runtask)
64 return e
66 @classmethod
67 def remove(cls, path):
68 try:
69 del cls.entries[path]
70 except KeyError:
71 pass
74 def _item_path(item):
75 """Return the item's path"""
76 try:
77 path = item.path
78 except AttributeError:
79 # the root QStandardItem does not have a 'path' attribute
80 path = ''
81 return path
84 class GitRepoModel(QtGui.QStandardItemModel):
85 """Provides an interface into a git repository for browsing purposes."""
87 updated = Signal()
88 restore = Signal()
90 def __init__(self, parent):
91 QtGui.QStandardItemModel.__init__(self, parent)
93 self.entries = {}
94 self._runtask = qtutils.RunTask(parent=parent)
95 self._parent = parent
96 self._interesting_paths = set()
97 self._interesting_files = set()
98 self._known_paths = set()
99 self._dir_entries = {}
100 self._dir_rows = collections.defaultdict(int)
102 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
104 model = main.model()
105 model.add_observer(model.message_updated, self._model_updated)
107 self.file_icon = icons.file_text()
108 self.dir_icon = icons.directory()
110 def mimeData(self, indexes):
111 paths = qtutils.paths_from_indexes(self, indexes,
112 item_type=GitRepoNameItem.TYPE)
113 return qtutils.mimedata_from_paths(paths)
115 def mimeTypes(self):
116 return qtutils.path_mimetypes()
118 def clear(self):
119 super(GitRepoModel, self).clear()
120 self.entries.clear()
122 def row(self, path, create=True):
123 try:
124 row = self.entries[path]
125 except KeyError:
126 if create:
127 row = self.entries[path] = [self.create_column(c, path)
128 for c in Columns.ALL]
129 else:
130 row = None
131 return row
133 def create_column(self, col, path):
134 """Creates a StandardItem for use in a treeview cell."""
135 # GitRepoNameItem is the only one that returns a custom type(),
136 # so we use to infer selections.
137 if col == Columns.NAME:
138 item = GitRepoNameItem(path, self._parent, self._runtask)
139 else:
140 item = GitRepoItem(col, path, self._parent, self._runtask)
141 return item
143 def _add_file(self, parent, path, insert=False):
144 """Add a file entry to the model."""
146 self._known_paths.add(path)
148 # Create model items
149 row_items = self.row(path)
151 # Use a standard file icon for the name field
152 row_items[0].setIcon(self.file_icon)
154 if not insert:
155 # Add file paths at the end of the list
156 parent.appendRow(row_items)
157 self.entry(path).update_name()
158 return
159 # Entries exist so try to find an a good insertion point
160 done = False
161 for idx in range(parent.rowCount()):
162 child = parent.child(idx, 0)
163 if child.rowCount() > 0:
164 continue
165 if path < child.path:
166 parent.insertRow(idx, row_items)
167 done = True
168 break
170 # No adequate place found so simply append
171 if not done:
172 parent.appendRow(row_items)
173 self.entry(path).update_name()
175 def add_directory(self, parent, path):
176 """Add a directory entry to the model."""
178 # Create model items
179 row_items = self.row(path)
181 # Use a standard directory icon
182 row_items[0].setIcon(self.dir_icon)
184 # Insert directories before file paths
185 # TODO: have self._dir_rows's keys based on something less flaky than
186 # QStandardItem instances.
187 parent_path = _item_path(parent)
188 row = self._dir_rows[parent_path]
189 parent.insertRow(row, row_items)
190 self._dir_rows[parent_path] += 1
192 # Update the 'name' column for this entry
193 self.entry(path).update_name()
194 self._known_paths.add(path)
196 return row_items[0]
198 def path_is_interesting(self, path):
199 """Return True if path has a status."""
200 return path in self._interesting_paths
202 def get_paths(self, files=None):
203 """Return paths of interest; e.g. paths with a status."""
204 if files is None:
205 files = self.get_files()
206 return utils.add_parents(files)
208 def get_files(self):
209 model = main.model()
210 return set(model.staged + model.unstaged)
212 def _model_updated(self):
213 """Observes model changes and updates paths accordingly."""
214 self.updated.emit()
216 def refresh(self):
217 old_files = self._interesting_files
218 old_paths = self._interesting_paths
219 new_files = self.get_files()
220 new_paths = self.get_paths(files=new_files)
222 if new_files != old_files or not old_paths:
223 self.clear()
224 self._initialize()
225 self.restore.emit()
227 # Existing items
228 for path in sorted(new_paths.union(old_paths)):
229 self.entry(path).update()
231 self._interesting_files = new_files
232 self._interesting_paths = new_paths
234 def _initialize(self):
236 self.setColumnCount(len(Columns.ALL))
237 for idx, header in enumerate(Columns.ALL):
238 text = Columns.text(header)
239 self.setHeaderData(idx, Qt.Horizontal, text)
241 self._entries = {}
242 self._dir_rows = collections.defaultdict(int)
243 self._known_paths = set()
244 self._dir_entries = {'': self.invisibleRootItem()}
245 self._interesting_files = files = self.get_files()
246 self._interesting_paths = self.get_paths(files=files)
247 for path in gitcmds.all_files():
248 self.add_file(path)
250 def add_file(self, path, insert=False):
251 """Add a file to the model."""
252 dirname = utils.dirname(path)
253 if dirname in self._dir_entries:
254 parent = self._dir_entries[dirname]
255 else:
256 parent = self._create_dir_entry(dirname, self._dir_entries)
257 self._dir_entries[dirname] = parent
258 self._add_file(parent, path, insert=insert)
260 def _create_dir_entry(self, dirname, direntries):
262 Create a directory entry for the model.
264 This ensures that directories are always listed before files.
267 entries = dirname.split('/')
268 curdir = []
269 parent = self.invisibleRootItem()
270 curdir_append = curdir.append
271 self_add_directory = self.add_directory
272 for entry in entries:
273 curdir_append(entry)
274 path = '/'.join(curdir)
275 try:
276 parent = direntries[path]
277 except KeyError:
278 grandparent = parent
279 parent = self_add_directory(grandparent, path)
280 direntries[path] = parent
281 return parent
283 def entry(self, path):
284 """Return the GitRepoEntry for a path."""
285 return GitRepoEntryStore.entry(path, self._parent, self._runtask)
288 class GitRepoEntry(QtCore.QObject):
290 Provides asynchronous lookup of repository data for a path.
292 Emits signal names matching those defined in Columns.
295 name = Signal(object)
296 status = Signal(object)
297 author = Signal(object)
298 message = Signal(object)
299 age = Signal(object)
301 def __init__(self, path, parent, runtask):
302 QtCore.QObject.__init__(self, parent)
303 self.path = path
304 self.runtask = runtask
306 def update_name(self):
307 """Emits a signal corresponding to the entry's name."""
308 # 'name' is cheap to calculate so simply emit a signal
309 self.name.emit(utils.basename(self.path))
310 if '/' not in self.path:
311 self.update()
313 def update(self):
314 """Starts a GitRepoInfoTask to calculate info for entries."""
315 # GitRepoInfoTask handles expensive lookups
316 task = GitRepoInfoTask(self.path, self, self.runtask)
317 self.runtask.start(task)
319 def event(self, e):
320 """Receive GitRepoInfoEvents and emit corresponding Qt signals."""
321 if e.type() == INFO_EVENT_TYPE:
322 e.accept()
323 signal = getattr(self, e.signal)
324 signal.emit(*e.data)
325 return True
326 return QtCore.QObject.event(self, e)
329 class GitRepoInfoTask(qtutils.Task):
330 """Handles expensive git lookups for a path."""
332 def __init__(self, path, parent, runtask):
333 qtutils.Task.__init__(self, parent)
334 self.path = path
335 self._parent = parent
336 self._runtask = runtask
337 self._cfg = gitcfg.current()
338 self._data = {}
340 def data(self, key):
342 Return git data for a path.
344 Supported keys are 'date', 'message', and 'author'
347 if not self._data:
348 log_line = main.model().git.log('-1', '--', self.path,
349 no_color=True,
350 pretty=r'format:%ar%x01%s%x01%an',
351 _readonly=True
352 )[STDOUT]
353 if log_line:
354 log_line = log_line
355 date, message, author = log_line.split(chr(0x01), 2)
356 self._data['date'] = date
357 self._data['message'] = message
358 self._data['author'] = author
359 else:
360 self._data['date'] = self.date()
361 self._data['message'] = '-'
362 self._data['author'] = self._cfg.get('user.name', 'unknown')
363 return self._data[key]
365 def name(self):
366 """Calculate the name for an entry."""
367 return utils.basename(self.path)
369 def date(self):
371 Returns a relative date for a file path.
373 This is typically used for new entries that do not have
374 'git log' information.
377 try:
378 st = core.stat(self.path)
379 except:
380 return N_('%d minutes ago') % 0
381 elapsed = time.time() - st.st_mtime
382 minutes = int(elapsed / 60)
383 if minutes < 60:
384 return N_('%d minutes ago') % minutes
385 hours = int(elapsed / 60 / 60)
386 if hours < 24:
387 return N_('%d hours ago') % hours
388 return N_('%d days ago') % int(elapsed / 60 / 60 / 24)
390 def status(self):
391 """Return the status for the entry's path."""
393 model = main.model()
394 unmerged = utils.add_parents(model.unmerged)
395 modified = utils.add_parents(model.modified)
396 staged = utils.add_parents(model.staged)
397 untracked = utils.add_parents(model.untracked)
398 upstream_changed = utils.add_parents(model.upstream_changed)
400 if self.path in unmerged:
401 status = (icons.modified_name(), N_('Unmerged'))
402 elif self.path in modified and self.path in staged:
403 status = (icons.partial_name(), N_('Partially Staged'))
404 elif self.path in modified:
405 status = (icons.modified_name(), N_('Modified'))
406 elif self.path in staged:
407 status = (icons.staged_name(), N_('Staged'))
408 elif self.path in upstream_changed:
409 status = (icons.upstream_name(), N_('Changed Upstream'))
410 elif self.path in untracked:
411 status = (None, '?')
412 else:
413 status = (None, '')
414 return status
416 def task(self):
417 """Perform expensive lookups and post corresponding events."""
418 app = QtWidgets.QApplication.instance()
419 entry = GitRepoEntryStore.entry(self.path, self._parent, self._runtask)
420 app.postEvent(entry,
421 GitRepoInfoEvent(Columns.MESSAGE, self.data('message')))
422 app.postEvent(entry,
423 GitRepoInfoEvent(Columns.AGE, self.data('date')))
424 app.postEvent(entry,
425 GitRepoInfoEvent(Columns.AUTHOR, self.data('author')))
426 app.postEvent(entry,
427 GitRepoInfoEvent(Columns.STATUS, self.status()))
430 class GitRepoInfoEvent(QtCore.QEvent):
431 """Transport mechanism for communicating from a GitRepoInfoTask."""
432 def __init__(self, signal, *data):
433 QtCore.QEvent.__init__(self, INFO_EVENT_TYPE)
434 self.signal = signal
435 self.data = data
437 def type(self):
438 return INFO_EVENT_TYPE
441 class GitRepoItem(QtGui.QStandardItem):
443 Represents a cell in a treeview.
445 Many GitRepoItems map to a single repository path.
446 Each GitRepoItem manages a different cell in the tree view.
447 One is created for each column -- Name, Status, Age, etc.
450 def __init__(self, column, path, parent, runtask):
451 QtGui.QStandardItem.__init__(self)
452 self.path = path
453 self.runtask = runtask
454 self.cached = False
455 self.setDragEnabled(False)
456 self.setEditable(False)
457 entry = GitRepoEntryStore.entry(path, parent, runtask)
458 if column == Columns.STATUS:
459 entry.status.connect(self.set_status, type=Qt.QueuedConnection)
460 else:
461 signal = getattr(entry, column)
462 signal.connect(self.setText, type=Qt.QueuedConnection)
464 def set_status(self, data):
465 icon, txt = data
466 if icon:
467 self.setIcon(QtGui.QIcon(icon))
468 else:
469 self.setIcon(QtGui.QIcon())
470 self.setText(txt)
473 class GitRepoNameItem(GitRepoItem):
474 """Subclass GitRepoItem to provide a custom type()."""
475 TYPE = QtGui.QStandardItem.ItemType(QtGui.QStandardItem.UserType + 1)
477 def __init__(self, path, parent, runtask):
478 GitRepoItem.__init__(self, Columns.NAME, path, parent, runtask)
479 self.setDragEnabled(True)
481 def type(self):
483 Indicate that this item is of a special user-defined type.
485 'name' is the only column that registers a user-defined type.
486 This is done to allow filtering out other columns when determining
487 which paths are selected.
490 return GitRepoNameItem.TYPE