/tortoisehg/hgqt/hgemail.py

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