models.gitrepo: Ignore unknown paths
[git-cola.git] / cola / models / gitrepo.py
blob6bec328e7683f2383ec12579aa3d9280c1619726
1 import os
2 import sys
3 import time
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
10 import cola
11 from cola import core
12 from cola import utils
13 from cola import qtutils
15 # Custom event type for GitRepoInfoEvents
16 INFO_EVENT_TYPE = QtCore.QEvent.User + 42
19 class Columns(object):
20 """Defines columns in the classic view"""
21 NAME = 'name'
22 STATUS = 'status'
23 AGE = 'age'
24 MESSAGE = 'message'
25 WHO = 'who'
26 ALL = (NAME, STATUS, AGE, MESSAGE, WHO)
29 class GitRepoModel(QtGui.QStandardItemModel):
30 """Provides an interface into a git repository for browsing purposes."""
31 def __init__(self, parent):
32 QtGui.QStandardItem.__init__(self, parent)
33 self._interesting_paths = self._get_paths()
34 self._known_paths = set()
35 model = cola.model()
36 model.add_message_observer(model.message_updated,
37 self._model_updated)
38 self._dir_rows = {}
39 self.setColumnCount(len(Columns.ALL))
40 for idx, header in enumerate(Columns.ALL):
41 self.setHeaderData(idx, Qt.Horizontal,
42 QtCore.QVariant(self.tr(header.title())))
44 self._direntries = {'': self.invisibleRootItem()}
45 self._initialize()
47 def _create_column(self, col, path):
48 """Creates a StandardItem for use in a treeview cell."""
49 # GitRepoNameItem is the only one that returns a custom type(),
50 # so we use to infer selections.
51 if col == Columns.NAME:
52 return GitRepoNameItem(path)
53 return GitRepoItem(col, path)
55 def _create_row(self, path):
56 """Return a list of items representing a row."""
57 return [self._create_column(c, path) for c in Columns.ALL]
59 def _add_file(self, parent, path, insert=False):
60 """Add a file entry to the model."""
62 # Create model items
63 row_items = self._create_row(path)
65 # Use a standard file icon for the name field
66 row_items[0].setIcon(qtutils.file_icon())
68 if not insert:
69 # Add file paths at the end of the list
70 parent.appendRow(row_items)
71 self.entry(path).update_name()
72 self._known_paths.add(path)
73 return
74 # Entries exist so try to find an a good insertion point
75 done = False
76 for idx in xrange(parent.rowCount()):
77 child = parent.child(idx, 0)
78 if child.rowCount() > 0:
79 continue
80 if path < child.path:
81 parent.insertRow(idx, row_items)
82 done = True
83 break
85 # No adequate place found so simply append
86 if not done:
87 parent.appendRow(row_items)
88 self.entry(path).update_name()
89 self._known_paths.add(path)
91 def add_directory(self, parent, path):
92 """Add a directory entry to the model."""
94 # Create model items
95 row_items = self._create_row(path)
97 # Use a standard directory icon
98 row_items[0].setIcon(qtutils.dir_icon())
100 # Insert directories before file paths
101 row = self._dir_rows.setdefault(parent, 0)
102 parent.insertRow(row, row_items)
103 self._dir_rows[parent] += 1
105 # Update the 'name' column for this entry
106 self.entry(path).update_name()
107 self._known_paths.add(path)
109 return row_items[0]
111 def path_is_interesting(self, path):
112 """Return True if path has a status."""
113 return path in self._interesting_paths
115 def _get_paths(self):
116 """Return paths of interest; e.g. paths with a status."""
117 model = cola.model()
118 paths = set(model.staged + model.unstaged)
119 return cola.utils.add_parents(paths)
121 def _model_updated(self):
122 """Observes model changes and updates paths accordingly."""
123 old_paths = self._interesting_paths
124 new_paths = self._get_paths()
125 for path in new_paths.union(old_paths):
126 if path not in self._known_paths:
127 continue
128 self.entry(path).update()
130 self._interesting_paths = new_paths
132 def _initialize(self):
133 """Iterate over the cola model and create GitRepoItems."""
134 for path in cola.model().everything():
135 self.add_file(path)
137 def add_file(self, path, insert=False):
138 """Add a file to the model."""
139 dirname = utils.dirname(path)
140 if dirname in self._direntries:
141 parent = self._direntries[dirname]
142 else:
143 parent = self._create_dir_entry(dirname, self._direntries)
144 self._direntries[dirname] = parent
145 self._add_file(parent, path, insert=insert)
147 def _create_dir_entry(self, dirname, direntries):
149 Create a directory entry for the model.
151 This ensures that directories are always listed before files.
154 entries = dirname.split('/')
155 curdir = []
156 parent = self.invisibleRootItem()
157 for entry in entries:
158 curdir.append(entry)
159 path = '/'.join(curdir)
160 if path in direntries:
161 parent = direntries[path]
162 else:
163 grandparent = parent
164 parent_path = '/'.join(curdir[:-1])
165 parent = self.add_directory(grandparent, path)
166 direntries[path] = parent
167 return parent
169 def entry(self, path):
170 """Return the GitRepoEntry for a path."""
171 return GitRepoEntryManager.entry(path)
174 class GitRepoEntryManager(object):
176 Provides access to static instances of GitRepoEntry and model data.
178 static_entries = {}
180 @classmethod
181 def entry(cls, path):
182 """Return a static instance of a GitRepoEntry."""
183 if path not in cls.static_entries:
184 cls.static_entries[path] = GitRepoEntry(path)
185 return cls.static_entries[path]
188 class GitRepoEntry(QtCore.QObject):
190 Provides asynchronous lookup of repository data for a path.
192 Emits signal names matching those defined in Columns.
195 def __init__(self, path):
196 QtCore.QObject.__init__(self)
197 self.path = path
198 self.task = None
200 def update_name(self):
201 """Emits a signal corresponding to the entry's name."""
202 # 'name' is cheap to calculate so simply emit a signal
203 self.emit(SIGNAL(Columns.NAME), utils.basename(self.path))
204 if '/' not in self.path:
205 self.update()
207 def update(self):
208 """Starts a GitRepoInfoTask to calculate info for entries."""
209 # GitRepoInfoTask handles expensive lookups
210 threadpool = QtCore.QThreadPool.globalInstance()
211 self.task = GitRepoInfoTask(self.path)
212 threadpool.start(self.task)
214 def event(self, e):
215 """Receive GitRepoInfoEvents and emit corresponding Qt signals."""
216 if e.type() == INFO_EVENT_TYPE:
217 e.accept()
218 self.emit(SIGNAL(e.signal), *e.data)
219 return True
220 return QtCore.QObject.event(self, e)
223 class GitRepoInfoTask(QtCore.QRunnable):
224 """Handles expensive git lookups for a path."""
225 def __init__(self, path):
226 QtCore.QRunnable.__init__(self)
227 self.path = path
228 self._data = {}
230 def data(self, key):
232 Return git data for a path.
234 Supported keys are 'date', 'message', and 'author'
237 if not self._data:
238 log_line = cola.model().git.log('-1', '--', self.path,
239 M=True,
240 all=True,
241 pretty='format:%ar/%s/%an')
242 if log_line:
243 log_line = core.decode(log_line)
244 date, rest = log_line.split('/', 1)
245 message, author = rest.rsplit('/', 1)
246 self._data['date'] = date
247 self._data['message'] = message
248 self._data['author'] = author
249 else:
250 self._data['date'] = self.date()
251 self._data['message'] = '-'
252 self._data['author'] = cola.model().local_user_name
253 return self._data[key]
255 def name(self):
256 """Calculate the name for an entry."""
257 return utils.basename(self.path)
259 def date(self):
261 Returns a relative date for a file path.
263 This is typically used for new entries that do not have
264 'git log' information.
267 encpath = core.encode(self.path)
268 st = os.stat(encpath)
269 elapsed = time.time() - st.st_mtime
270 minutes = int(elapsed / 60.)
271 if minutes < 60:
272 return '%d minutes ago' % minutes
273 hours = int(elapsed / 60. / 60.)
274 if hours < 24:
275 return '%d hours ago' % hours
276 return '%d days ago' % int(elapsed / 60. / 60. / 24.)
278 def status(self):
279 """Return the status for the entry's path."""
281 model = cola.model()
282 unmerged = utils.add_parents(set(model.unmerged))
283 modified = utils.add_parents(set(model.modified))
284 staged = utils.add_parents(set(model.staged))
285 untracked = utils.add_parents(set(model.untracked))
286 upstream_changed = utils.add_parents(set(model.upstream_changed))
288 if self.path in unmerged:
289 return qtutils.tr('Unmerged')
290 if self.path in modified and self.path in staged:
291 return qtutils.tr('Partially Staged')
292 if self.path in modified:
293 return qtutils.tr('Modified')
294 if self.path in staged:
295 return qtutils.tr('Staged')
296 if self.path in untracked:
297 return qtutils.tr('Untracked')
298 if self.path in upstream_changed:
299 return qtutils.tr('Changed Upstream')
300 return '-'
302 def run(self):
303 """Perform expensive lookups and post corresponding events."""
304 app = QtGui.QApplication.instance()
305 app.postEvent(GitRepoEntryManager.entry(self.path),
306 GitRepoInfoEvent(Columns.MESSAGE, self.data('message')))
307 app.postEvent(GitRepoEntryManager.entry(self.path),
308 GitRepoInfoEvent(Columns.AGE, self.data('date')))
309 app.postEvent(GitRepoEntryManager.entry(self.path),
310 GitRepoInfoEvent(Columns.WHO, self.data('author')))
311 app.postEvent(GitRepoEntryManager.entry(self.path),
312 GitRepoInfoEvent(Columns.STATUS, self.status()))
315 class GitRepoInfoEvent(QtCore.QEvent):
316 """Transport mechanism for communicating from a GitRepoInfoTask."""
317 def __init__(self, signal, *data):
318 QtCore.QEvent.__init__(self, QtCore.QEvent.User + 1)
319 self.signal = signal
320 self.data = data
322 def type(self):
323 return INFO_EVENT_TYPE
326 class GitRepoItem(QtGui.QStandardItem):
328 Represents a cell in a treeview.
330 Many GitRepoItems map to a single repository path.
331 Each GitRepoItem manages a different cell in the tree view.
332 One is created for each column -- Name, Status, Age, etc.
335 def __init__(self, column, path):
336 QtGui.QStandardItem.__init__(self)
337 self.setEditable(False)
338 self.setDragEnabled(False)
339 entry = GitRepoEntryManager.entry(path)
340 QtCore.QObject.connect(entry, SIGNAL(column), self.setText)
343 class GitRepoNameItem(GitRepoItem):
344 """Subclass GitRepoItem to provide a custom type()."""
345 TYPE = QtGui.QStandardItem.UserType + 1
347 def __init__(self, path):
348 GitRepoItem.__init__(self, Columns.NAME, path)
349 self.path = path
351 def type(self):
353 Indicate that this item is of a special user-defined type.
355 'name' is the only column that registers a user-defined type.
356 This is done to allow filtering out other columns when determining
357 which paths are selected.
360 return GitRepoNameItem.TYPE