PageRenderTime 70ms CodeModel.GetById 33ms app.highlight 32ms RepoModel.GetById 1ms app.codeStats 1ms

/tortoisehg/hgqt/hgemail.py

https://bitbucket.org/tortoisehg/hgtk/
Python | 461 lines | 376 code | 51 blank | 34 comment | 51 complexity | 0d5055c3f45909ecda92db56ffdeab99 MD5 | raw file
  1# hgemail.py - TortoiseHg's dialog for sending patches via email
  2#
  3# Copyright 2007 TK Soh <teekaysoh@gmail.com>
  4# Copyright 2007 Steve Borho <steve@borho.org>
  5# Copyright 2010 Yuya Nishihara <yuya@tcha.org>
  6#
  7# This software may be used and distributed according to the terms of the
  8# GNU General Public License version 2, incorporated herein by reference.
  9
 10import os, tempfile, re
 11from StringIO import StringIO
 12from PyQt4.QtCore import *
 13from PyQt4.QtGui import *
 14from mercurial import error, extensions, util, cmdutil
 15from tortoisehg.util import hglib, paths
 16from tortoisehg.hgqt.i18n import _
 17from tortoisehg.hgqt import cmdui, lexers, qtlib, thgrepo
 18from tortoisehg.hgqt.hgemail_ui import Ui_EmailDialog
 19
 20class EmailDialog(QDialog):
 21    """Dialog for sending patches via email"""
 22    def __init__(self, repo, revs, parent=None, outgoing=False,
 23                 outgoingrevs=None):
 24        """Create EmailDialog for the given repo and revs
 25
 26        :revs: List of revisions to be sent.
 27        :outgoing: Enable outgoing bundle support. You also need to set
 28                   outgoing revisions to `revs`.
 29        :outgoingrevs: Target revision of outgoing bundle.
 30                       (Passed as `hg email --bundle --rev {rev}`)
 31        """
 32        super(EmailDialog, self).__init__(parent)
 33        self._repo = repo
 34        self._outgoing = outgoing
 35        self._outgoingrevs = outgoingrevs or []
 36
 37        self._qui = Ui_EmailDialog()
 38        self._qui.setupUi(self)
 39
 40        self._initchangesets(revs)
 41        self._initpreviewtab()
 42        self._initenvelopebox()
 43        self._qui.bundle_radio.toggled.connect(self._updateforms)
 44        self._initintrobox()
 45        self._readhistory()
 46        self._filldefaults()
 47        self._updateforms()
 48        self._readsettings()
 49
 50    def keyPressEvent(self, event):
 51        # don't send email by just hitting enter
 52        if event.key() in (Qt.Key_Return, Qt.Key_Enter):
 53            if event.modifiers() == Qt.ControlModifier:
 54                self.accept()  # Ctrl+Enter
 55
 56            return
 57
 58        super(EmailDialog, self).keyPressEvent(event)
 59
 60    def closeEvent(self, event):
 61        self._writesettings()
 62        super(EmailDialog, self).closeEvent(event)
 63
 64    def _readsettings(self):
 65        s = QSettings()
 66        self.restoreGeometry(s.value('email/geom').toByteArray())
 67        self._qui.intro_changesets_splitter.restoreState(
 68            s.value('email/intro_changesets_splitter').toByteArray())
 69
 70    def _writesettings(self):
 71        s = QSettings()
 72        s.setValue('email/geom', self.saveGeometry())
 73        s.setValue('email/intro_changesets_splitter',
 74                   self._qui.intro_changesets_splitter.saveState())
 75
 76    def _readhistory(self):
 77        s = QSettings()
 78        for k in ('to', 'cc', 'from', 'flag'):
 79            w = getattr(self._qui, '%s_edit' % k)
 80            w.addItems(s.value('email/%s_history' % k).toStringList())
 81            w.setCurrentIndex(-1)  # unselect
 82
 83    def _writehistory(self):
 84        def itercombo(w):
 85            if w.currentText():
 86                yield w.currentText()
 87            for i in xrange(w.count()):
 88                if w.itemText(i) != w.currentText():
 89                    yield w.itemText(i)
 90
 91        s = QSettings()
 92        for k in ('to', 'cc', 'from', 'flag'):
 93            w = getattr(self._qui, '%s_edit' % k)
 94            s.setValue('email/%s_history' % k, list(itercombo(w))[:10])
 95
 96    def _initchangesets(self, revs):
 97        def purerevs(revs):
 98            return cmdutil.revrange(self._repo,
 99                                    iter(str(e) for e in revs))
100
101        self._changesets = _ChangesetsModel(self._repo,
102                                            # TODO: [':'] is inefficient
103                                            revs=purerevs(revs or [':']),
104                                            selectedrevs=purerevs(revs),
105                                            parent=self)
106        self._changesets.dataChanged.connect(self._updateforms)
107        self._qui.changesets_view.setModel(self._changesets)
108
109    @property
110    def _ui(self):
111        return self._repo.ui
112
113    @property
114    def _revs(self):
115        """Returns list of revisions to be sent"""
116        return self._changesets.selectedrevs
117
118    def _filldefaults(self):
119        """Fill form by default values"""
120        def getfromaddr(ui):
121            """Get sender address in the same manner as patchbomb"""
122            addr = ui.config('email', 'from') or ui.config('patchbomb', 'from')
123            if addr:
124                return addr
125            try:
126                return ui.username()
127            except error.Abort:
128                return ''
129
130        self._qui.to_edit.setEditText(
131            hglib.tounicode(self._ui.config('email', 'to', '')))
132        self._qui.cc_edit.setEditText(
133            hglib.tounicode(self._ui.config('email', 'cc', '')))
134        self._qui.from_edit.setEditText(hglib.tounicode(getfromaddr(self._ui)))
135
136        self.setdiffformat(self._ui.configbool('diff', 'git') and 'git' or 'hg')
137
138    def setdiffformat(self, format):
139        """Set diff format, 'hg', 'git' or 'plain'"""
140        try:
141            radio = getattr(self._qui, '%spatch_radio' % format)
142        except AttributeError:
143            raise ValueError('unknown diff format: %r' % format)
144
145        radio.setChecked(True)
146
147    def getdiffformat(self):
148        """Selected diff format"""
149        for e in self._qui.patch_frame.children():
150            m = re.match(r'(\w+)patch_radio', str(e.objectName()))
151            if m and e.isChecked():
152                return m.group(1)
153
154        return 'hg'
155
156    def getextraopts(self):
157        """Dict of extra options"""
158        opts = {}
159        for e in self._qui.extra_frame.children():
160            m = re.match(r'(\w+)_check', str(e.objectName()))
161            if m:
162                opts[m.group(1)] = e.isChecked()
163
164        return opts
165
166    def _patchbombopts(self, **opts):
167        """Generate opts for patchbomb by form values"""
168        def headertext(s):
169            # QLineEdit may contain newline character
170            return re.sub(r'\s', ' ', hglib.fromunicode(s))
171
172        opts['to'] = [headertext(self._qui.to_edit.currentText())]
173        opts['cc'] = [headertext(self._qui.cc_edit.currentText())]
174        opts['from'] = headertext(self._qui.from_edit.currentText())
175        opts['in_reply_to'] = headertext(self._qui.inreplyto_edit.text())
176        opts['flag'] = [headertext(self._qui.flag_edit.currentText())]
177
178        if self._qui.bundle_radio.isChecked():
179            assert self._outgoing  # only outgoing bundle is supported
180            opts['rev'] = map(str, self._outgoingrevs)
181            opts['bundle'] = True
182        else:
183            opts['rev'] = map(str, self._revs)
184
185        def diffformat():
186            n = self.getdiffformat()
187            if n == 'hg':
188                return {}
189            else:
190                return {n: True}
191        opts.update(diffformat())
192
193        opts.update(self.getextraopts())
194
195        def writetempfile(s):
196            fd, fname = tempfile.mkstemp(prefix='thg_emaildesc_')
197            try:
198                os.write(fd, s)
199                return fname
200            finally:
201                os.close(fd)
202
203        opts['intro'] = self._qui.writeintro_check.isChecked()
204        if opts['intro']:
205            opts['subject'] = headertext(self._qui.subject_edit.text())
206            opts['desc'] = writetempfile(hglib.fromunicode(self._qui.body_edit.toPlainText()))
207            # TODO: change patchbomb not to use temporary file
208
209        # Include the repo in the command so it can be found when thg is not
210        # run from within a hg path
211        opts['repository'] = self._repo.root
212
213        return opts
214
215    def _isvalid(self):
216        """Filled all required values?"""
217        for e in ('to_edit', 'from_edit'):
218            if not getattr(self._qui, e).currentText():
219                return False
220
221        if self._qui.writeintro_check.isChecked() and not self._qui.subject_edit.text():
222            return False
223
224        if not self._revs:
225            return False
226
227        return True
228
229    @pyqtSlot()
230    def _updateforms(self):
231        """Update availability of form widgets"""
232        valid = self._isvalid()
233        self._qui.send_button.setEnabled(valid)
234        self._qui.main_tabs.setTabEnabled(self._previewtabindex(), valid)
235        self._qui.writeintro_check.setEnabled(not self._introrequired())
236
237        self._qui.bundle_radio.setEnabled(
238            self._outgoing and self._changesets.isselectedall())
239        self._changesets.setReadOnly(self._qui.bundle_radio.isChecked())
240        if self._qui.bundle_radio.isChecked():
241            # workaround to disable preview for outgoing bundle because it
242            # may freeze main thread
243            self._qui.main_tabs.setTabEnabled(self._previewtabindex(), False)
244
245        if self._introrequired():
246            self._qui.writeintro_check.setChecked(True)
247
248    def _initenvelopebox(self):
249        for e in ('to_edit', 'from_edit'):
250            getattr(self._qui, e).editTextChanged.connect(self._updateforms)
251
252    def close(self):
253        super(EmailDialog, self).accept()
254
255    def accept(self):
256        # TODO: want to pass patchbombopts directly
257        def cmdargs(opts):
258            args = []
259            for k, v in opts.iteritems():
260                if isinstance(v, bool):
261                    if v:
262                        args.append('--%s' % k.replace('_', '-'))
263                else:
264                    for e in isinstance(v, basestring) and [v] or v:
265                        args += ['--%s' % k.replace('_', '-'), e]
266
267            return args
268
269        hglib.loadextension(self._ui, 'patchbomb')
270
271        opts = self._patchbombopts()
272        try:
273            cmd = cmdui.Dialog(['email'] + cmdargs(opts), parent=self)
274            cmd.setWindowTitle(_('Sending Email'))
275            cmd.setShowOutput(False)
276            if cmd.exec_():
277                self._writehistory()
278                super(EmailDialog, self).accept()
279        finally:
280            if 'desc' in opts:
281                os.unlink(opts['desc'])  # TODO: don't use tempfile
282
283    def _initintrobox(self):
284        self._qui.intro_box.hide()  # hidden by default
285        self._qui.subject_edit.textChanged.connect(self._updateforms)
286        self._qui.writeintro_check.toggled.connect(self._updateforms)
287
288    def _introrequired(self):
289        """Is intro message required?"""
290        return len(self._revs) > 1 or self._qui.bundle_radio.isChecked()
291
292    def _initpreviewtab(self):
293        def initqsci(w):
294            w.setUtf8(True)
295            w.setReadOnly(True)
296            w.setMarginWidth(1, 0)  # hide area for line numbers
297            lex = lexers.get_diff_lexer(self)
298            fh = qtlib.getfont('fontdiff')
299            fh.changed.connect(lambda f: lex.setFont(f))
300            # TODO: why cannot we connect directly, without lambda?
301            lex.setFont(fh.font())
302            w.setLexer(lex)
303            # TODO: better way to setup diff lexer
304        initqsci(self._qui.preview_edit)
305
306        self._qui.main_tabs.currentChanged.connect(self._refreshpreviewtab)
307        self._refreshpreviewtab(self._qui.main_tabs.currentIndex())
308
309    @pyqtSlot(int)
310    def _refreshpreviewtab(self, index):
311        """Generate preview text if current tab is preview"""
312        if self._previewtabindex() != index:
313            return
314
315        self._qui.preview_edit.setText(self._preview())
316
317    def _preview(self):
318        """Generate preview text by running patchbomb"""
319        def loadpatchbomb():
320            hglib.loadextension(self._ui, 'patchbomb')
321            return extensions.find('patchbomb')
322
323        def wrapui(ui):
324            buf = StringIO()
325            # TODO: common way to prepare pure ui
326            newui = ui.copy()
327            newui.setconfig('ui', 'interactive', False)
328            newui.setconfig('diff', 'git', False)
329            newui.write = lambda *args, **opts: buf.write(''.join(args))
330            newui.status = lambda *args, **opts: None
331            return newui, buf
332
333        def stripheadmsg(s):
334            # TODO: skip until first Content-type: line ??
335            return '\n'.join(s.splitlines()[3:])
336
337        ui, buf = wrapui(self._ui)
338        opts = self._patchbombopts(test=True)
339        try:
340            # TODO: fix hgext.patchbomb's implementation instead
341            if 'PAGER' in os.environ:
342                del os.environ['PAGER']
343
344            loadpatchbomb().patchbomb(ui, self._repo, **opts)
345            return stripheadmsg(hglib.tounicode(buf.getvalue()))
346        finally:
347            if 'desc' in opts:
348                os.unlink(opts['desc'])  # TODO: don't use tempfile
349
350    def _previewtabindex(self):
351        """Index of preview tab"""
352        return self._qui.main_tabs.indexOf(self._qui.preview_tab)
353
354    @pyqtSlot()
355    def on_settings_button_clicked(self):
356        from tortoisehg.hgqt import settings
357        if settings.SettingsDialog(parent=self, focus='email.from').exec_():
358            # not use repo.configChanged because it can clobber user input
359            # accidentally.
360            self._repo.invalidateui()  # force reloading config immediately
361            self._filldefaults()
362
363class _ChangesetsModel(QAbstractTableModel):  # TODO: use component of log viewer?
364    _COLUMNS = [('rev', lambda ctx: '%d:%s' % (ctx.rev(), ctx)),
365                ('author', lambda ctx: hglib.username(ctx.user())),
366                ('date', lambda ctx: util.shortdate(ctx.date())),
367                ('description', lambda ctx: ctx.description().splitlines()[0])]
368
369    def __init__(self, repo, revs, selectedrevs, parent=None):
370        super(_ChangesetsModel, self).__init__(parent)
371        self._repo = repo
372        self._revs = list(reversed(sorted(revs)))
373        self._selectedrevs = set(selectedrevs)
374        self._readonly = False
375
376    @property
377    def revs(self):
378        return self._revs
379
380    @property
381    def selectedrevs(self):
382        """Return the list of selected revisions"""
383        return list(sorted(self._selectedrevs))
384
385    def isselectedall(self):
386        return len(self._revs) == len(self._selectedrevs)
387
388    def data(self, index, role):
389        if not index.isValid():
390            return QVariant()
391
392        rev = self._revs[index.row()]
393        if index.column() == 0 and role == Qt.CheckStateRole:
394            return rev in self._selectedrevs and Qt.Checked or Qt.Unchecked
395        if role == Qt.DisplayRole:
396            coldata = self._COLUMNS[index.column()][1]
397            return QVariant(hglib.tounicode(coldata(self._repo[rev])))
398
399        return QVariant()
400
401    def setData(self, index, value, role=Qt.EditRole):
402        if not index.isValid() or self._readonly:
403            return False
404
405        rev = self._revs[index.row()]
406        if index.column() == 0 and role == Qt.CheckStateRole:
407            origvalue = rev in self._selectedrevs
408            if value == Qt.Checked:
409                self._selectedrevs.add(rev)
410            else:
411                self._selectedrevs.remove(rev)
412
413            if origvalue != (rev in self._selectedrevs):
414                self.dataChanged.emit(index, index)
415
416            return True
417
418        return False
419
420    def setReadOnly(self, readonly):
421        self._readonly = readonly
422
423    def flags(self, index):
424        v = super(_ChangesetsModel, self).flags(index)
425        if index.column() == 0 and not self._readonly:
426            return Qt.ItemIsUserCheckable | v
427        else:
428            return v
429
430    def rowCount(self, parent=QModelIndex()):
431        if parent.isValid():
432            return 0  # no child
433        return len(self._revs)
434
435    def columnCount(self, parent=QModelIndex()):
436        if parent.isValid():
437            return 0  # no child
438        return len(self._COLUMNS)
439
440    def headerData(self, section, orientation, role):
441        if role != Qt.DisplayRole or orientation != Qt.Horizontal:
442            return QVariant()
443
444        return QVariant(self._COLUMNS[section][0].capitalize())
445
446def run(ui, *revs, **opts):
447    # TODO: same options as patchbomb
448    if opts.get('rev'):
449        if revs:
450            raise util.Abort(_('use only one form to specify the revision'))
451        revs = opts.get('rev')
452
453    # TODO: repo should be a required argument?
454    repo = opts.get('repo') or thgrepo.repository(ui, paths.find_root())
455
456    try:
457        return EmailDialog(repo, revs, outgoing=opts.get('outgoing', False),
458                           outgoingrevs=opts.get('outgoingrevs', None))
459    except error.RepoLookupError, e:
460        qtlib.ErrorMsgBox(_('Failed to open Email dialog'),
461                          hglib.tounicode(e.message))