core: honor core.commentChar in commit messages and rebase todos
[git-cola.git] / cola / widgets / commitmsg.py
blobd2dbfa6e4c93789c665969f1f7d97b3a24b8b514
1 from __future__ import division, absolute_import, unicode_literals
2 import re
4 from PyQt4 import QtGui
5 from PyQt4 import QtCore
6 from PyQt4.QtCore import Qt
7 from PyQt4.QtCore import SIGNAL
9 from cola import actions
10 from cola import cmds
11 from cola import core
12 from cola import gitcmds
13 from cola import gitcfg
14 from cola import textwrap
15 from cola import qtutils
16 from cola.cmds import Interaction
17 from cola.gitcmds import commit_message_path
18 from cola.i18n import N_
19 from cola.models import dag
20 from cola.models import prefs
21 from cola.models import selection
22 from cola.widgets import defs
23 from cola.widgets.selectcommits import select_commits
24 from cola.widgets.spellcheck import SpellCheckTextEdit
25 from cola.widgets.text import HintedLineEdit
26 from cola.compat import ustr
29 class CommitMessageEditor(QtGui.QWidget):
30 def __init__(self, model, parent):
31 QtGui.QWidget.__init__(self, parent)
33 self.model = model
34 self.spellcheck_initialized = False
36 self._linebreak = None
37 self._textwidth = None
38 self._tabwidth = None
40 # Actions
41 self.signoff_action = qtutils.add_action(self, cmds.SignOff.name(),
42 cmds.run(cmds.SignOff),
43 cmds.SignOff.SHORTCUT)
44 self.signoff_action.setToolTip(N_('Sign off on this commit'))
46 self.commit_action = qtutils.add_action(self,
47 N_('Commit@@verb'),
48 self.commit,
49 cmds.Commit.SHORTCUT)
50 self.commit_action.setToolTip(N_('Commit staged changes'))
51 self.clear_action = qtutils.add_action(self, N_('Clear...'), self.clear)
53 self.launch_editor = actions.launch_editor(self)
54 self.launch_difftool = actions.launch_difftool(self)
55 self.stage_or_unstage = actions.stage_or_unstage(self)
57 self.move_up = actions.move_up(self)
58 self.move_down = actions.move_down(self)
60 # Widgets
61 self.summary = CommitSummaryLineEdit()
62 self.summary.setMinimumHeight(defs.tool_button_height)
63 self.summary.extra_actions.append(self.clear_action)
64 self.summary.extra_actions.append(None)
65 self.summary.extra_actions.append(self.signoff_action)
66 self.summary.extra_actions.append(self.commit_action)
67 self.summary.extra_actions.append(None)
68 self.summary.extra_actions.append(self.launch_editor)
69 self.summary.extra_actions.append(self.launch_difftool)
70 self.summary.extra_actions.append(self.stage_or_unstage)
71 self.summary.extra_actions.append(None)
72 self.summary.extra_actions.append(self.move_up)
73 self.summary.extra_actions.append(self.move_down)
75 self.description = CommitMessageTextEdit()
76 self.description.extra_actions.append(self.clear_action)
77 self.description.extra_actions.append(None)
78 self.description.extra_actions.append(self.signoff_action)
79 self.description.extra_actions.append(self.commit_action)
80 self.description.extra_actions.append(None)
81 self.description.extra_actions.append(self.launch_editor)
82 self.description.extra_actions.append(self.launch_difftool)
83 self.description.extra_actions.append(self.stage_or_unstage)
84 self.description.extra_actions.append(None)
85 self.description.extra_actions.append(self.move_up)
86 self.description.extra_actions.append(self.move_down)
88 commit_button_tooltip = N_('Commit staged changes\n'
89 'Shortcut: Ctrl+Enter')
90 self.commit_button = qtutils.create_toolbutton(
91 text=N_('Commit@@verb'), tooltip=commit_button_tooltip,
92 icon=qtutils.save_icon())
94 self.actions_menu = QtGui.QMenu()
95 self.actions_button = qtutils.create_toolbutton(
96 icon=qtutils.options_icon(), tooltip=N_('Actions...'))
97 self.actions_button.setMenu(self.actions_menu)
98 self.actions_button.setPopupMode(QtGui.QToolButton.InstantPopup)
99 qtutils.hide_button_menu_indicator(self.actions_button)
101 self.actions_menu.addAction(self.signoff_action)
102 self.actions_menu.addAction(self.commit_action)
103 self.actions_menu.addSeparator()
105 # Amend checkbox
106 self.amend_action = self.actions_menu.addAction(
107 N_('Amend Last Commit'))
108 self.amend_action.setCheckable(True)
109 self.amend_action.setShortcut(cmds.AmendMode.SHORTCUT)
110 self.amend_action.setShortcutContext(Qt.ApplicationShortcut)
112 # Bypass hooks
113 self.bypass_commit_hooks_action = self.actions_menu.addAction(
114 N_('Bypass Commit Hooks'))
115 self.bypass_commit_hooks_action.setCheckable(True)
116 self.bypass_commit_hooks_action.setChecked(False)
118 # Sign commits
119 cfg = gitcfg.current()
120 self.sign_action = self.actions_menu.addAction(
121 N_('Create Signed Commit'))
122 self.sign_action.setCheckable(True)
123 self.sign_action.setChecked(cfg.get('cola.signcommits', False))
125 # Spell checker
126 self.check_spelling_action = self.actions_menu.addAction(
127 N_('Check Spelling'))
128 self.check_spelling_action.setCheckable(True)
129 self.check_spelling_action.setChecked(False)
131 # Line wrapping
132 self.autowrap_action = self.actions_menu.addAction(
133 N_('Auto-Wrap Lines'))
134 self.autowrap_action.setCheckable(True)
135 self.autowrap_action.setChecked(prefs.linebreak())
137 # Commit message
138 self.actions_menu.addSeparator()
139 self.load_commitmsg_menu = self.actions_menu.addMenu(
140 N_('Load Previous Commit Message'))
141 self.connect(self.load_commitmsg_menu, SIGNAL('aboutToShow()'),
142 self.build_commitmsg_menu)
144 self.fixup_commit_menu = self.actions_menu.addMenu(
145 N_('Fixup Previous Commit'))
146 self.connect(self.fixup_commit_menu, SIGNAL('aboutToShow()'),
147 self.build_fixup_menu)
149 self.toplayout = qtutils.hbox(defs.no_margin, defs.spacing,
150 self.actions_button, self.summary,
151 self.commit_button)
152 self.toplayout.setContentsMargins(defs.margin, defs.no_margin,
153 defs.no_margin, defs.no_margin)
155 self.mainlayout = qtutils.vbox(defs.no_margin, defs.spacing,
156 self.toplayout, self.description)
157 self.setLayout(self.mainlayout)
159 qtutils.connect_button(self.commit_button, self.commit)
161 # Broadcast the amend mode
162 qtutils.connect_action_bool(self.amend_action, cmds.run(cmds.AmendMode))
163 qtutils.connect_action_bool(self.check_spelling_action,
164 self.toggle_check_spelling)
166 # Handle the one-off autowrapping
167 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
169 qtutils.add_action(self.summary, N_('Move Down'),
170 self.focus_description,
171 Qt.Key_Return, Qt.Key_Enter)
173 qtutils.add_action(self.summary, N_('Move Down'),
174 self.summary_cursor_down,
175 Qt.Key_Down)
177 self.selection_model = selection_model = selection.selection_model()
178 selection_model.add_observer(selection_model.message_selection_changed,
179 self._update)
181 self.model.add_observer(self.model.message_commit_message_changed,
182 self._set_commit_message)
184 self.connect(self, SIGNAL('set_commit_message(PyQt_PyObject)'),
185 self.set_commit_message, Qt.QueuedConnection)
187 self.connect(self.summary, SIGNAL('cursorPosition(int,int)'),
188 self.emit_position)
190 self.connect(self.description, SIGNAL('cursorPosition(int,int)'),
191 # description starts at line 2
192 lambda row, col: self.emit_position(row + 2, col))
194 # Keep model informed of changes
195 self.connect(self.summary, SIGNAL('textChanged(QString)'),
196 self.commit_summary_changed)
198 self.connect(self.description, SIGNAL('textChanged()'),
199 self.commit_message_changed)
201 self.connect(self.description, SIGNAL('leave()'),
202 self.focus_summary)
204 self.connect(self, SIGNAL('update()'),
205 self._update_callback, Qt.QueuedConnection)
207 self.setFont(qtutils.diff_font())
209 self.summary.hint.enable(True)
210 self.description.hint.enable(True)
212 self.commit_button.setEnabled(False)
213 self.commit_action.setEnabled(False)
215 self.setFocusProxy(self.summary)
217 self.set_tabwidth(prefs.tabwidth())
218 self.set_textwidth(prefs.textwidth())
219 self.set_linebreak(prefs.linebreak())
221 # Loading message
222 commit_msg = ''
223 commit_msg_path = commit_message_path()
224 if commit_msg_path:
225 commit_msg = core.read(commit_msg_path)
226 self.set_commit_message(commit_msg)
228 # Allow tab to jump from the summary to the description
229 self.setTabOrder(self.summary, self.description)
231 def _update(self):
232 self.emit(SIGNAL('update()'))
234 def _update_callback(self):
235 enabled = self.model.stageable() or self.model.unstageable()
236 if self.model.stageable():
237 text = N_('Stage')
238 else:
239 text = N_('Unstage')
240 self.stage_or_unstage.setEnabled(enabled)
241 self.stage_or_unstage.setText(text)
243 def set_initial_size(self):
244 self.setMaximumHeight(133)
245 QtCore.QTimer.singleShot(1, self.restore_size)
247 def restore_size(self):
248 self.setMaximumHeight(2 ** 13)
250 def focus_summary(self):
251 self.summary.setFocus()
253 def focus_description(self):
254 self.description.setFocus()
256 def summary_cursor_down(self):
257 """Handle the down key in the summary field
259 If the cursor is at the end of the line then focus the description.
260 Otherwise, move the cursor to the end of the line so that a
261 subsequence "down" press moves to the end of the line.
264 cur_position = self.summary.cursorPosition()
265 end_position = len(self.summary.value())
266 if cur_position == end_position:
267 self.focus_description()
268 else:
269 self.summary.setCursorPosition(end_position)
271 def commit_message(self, raw=True):
272 """Return the commit message as a unicode string"""
273 summary = self.summary.value()
274 if raw:
275 description = self.description.value()
276 else:
277 description = self.formatted_description()
278 if summary and description:
279 return summary + '\n\n' + description
280 elif summary:
281 return summary
282 elif description:
283 return '\n\n' + description
284 else:
285 return ''
287 def formatted_description(self):
288 text = self.description.value()
289 if not self._linebreak:
290 return text
291 return textwrap.word_wrap(text, self._tabwidth, self._textwidth)
293 def commit_summary_changed(self, value):
294 """Respond to changes to the `summary` field
296 Newlines can enter the `summary` field when pasting, which is
297 undesirable. Break the pasted value apart into the separate
298 (summary, description) values and move the description over to the
299 "extended description" field.
302 value = ustr(value)
303 if '\n' in value:
304 summary, description = value.split('\n', 1)
305 description = description.lstrip('\n')
306 cur_description = self.description.value()
307 if cur_description:
308 description = description + '\n' + cur_description
309 # this callback is triggered by changing `summary`
310 # so disable signals for `summary` only.
311 self.summary.set_value(summary, block=True)
312 self.description.set_value(description)
313 self.commit_message_changed()
315 def commit_message_changed(self, value=None):
316 """Update the model when values change"""
317 message = self.commit_message()
318 self.model.set_commitmsg(message, notify=False)
319 self.refresh_palettes()
320 self.update_actions()
322 def clear(self):
323 if not qtutils.confirm(
324 N_('Clear commit message?'),
325 N_('The commit message will be cleared.'),
326 N_('This cannot be undone. Clear commit message?'),
327 N_('Clear commit message'),
328 default=True,
329 icon=qtutils.discard_icon()):
330 return
331 self.model.set_commitmsg('')
333 def update_actions(self):
334 commit_enabled = bool(self.summary.value())
335 self.commit_button.setEnabled(commit_enabled)
336 self.commit_action.setEnabled(commit_enabled)
338 def refresh_palettes(self):
339 """Update the color palette for the hint text"""
340 self.summary.hint.refresh()
341 self.description.hint.refresh()
343 def _set_commit_message(self, message):
344 self.emit(SIGNAL('set_commit_message(PyQt_PyObject)'), message)
346 def set_commit_message(self, message):
347 """Set the commit message to match the observed model"""
348 # Parse the "summary" and "description" fields
349 umsg = ustr(message)
350 lines = umsg.splitlines()
352 num_lines = len(lines)
354 if num_lines == 0:
355 # Message is empty
356 summary = ''
357 description = ''
359 elif num_lines == 1:
360 # Message has a summary only
361 summary = lines[0]
362 description = ''
364 elif num_lines == 2:
365 # Message has two lines; this is not a common case
366 summary = lines[0]
367 description = lines[1]
369 else:
370 # Summary and several description lines
371 summary = lines[0]
372 if lines[1]:
373 # We usually skip this line but check just in case
374 description_lines = lines[1:]
375 else:
376 description_lines = lines[2:]
377 description = '\n'.join(description_lines)
379 focus_summary = not summary
380 focus_description = not description
382 # Update summary
383 if not summary and not self.summary.hasFocus():
384 self.summary.hint.enable(True)
385 else:
386 self.summary.set_value(summary, block=True)
388 # Update description
389 if not description and not self.description.hasFocus():
390 self.description.hint.enable(True)
391 else:
392 self.description.set_value(description, block=True)
394 # Update text color
395 self.refresh_palettes()
397 # Focus the empty summary or description
398 if focus_summary:
399 self.summary.setFocus()
400 elif focus_description:
401 self.description.setFocus()
402 else:
403 self.summary.cursor_position.emit()
405 self.update_actions()
407 def set_tabwidth(self, width):
408 self._tabwidth = width
409 self.description.set_tabwidth(width)
411 def set_textwidth(self, width):
412 self._textwidth = width
413 self.description.set_textwidth(width)
415 def set_linebreak(self, brk):
416 self._linebreak = brk
417 self.description.set_linebreak(brk)
418 blocksignals = self.autowrap_action.blockSignals(True)
419 self.autowrap_action.setChecked(brk)
420 self.autowrap_action.blockSignals(blocksignals)
422 def setFont(self, font):
423 """Pass the setFont() calls down to the text widgets"""
424 self.summary.setFont(font)
425 self.description.setFont(font)
427 def set_mode(self, mode):
428 can_amend = not self.model.is_merging
429 checked = (mode == self.model.mode_amend)
430 blocksignals = self.amend_action.blockSignals(True)
431 self.amend_action.setEnabled(can_amend)
432 self.amend_action.setChecked(checked)
433 self.amend_action.blockSignals(blocksignals)
435 def emit_position(self, row, col):
436 self.emit(SIGNAL('cursorPosition(int,int)'), row, col)
438 def commit(self):
439 """Attempt to create a commit from the index and commit message."""
440 if not bool(self.summary.value()):
441 # Describe a good commit message
442 error_msg = N_(''
443 'Please supply a commit message.\n\n'
444 'A good commit message has the following format:\n\n'
445 '- First line: Describe in one sentence what you did.\n'
446 '- Second line: Blank\n'
447 '- Remaining lines: Describe why this change is good.\n')
448 Interaction.log(error_msg)
449 Interaction.information(N_('Missing Commit Message'), error_msg)
450 return
452 msg = self.commit_message(raw=False)
454 if not self.model.staged:
455 error_msg = N_(''
456 'No changes to commit.\n\n'
457 'You must stage at least 1 file before you can commit.')
458 if self.model.modified:
459 informative_text = N_('Would you like to stage and '
460 'commit all modified files?')
461 if not qtutils.confirm(
462 N_('Stage and commit?'),
463 error_msg, informative_text,
464 N_('Stage and Commit'),
465 default=True,
466 icon=qtutils.save_icon()):
467 return
468 else:
469 Interaction.information(N_('Nothing to commit'), error_msg)
470 return
471 cmds.do(cmds.StageModified)
473 # Warn that amending published commits is generally bad
474 amend = self.amend_action.isChecked()
475 if (amend and self.model.is_commit_published() and
476 not qtutils.confirm(
477 N_('Rewrite Published Commit?'),
478 N_('This commit has already been published.\n'
479 'This operation will rewrite published history.\n'
480 'You probably don\'t want to do this.'),
481 N_('Amend the published commit?'),
482 N_('Amend Commit'),
483 default=False, icon=qtutils.save_icon())):
484 return
485 no_verify = self.bypass_commit_hooks_action.isChecked()
486 sign = self.sign_action.isChecked()
487 status, out, err = cmds.do(cmds.Commit, amend, msg, sign,
488 no_verify=no_verify)
489 if status != 0:
490 Interaction.critical(N_('Commit failed'),
491 N_('"git commit" returned exit code %s') %
492 (status,),
493 out + err)
495 def build_fixup_menu(self):
496 self.build_commits_menu(cmds.LoadFixupMessage,
497 self.fixup_commit_menu,
498 self.choose_fixup_commit,
499 prefix='fixup! ')
501 def build_commitmsg_menu(self):
502 self.build_commits_menu(cmds.LoadCommitMessageFromSHA1,
503 self.load_commitmsg_menu,
504 self.choose_commit_message)
506 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
507 ctx = dag.DAG('HEAD', 6)
508 commits = dag.RepoReader(ctx)
510 menu_commits = []
511 for idx, c in enumerate(commits):
512 menu_commits.insert(0, c)
513 if idx > 5:
514 continue
516 menu.clear()
517 for c in menu_commits:
518 menu.addAction(prefix + c.summary, cmds.run(cmd, c.sha1))
520 if len(commits) == 6:
521 menu.addSeparator()
522 menu.addAction(N_('More...'), chooser)
525 def choose_commit(self, cmd):
526 revs, summaries = gitcmds.log_helper()
527 sha1s = select_commits(N_('Select Commit'), revs, summaries,
528 multiselect=False)
529 if not sha1s:
530 return
531 sha1 = sha1s[0]
532 cmds.do(cmd, sha1)
534 def choose_commit_message(self):
535 self.choose_commit(cmds.LoadCommitMessageFromSHA1)
537 def choose_fixup_commit(self):
538 self.choose_commit(cmds.LoadFixupMessage)
540 def toggle_check_spelling(self, enabled):
541 spellcheck = self.description.spellcheck
543 if enabled and not self.spellcheck_initialized:
544 # Add our name to the dictionary
545 self.spellcheck_initialized = True
546 cfg = gitcfg.current()
547 user_name = cfg.get('user.name')
548 if user_name:
549 for part in user_name.split():
550 spellcheck.add_word(part)
552 # Add our email address to the dictionary
553 user_email = cfg.get('user.email')
554 if user_email:
555 for part in user_email.split('@'):
556 for elt in part.split('.'):
557 spellcheck.add_word(elt)
559 # git jargon
560 spellcheck.add_word('Acked')
561 spellcheck.add_word('Signed')
562 spellcheck.add_word('Closes')
563 spellcheck.add_word('Fixes')
565 self.description.highlighter.enable(enabled)
568 class CommitSummaryLineEdit(HintedLineEdit):
570 def __init__(self, parent=None):
571 hint = N_('Commit summary')
572 HintedLineEdit.__init__(self, hint, parent)
573 self.extra_actions = []
575 comment_char = prefs.comment_char()
576 re_comment_char = re.escape(comment_char)
577 regex = QtCore.QRegExp(r'^[^%s \t].*' % re_comment_char)
578 self._validator = QtGui.QRegExpValidator(regex, self)
579 self.setValidator(self._validator)
581 def contextMenuEvent(self, event):
582 menu = self.createStandardContextMenu()
583 if self.extra_actions:
584 menu.addSeparator()
585 for action in self.extra_actions:
586 if action is None:
587 menu.addSeparator()
588 else:
589 menu.addAction(action)
590 menu.exec_(self.mapToGlobal(event.pos()))
593 class CommitMessageTextEdit(SpellCheckTextEdit):
595 def __init__(self, parent=None):
596 hint = N_('Extended description...')
597 SpellCheckTextEdit.__init__(self, hint, parent)
598 self.extra_actions = []
600 self.action_emit_leave = qtutils.add_action(self,
601 'Shift Tab', self.emit_leave, 'Shift+Tab')
603 def contextMenuEvent(self, event):
604 menu, spell_menu = self.context_menu()
605 if self.extra_actions:
606 menu.addSeparator()
607 for action in self.extra_actions:
608 if action is None:
609 menu.addSeparator()
610 else:
611 menu.addAction(action)
612 menu.exec_(self.mapToGlobal(event.pos()))
614 def keyPressEvent(self, event):
615 if event.key() == Qt.Key_Up:
616 cursor = self.textCursor()
617 position = cursor.position()
618 if position == 0:
619 # The cursor is at the beginning of the line.
620 # If we have selection then simply reset the cursor.
621 # Otherwise, emit a signal so that the parent can
622 # change focus.
623 if cursor.hasSelection():
624 cursor.setPosition(0)
625 self.setTextCursor(cursor)
626 else:
627 self.emit_leave()
628 event.accept()
629 return
630 text_before = ustr(self.toPlainText())[:position]
631 lines_before = text_before.count('\n')
632 if lines_before == 0:
633 # If we're on the first line, but not at the
634 # beginning, then move the cursor to the beginning
635 # of the line.
636 if event.modifiers() & Qt.ShiftModifier:
637 mode = QtGui.QTextCursor.KeepAnchor
638 else:
639 mode = QtGui.QTextCursor.MoveAnchor
640 cursor.setPosition(0, mode)
641 self.setTextCursor(cursor)
642 event.accept()
643 return
644 elif event.key() == Qt.Key_Down:
645 cursor = self.textCursor()
646 position = cursor.position()
647 all_text = ustr(self.toPlainText())
648 text_after = all_text[position:]
649 lines_after = text_after.count('\n')
650 if lines_after == 0:
651 if event.modifiers() & Qt.ShiftModifier:
652 mode = QtGui.QTextCursor.KeepAnchor
653 else:
654 mode = QtGui.QTextCursor.MoveAnchor
655 cursor.setPosition(len(all_text), mode)
656 self.setTextCursor(cursor)
657 event.accept()
658 return
659 SpellCheckTextEdit.keyPressEvent(self, event)
661 def emit_leave(self):
662 self.emit(SIGNAL('leave()'))
664 def setFont(self, font):
665 SpellCheckTextEdit.setFont(self, font)
666 fm = self.fontMetrics()
667 self.setMinimumSize(QtCore.QSize(1, fm.height() * 2))