browse: do not auto-delete tasks
[git-cola.git] / cola / models / browse.py
blobf9e578b85806e60e710636a6865fd2e14e676ebd
1 from __future__ import division, absolute_import, unicode_literals
3 import collections
4 import time
6 from PyQt4 import QtCore
7 from PyQt4 import QtGui
8 from PyQt4.QtCore import Qt
9 from PyQt4.QtCore import SIGNAL
11 from cola import gitcfg
12 from cola import gitcmds
13 from cola import core
14 from cola import utils
15 from cola import qtutils
16 from cola import version
17 from cola import resources
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.User + 42
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):
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)
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 def __init__(self, parent):
88 QtGui.QStandardItemModel.__init__(self, parent)
90 self.entries = {}
91 self._interesting_paths = set()
92 self._interesting_files = set()
93 self._known_paths = set()
94 self._dir_entries= {}
95 self._dir_rows = collections.defaultdict(int)
97 self.connect(self, SIGNAL('updated()'),
98 self.refresh, Qt.QueuedConnection)
100 model = main.model()
101 model.add_observer(model.message_updated, self._model_updated)
103 self.file_icon = qtutils.file_icon()
104 self.dir_icon = qtutils.dir_icon()
106 def mimeData(self, indexes):
107 paths = qtutils.paths_from_indexes(self, indexes,
108 item_type=GitRepoNameItem.TYPE)
109 return qtutils.mimedata_from_paths(paths)
111 def mimeTypes(self):
112 return qtutils.path_mimetypes()
114 def clear(self):
115 super(GitRepoModel, self).clear()
116 self.entries.clear()
118 def row(self, path, create=True):
119 try:
120 row = self.entries[path]
121 except KeyError:
122 if create:
123 row = self.entries[path] = [self.create_column(c, path)
124 for c in Columns.ALL]
125 else:
126 row = None
127 return row
129 def create_column(self, col, path):
130 """Creates a StandardItem for use in a treeview cell."""
131 # GitRepoNameItem is the only one that returns a custom type(),
132 # so we use to infer selections.
133 if col == Columns.NAME:
134 item = GitRepoNameItem(path)
135 else:
136 item = GitRepoItem(col, path)
137 return item
139 def _add_file(self, parent, path, insert=False):
140 """Add a file entry to the model."""
142 self._known_paths.add(path)
144 # Create model items
145 row_items = self.row(path)
147 # Use a standard file icon for the name field
148 row_items[0].setIcon(self.file_icon)
150 if not insert:
151 # Add file paths at the end of the list
152 parent.appendRow(row_items)
153 self.entry(path).update_name()
154 return
155 # Entries exist so try to find an a good insertion point
156 done = False
157 for idx in range(parent.rowCount()):
158 child = parent.child(idx, 0)
159 if child.rowCount() > 0:
160 continue
161 if path < child.path:
162 parent.insertRow(idx, row_items)
163 done = True
164 break
166 # No adequate place found so simply append
167 if not done:
168 parent.appendRow(row_items)
169 self.entry(path).update_name()
171 def add_directory(self, parent, path):
172 """Add a directory entry to the model."""
174 # Create model items
175 row_items = self.row(path)
177 # Use a standard directory icon
178 row_items[0].setIcon(self.dir_icon)
180 # Insert directories before file paths
181 # TODO: have self._dir_rows's keys based on something less flaky than
182 # QStandardItem instances.
183 parent_path = _item_path(parent)
184 row = self._dir_rows[parent_path]
185 parent.insertRow(row, row_items)
186 self._dir_rows[parent_path] += 1
188 # Update the 'name' column for this entry
189 self.entry(path).update_name()
190 self._known_paths.add(path)
192 return row_items[0]
194 def path_is_interesting(self, path):
195 """Return True if path has a status."""
196 return path in self._interesting_paths
198 def get_paths(self, files=None):
199 """Return paths of interest; e.g. paths with a status."""
200 if files is None:
201 files = self.get_files()
202 return utils.add_parents(files)
204 def get_files(self):
205 model = main.model()
206 return set(model.staged + model.unstaged)
208 def _model_updated(self):
209 """Observes model changes and updates paths accordingly."""
210 self.emit(SIGNAL('updated()'))
212 def refresh(self):
213 old_files = self._interesting_files
214 old_paths = self._interesting_paths
215 new_files = self.get_files()
216 new_paths = self.get_paths(files=new_files)
218 if new_files != old_files or not old_paths:
219 self.clear()
220 self._initialize()
221 self.emit(SIGNAL('restore()'))
223 # Existing items
224 for path in sorted(new_paths.union(old_paths)):
225 self.entry(path).update()
227 self._interesting_files = new_files
228 self._interesting_paths = new_paths
230 def _initialize(self):
232 self.setColumnCount(len(Columns.ALL))
233 for idx, header in enumerate(Columns.ALL):
234 text = Columns.text(header)
235 self.setHeaderData(idx, Qt.Horizontal, QtCore.QVariant(text))
237 self._entries = {}
238 self._dir_rows = collections.defaultdict(int)
239 self._known_paths = set()
240 self._dir_entries = {'': self.invisibleRootItem()}
241 self._interesting_files = files = self.get_files()
242 self._interesting_paths = self.get_paths(files=files)
243 for path in gitcmds.all_files():
244 self.add_file(path)
246 def add_file(self, path, insert=False):
247 """Add a file to the model."""
248 dirname = utils.dirname(path)
249 if dirname in self._dir_entries:
250 parent = self._dir_entries[dirname]
251 else:
252 parent = self._create_dir_entry(dirname, self._dir_entries)
253 self._dir_entries[dirname] = parent
254 self._add_file(parent, path, insert=insert)
256 def _create_dir_entry(self, dirname, direntries):
258 Create a directory entry for the model.
260 This ensures that directories are always listed before files.
263 entries = dirname.split('/')
264 curdir = []
265 parent = self.invisibleRootItem()
266 curdir_append = curdir.append
267 self_add_directory = self.add_directory
268 for entry in entries:
269 curdir_append(entry)
270 path = '/'.join(curdir)
271 try:
272 parent = direntries[path]
273 except KeyError:
274 grandparent = parent
275 parent = self_add_directory(grandparent, path)
276 direntries[path] = parent
277 return parent
279 def entry(self, path):
280 """Return the GitRepoEntry for a path."""
281 return GitRepoEntryStore.entry(path)
284 class TaskRunner(object):
285 """Manages QRunnable tasks to avoid python's garbage collector
287 When PyQt stops referencing a QRunnable Python cleans it up which leads to
288 segfaults, e.g. the dreaded "C++ object has gone away".
290 This class keeps track of tasks and cleans up references to them as they
291 complete.
294 singleton = None
296 @classmethod
297 def current(cls):
298 if cls.singleton is None:
299 cls.singleton = TaskRunner()
300 return cls.singleton
302 def __init__(self):
303 self.tasks = set()
304 self.threadpool = QtCore.QThreadPool.globalInstance()
305 self.notifier = QtCore.QObject()
306 self.notifier.connect(self.notifier, SIGNAL('task_done(PyQt_PyObject)'),
307 self.task_done, Qt.QueuedConnection)
309 def run(self, task):
310 self.tasks.add(task)
311 self.threadpool.start(task)
313 def task_done(self, task):
314 if task in self.tasks:
315 self.tasks.remove(task)
317 def cleanup_task(self, task):
318 self.notifier.emit(SIGNAL('task_done(PyQt_PyObject)'), task)
321 class GitRepoEntry(QtCore.QObject):
323 Provides asynchronous lookup of repository data for a path.
325 Emits signal names matching those defined in Columns.
328 def __init__(self, path):
329 QtCore.QObject.__init__(self)
330 self.path = path
332 def update_name(self):
333 """Emits a signal corresponding to the entry's name."""
334 # 'name' is cheap to calculate so simply emit a signal
335 self.emit(SIGNAL(Columns.NAME), utils.basename(self.path))
336 if '/' not in self.path:
337 self.update()
339 def update(self):
340 """Starts a GitRepoInfoTask to calculate info for entries."""
341 # GitRepoInfoTask handles expensive lookups
342 task = GitRepoInfoTask(self.path)
343 TaskRunner.current().run(task)
345 def event(self, e):
346 """Receive GitRepoInfoEvents and emit corresponding Qt signals."""
347 if e.type() == INFO_EVENT_TYPE:
348 e.accept()
349 self.emit(SIGNAL(e.signal), *e.data)
350 return True
351 return QtCore.QObject.event(self, e)
354 # Support older versions of PyQt
355 if version.check('pyqt_qrunnable', QtCore.PYQT_VERSION_STR):
356 QRunnable = QtCore.QRunnable
357 else:
358 class QRunnable(object):
359 pass
362 class GitRepoInfoTask(QRunnable):
363 """Handles expensive git lookups for a path."""
365 def __init__(self, path):
366 QRunnable.__init__(self)
367 self.setAutoDelete(False)
368 self.path = path
369 self._cfg = gitcfg.current()
370 self._data = {}
372 def data(self, key):
374 Return git data for a path.
376 Supported keys are 'date', 'message', and 'author'
379 if not self._data:
380 log_line = main.model().git.log('-1', '--', self.path,
381 no_color=True,
382 pretty=r'format:%ar%x01%s%x01%an',
383 _readonly=True
384 )[STDOUT]
385 if log_line:
386 log_line = log_line
387 date, message, author = log_line.split(chr(0x01), 2)
388 self._data['date'] = date
389 self._data['message'] = message
390 self._data['author'] = author
391 else:
392 self._data['date'] = self.date()
393 self._data['message'] = '-'
394 self._data['author'] = self._cfg.get('user.name', 'unknown')
395 return self._data[key]
397 def name(self):
398 """Calculate the name for an entry."""
399 return utils.basename(self.path)
401 def date(self):
403 Returns a relative date for a file path.
405 This is typically used for new entries that do not have
406 'git log' information.
409 try:
410 st = core.stat(self.path)
411 except:
412 return N_('%d minutes ago') % 0
413 elapsed = time.time() - st.st_mtime
414 minutes = int(elapsed / 60)
415 if minutes < 60:
416 return N_('%d minutes ago') % minutes
417 hours = int(elapsed / 60 / 60)
418 if hours < 24:
419 return N_('%d hours ago') % hours
420 return N_('%d days ago') % int(elapsed / 60 / 60 / 24)
422 def status(self):
423 """Return the status for the entry's path."""
425 model = main.model()
426 unmerged = utils.add_parents(model.unmerged)
427 modified = utils.add_parents(model.modified)
428 staged = utils.add_parents(model.staged)
429 untracked = utils.add_parents(model.untracked)
430 upstream_changed = utils.add_parents(model.upstream_changed)
432 if self.path in unmerged:
433 return (resources.icon('modified.png'), N_('Unmerged'))
434 if self.path in modified and self.path in staged:
435 return (resources.icon('partial.png'), N_('Partially Staged'))
436 if self.path in modified:
437 return (resources.icon('modified.png'), N_('Modified'))
438 if self.path in staged:
439 return (resources.icon('staged.png'), N_('Staged'))
440 if self.path in upstream_changed:
441 return (resources.icon('upstream.png'), N_('Changed Upstream'))
442 if self.path in untracked:
443 return (None, '?')
444 return (None, '')
446 def run(self):
447 """Perform expensive lookups and post corresponding events."""
448 app = QtGui.QApplication.instance()
449 entry = GitRepoEntryStore.entry(self.path)
450 app.postEvent(entry,
451 GitRepoInfoEvent(Columns.MESSAGE, self.data('message')))
452 app.postEvent(entry,
453 GitRepoInfoEvent(Columns.AGE, self.data('date')))
454 app.postEvent(entry,
455 GitRepoInfoEvent(Columns.AUTHOR, self.data('author')))
456 app.postEvent(entry,
457 GitRepoInfoEvent(Columns.STATUS, self.status()))
459 TaskRunner.current().cleanup_task(self)
462 class GitRepoInfoEvent(QtCore.QEvent):
463 """Transport mechanism for communicating from a GitRepoInfoTask."""
464 def __init__(self, signal, *data):
465 QtCore.QEvent.__init__(self, QtCore.QEvent.User + 1)
466 self.signal = signal
467 self.data = data
469 def type(self):
470 return INFO_EVENT_TYPE
473 class GitRepoItem(QtGui.QStandardItem):
475 Represents a cell in a treeview.
477 Many GitRepoItems map to a single repository path.
478 Each GitRepoItem manages a different cell in the tree view.
479 One is created for each column -- Name, Status, Age, etc.
482 def __init__(self, column, path):
483 QtGui.QStandardItem.__init__(self)
484 self.path = path
485 self.cached = False
486 self.setDragEnabled(False)
487 self.setEditable(False)
488 entry = GitRepoEntryStore.entry(path)
489 if column == Columns.STATUS:
490 QtCore.QObject.connect(entry, SIGNAL(column), self.set_status,
491 Qt.QueuedConnection)
492 else:
493 QtCore.QObject.connect(entry, SIGNAL(column), self.setText,
494 Qt.QueuedConnection)
496 def set_status(self, data):
497 icon, txt = data
498 if icon:
499 self.setIcon(QtGui.QIcon(icon))
500 else:
501 self.setIcon(QtGui.QIcon())
502 self.setText(txt)
505 class GitRepoNameItem(GitRepoItem):
506 """Subclass GitRepoItem to provide a custom type()."""
507 TYPE = QtGui.QStandardItem.UserType + 1
509 def __init__(self, path):
510 GitRepoItem.__init__(self, Columns.NAME, path)
511 self.setDragEnabled(True)
513 def type(self):
515 Indicate that this item is of a special user-defined type.
517 'name' is the only column that registers a user-defined type.
518 This is done to allow filtering out other columns when determining
519 which paths are selected.
522 return GitRepoNameItem.TYPE