doc: add Thomas to the credits
[git-cola.git] / cola / widgets / commitmsg.py
blob31554c48867385daf17430fbb30c745d03c0db53
1 from __future__ import division, absolute_import, unicode_literals
2 from functools import partial
4 from qtpy import QtCore
5 from qtpy import QtGui
6 from qtpy import QtWidgets
7 from qtpy.QtCore import Qt
8 from qtpy.QtCore import Signal
10 from .. import actions
11 from .. import cmds
12 from .. import core
13 from .. import gitcmds
14 from .. import hotkeys
15 from .. import icons
16 from .. import textwrap
17 from .. import qtutils
18 from ..interaction import Interaction
19 from ..gitcmds import commit_message_path
20 from ..i18n import N_
21 from ..models import dag
22 from ..models import prefs
23 from ..qtutils import get
24 from ..utils import Group
25 from . import defs
26 from .selectcommits import select_commits
27 from .spellcheck import SpellCheckTextEdit
28 from .text import HintedLineEdit
31 class CommitMessageEditor(QtWidgets.QWidget):
32 commit_message_changed = Signal(object)
33 cursor_changed = Signal(int, int)
34 down = Signal()
35 up = Signal()
36 updated = Signal()
38 def __init__(self, context, parent):
39 QtWidgets.QWidget.__init__(self, parent)
40 self.context = context
41 self.model = model = context.model
42 self.spellcheck_initialized = False
44 self._linebreak = None
45 self._textwidth = None
46 self._tabwidth = None
48 # Actions
49 self.signoff_action = qtutils.add_action(
50 self, cmds.SignOff.name(), cmds.run(cmds.SignOff, context),
51 hotkeys.SIGNOFF)
52 self.signoff_action.setToolTip(N_('Sign off on this commit'))
54 self.commit_action = qtutils.add_action(
55 self, N_('Commit@@verb'), self.commit, hotkeys.COMMIT)
56 self.commit_action.setIcon(icons.commit())
57 self.commit_action.setToolTip(N_('Commit staged changes'))
58 self.clear_action = qtutils.add_action(
59 self, N_('Clear...'), self.clear)
61 self.launch_editor = actions.launch_editor(context, self)
62 self.launch_difftool = actions.launch_difftool(context, self)
63 self.stage_or_unstage = actions.stage_or_unstage(context, self)
65 self.move_up = actions.move_up(self)
66 self.move_down = actions.move_down(self)
68 # Menu acctions
69 self.menu_actions = menu_actions = [
70 None,
71 self.signoff_action,
72 self.commit_action,
73 None,
74 self.launch_editor,
75 self.launch_difftool,
76 self.stage_or_unstage,
77 None,
78 self.move_up,
79 self.move_down,
82 # Widgets
83 self.summary = CommitSummaryLineEdit(context)
84 self.summary.setMinimumHeight(defs.tool_button_height)
85 self.summary.menu_actions.extend(menu_actions)
87 cfg = context.cfg
88 self.summary_validator = MessageValidator(context, parent=self.summary)
89 self.summary.setValidator(self.summary_validator)
91 self.description = CommitMessageTextEdit(context, parent=self)
92 self.description.set_dictionary(cfg.get('cola.dictionary', None))
93 self.description.menu_actions.extend(menu_actions)
95 commit_button_tooltip = N_('Commit staged changes\n'
96 'Shortcut: Ctrl+Enter')
97 self.commit_button = qtutils.create_toolbutton(
98 text=N_('Commit@@verb'), tooltip=commit_button_tooltip,
99 icon=icons.commit())
100 self.commit_group = Group(self.commit_action, self.commit_button)
102 self.actions_menu = qtutils.create_menu(N_('Actions'), self)
103 self.actions_button = qtutils.create_toolbutton(
104 icon=icons.configure(), tooltip=N_('Actions...'))
105 self.actions_button.setMenu(self.actions_menu)
106 self.actions_button.setPopupMode(QtWidgets.QToolButton.InstantPopup)
107 qtutils.hide_button_menu_indicator(self.actions_button)
109 self.actions_menu.addAction(self.signoff_action)
110 self.actions_menu.addAction(self.commit_action)
111 self.actions_menu.addSeparator()
113 # Amend checkbox
114 self.amend_action = self.actions_menu.addAction(
115 N_('Amend Last Commit'))
116 self.amend_action.setCheckable(True)
117 self.amend_action.setShortcut(hotkeys.AMEND)
118 self.amend_action.setShortcutContext(Qt.ApplicationShortcut)
120 # Bypass hooks
121 self.bypass_commit_hooks_action = self.actions_menu.addAction(
122 N_('Bypass Commit Hooks'))
123 self.bypass_commit_hooks_action.setCheckable(True)
124 self.bypass_commit_hooks_action.setChecked(False)
126 # Sign commits
127 self.sign_action = self.actions_menu.addAction(
128 N_('Create Signed Commit'))
129 self.sign_action.setCheckable(True)
130 signcommits = cfg.get('cola.signcommits', default=False)
131 self.sign_action.setChecked(signcommits)
133 # Spell checker
134 self.check_spelling_action = self.actions_menu.addAction(
135 N_('Check Spelling'))
136 self.check_spelling_action.setCheckable(True)
137 spellcheck = prefs.spellcheck(context)
138 self.check_spelling_action.setChecked(spellcheck)
139 self.toggle_check_spelling(spellcheck)
141 # Line wrapping
142 self.autowrap_action = self.actions_menu.addAction(
143 N_('Auto-Wrap Lines'))
144 self.autowrap_action.setCheckable(True)
145 self.autowrap_action.setChecked(prefs.linebreak(context))
147 # Commit message
148 self.actions_menu.addSeparator()
149 self.load_commitmsg_menu = self.actions_menu.addMenu(
150 N_('Load Previous Commit Message'))
151 self.load_commitmsg_menu.aboutToShow.connect(self.build_commitmsg_menu)
153 self.fixup_commit_menu = self.actions_menu.addMenu(
154 N_('Fixup Previous Commit'))
155 self.fixup_commit_menu.aboutToShow.connect(self.build_fixup_menu)
157 self.toplayout = qtutils.hbox(defs.no_margin, defs.spacing,
158 self.actions_button, self.summary,
159 self.commit_button)
160 self.toplayout.setContentsMargins(defs.margin, defs.no_margin,
161 defs.no_margin, defs.no_margin)
163 self.mainlayout = qtutils.vbox(defs.no_margin, defs.spacing,
164 self.toplayout, self.description)
165 self.setLayout(self.mainlayout)
167 qtutils.connect_button(self.commit_button, self.commit)
169 # Broadcast the amend mode
170 qtutils.connect_action_bool(
171 self.amend_action, partial(cmds.run(cmds.AmendMode), context))
172 qtutils.connect_action_bool(self.check_spelling_action,
173 self.toggle_check_spelling)
175 # Handle the one-off autowrapping
176 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
178 qtutils.add_action(self.summary, N_('Move Down'),
179 self.focus_description, *hotkeys.ACCEPT)
181 qtutils.add_action(self.summary, N_('Move Down'),
182 self.summary_cursor_down, hotkeys.DOWN)
184 self.selection = selection = context.selection
185 selection.add_observer(
186 selection.message_selection_changed, self.updated.emit)
188 self.model.add_observer(self.model.message_commit_message_changed,
189 self.commit_message_changed.emit)
191 self.commit_message_changed.connect(self.set_commit_message,
192 type=Qt.QueuedConnection)
194 self.summary.cursor_changed.connect(self.cursor_changed.emit)
195 self.description.cursor_changed.connect(
196 # description starts at line 2
197 lambda row, col: self.cursor_changed.emit(row + 2, col))
199 self.summary.textChanged.connect(self.commit_summary_changed)
200 self.description.textChanged.connect(self._commit_message_changed)
201 self.description.leave.connect(self.focus_summary)
202 self.updated.connect(self.refresh)
204 self.commit_group.setEnabled(False)
206 self.set_expandtab(prefs.expandtab(context))
207 self.set_tabwidth(prefs.tabwidth(context))
208 self.set_textwidth(prefs.textwidth(context))
209 self.set_linebreak(prefs.linebreak(context))
211 # Loading message
212 commit_msg = ''
213 commit_msg_path = commit_message_path(context)
214 if commit_msg_path:
215 commit_msg = core.read(commit_msg_path)
216 model.set_commitmsg(commit_msg)
218 # Allow tab to jump from the summary to the description
219 self.setTabOrder(self.summary, self.description)
220 self.setFont(qtutils.diff_font(context))
221 self.setFocusProxy(self.summary)
223 cfg.add_observer(cfg.message_user_config_changed, self.config_changed)
225 def config_changed(self, key, value):
226 if key != prefs.SPELL_CHECK:
227 return
228 if get(self.check_spelling_action) == value:
229 return
230 self.check_spelling_action.setChecked(value)
231 self.toggle_check_spelling(value)
233 def refresh(self):
234 enabled = self.model.stageable() or self.model.unstageable()
235 if self.model.stageable():
236 text = N_('Stage')
237 else:
238 text = N_('Unstage')
239 self.stage_or_unstage.setEnabled(enabled)
240 self.stage_or_unstage.setText(text)
242 def set_initial_size(self):
243 self.setMaximumHeight(133)
244 QtCore.QTimer.singleShot(1, self.restore_size)
246 def restore_size(self):
247 self.setMaximumHeight(2 ** 13)
249 def focus_summary(self):
250 self.summary.setFocus()
252 def focus_description(self):
253 self.description.setFocus()
255 def summary_cursor_down(self):
256 """Handle the down key in the summary field
258 If the cursor is at the end of the line then focus the description.
259 Otherwise, move the cursor to the end of the line so that a
260 subsequence "down" press moves to the end of the line.
263 cur_position = self.summary.cursorPosition()
264 end_position = len(get(self.summary))
265 if cur_position == end_position:
266 self.focus_description()
267 else:
268 self.summary.setCursorPosition(end_position)
270 def commit_message(self, raw=True):
271 """Return the commit message as a unicode string"""
272 summary = get(self.summary)
273 if raw:
274 description = get(self.description)
275 else:
276 description = self.formatted_description()
277 if summary and description:
278 return summary + '\n\n' + description
279 if summary:
280 return summary
281 if description:
282 return '\n\n' + description
283 return ''
285 def formatted_description(self):
286 text = get(self.description)
287 if not self._linebreak:
288 return text
289 return textwrap.word_wrap(text, self._tabwidth, self._textwidth)
291 def commit_summary_changed(self, value):
292 """Respond to changes to the `summary` field
294 Newlines can enter the `summary` field when pasting, which is
295 undesirable. Break the pasted value apart into the separate
296 (summary, description) values and move the description over to the
297 "extended description" field.
300 if '\n' in value:
301 summary, description = value.split('\n', 1)
302 description = description.lstrip('\n')
303 cur_description = get(self.description)
304 if cur_description:
305 description = description + '\n' + cur_description
306 # this callback is triggered by changing `summary`
307 # so disable signals for `summary` only.
308 self.summary.set_value(summary, block=True)
309 self.description.set_value(description)
310 self._commit_message_changed()
312 def _commit_message_changed(self, _value=None):
313 """Update the model when values change"""
314 message = self.commit_message()
315 self.model.set_commitmsg(message, notify=False)
316 self.refresh_palettes()
317 self.update_actions()
319 def clear(self):
320 if not Interaction.confirm(
321 N_('Clear commit message?'),
322 N_('The commit message will be cleared.'),
323 N_('This cannot be undone. Clear commit message?'),
324 N_('Clear commit message'), default=True, icon=icons.discard()):
325 return
326 self.model.set_commitmsg('')
328 def update_actions(self):
329 commit_enabled = bool(get(self.summary))
330 self.commit_group.setEnabled(commit_enabled)
332 def refresh_palettes(self):
333 """Update the color palette for the hint text"""
334 self.summary.hint.refresh()
335 self.description.hint.refresh()
337 def set_commit_message(self, message):
338 """Set the commit message to match the observed model"""
339 # Parse the "summary" and "description" fields
340 lines = message.splitlines()
342 num_lines = len(lines)
344 if num_lines == 0:
345 # Message is empty
346 summary = ''
347 description = ''
349 elif num_lines == 1:
350 # Message has a summary only
351 summary = lines[0]
352 description = ''
354 elif num_lines == 2:
355 # Message has two lines; this is not a common case
356 summary = lines[0]
357 description = lines[1]
359 else:
360 # Summary and several description lines
361 summary = lines[0]
362 if lines[1]:
363 # We usually skip this line but check just in case
364 description_lines = lines[1:]
365 else:
366 description_lines = lines[2:]
367 description = '\n'.join(description_lines)
369 focus_summary = not summary
370 focus_description = not description
372 # Update summary
373 self.summary.set_value(summary, block=True)
375 # Update description
376 self.description.set_value(description, block=True)
378 # Update text color
379 self.refresh_palettes()
381 # Focus the empty summary or description
382 if focus_summary:
383 self.summary.setFocus()
384 elif focus_description:
385 self.description.setFocus()
386 else:
387 self.summary.cursor_position.emit()
389 self.update_actions()
391 def set_expandtab(self, value):
392 self.description.set_expandtab(value)
394 def set_tabwidth(self, width):
395 self._tabwidth = width
396 self.description.set_tabwidth(width)
398 def set_textwidth(self, width):
399 self._textwidth = width
400 self.description.set_textwidth(width)
402 def set_linebreak(self, brk):
403 self._linebreak = brk
404 self.description.set_linebreak(brk)
405 blocksignals = self.autowrap_action.blockSignals(True)
406 self.autowrap_action.setChecked(brk)
407 self.autowrap_action.blockSignals(blocksignals)
409 def setFont(self, font):
410 """Pass the setFont() calls down to the text widgets"""
411 self.summary.setFont(font)
412 self.description.setFont(font)
414 def set_mode(self, mode):
415 can_amend = not self.model.is_merging
416 checked = (mode == self.model.mode_amend)
417 blocksignals = self.amend_action.blockSignals(True)
418 self.amend_action.setEnabled(can_amend)
419 self.amend_action.setChecked(checked)
420 self.amend_action.blockSignals(blocksignals)
422 def commit(self):
423 """Attempt to create a commit from the index and commit message."""
424 context = self.context
425 if not bool(get(self.summary)):
426 # Describe a good commit message
427 error_msg = N_(
428 'Please supply a commit message.\n\n'
429 'A good commit message has the following format:\n\n'
430 '- First line: Describe in one sentence what you did.\n'
431 '- Second line: Blank\n'
432 '- Remaining lines: Describe why this change is good.\n')
433 Interaction.log(error_msg)
434 Interaction.information(N_('Missing Commit Message'), error_msg)
435 return
437 msg = self.commit_message(raw=False)
439 # We either need to have something staged, or be merging.
440 # If there was a merge conflict resolved, there may not be anything
441 # to stage, but we still need to commit to complete the merge.
442 if not (self.model.staged or self.model.is_merging):
443 error_msg = N_(
444 'No changes to commit.\n\n'
445 'You must stage at least 1 file before you can commit.')
446 if self.model.modified:
447 informative_text = N_('Would you like to stage and '
448 'commit all modified files?')
449 if not Interaction.confirm(
450 N_('Stage and commit?'), error_msg, informative_text,
451 N_('Stage and Commit'),
452 default=True, icon=icons.save()):
453 return
454 else:
455 Interaction.information(N_('Nothing to commit'), error_msg)
456 return
457 cmds.do(cmds.StageModified, context)
459 # Warn that amending published commits is generally bad
460 amend = get(self.amend_action)
461 if (amend and self.model.is_commit_published() and
462 not Interaction.confirm(
463 N_('Rewrite Published Commit?'),
464 N_('This commit has already been published.\n'
465 'This operation will rewrite published history.\n'
466 'You probably don\'t want to do this.'),
467 N_('Amend the published commit?'),
468 N_('Amend Commit'), default=False, icon=icons.save())):
469 return
470 no_verify = get(self.bypass_commit_hooks_action)
471 sign = get(self.sign_action)
472 cmds.do(cmds.Commit, context, amend, msg, sign, no_verify=no_verify)
473 self.bypass_commit_hooks_action.setChecked(False)
475 def build_fixup_menu(self):
476 self.build_commits_menu(cmds.LoadFixupMessage,
477 self.fixup_commit_menu,
478 self.choose_fixup_commit,
479 prefix='fixup! ')
481 def build_commitmsg_menu(self):
482 self.build_commits_menu(cmds.LoadCommitMessageFromOID,
483 self.load_commitmsg_menu,
484 self.choose_commit_message)
486 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
487 context = self.context
488 params = dag.DAG('HEAD', 6)
489 commits = dag.RepoReader(context, params)
491 menu_commits = []
492 for idx, c in enumerate(commits.get()):
493 menu_commits.insert(0, c)
494 if idx > 5:
495 continue
497 menu.clear()
498 for c in menu_commits:
499 menu.addAction(prefix + c.summary, cmds.run(cmd, context, c.oid))
501 if len(commits) == 6:
502 menu.addSeparator()
503 menu.addAction(N_('More...'), chooser)
505 def choose_commit(self, cmd):
506 context = self.context
507 revs, summaries = gitcmds.log_helper(context)
508 oids = select_commits(
509 context, N_('Select Commit'), revs, summaries, multiselect=False)
510 if not oids:
511 return
512 oid = oids[0]
513 cmds.do(cmd, context, oid)
515 def choose_commit_message(self):
516 self.choose_commit(cmds.LoadCommitMessageFromOID)
518 def choose_fixup_commit(self):
519 self.choose_commit(cmds.LoadFixupMessage)
521 def toggle_check_spelling(self, enabled):
522 spellcheck = self.description.spellcheck
523 cfg = self.context.cfg
525 if cfg.get_user(prefs.SPELL_CHECK) != enabled:
526 cfg.set_user(prefs.SPELL_CHECK, enabled)
527 if enabled and not self.spellcheck_initialized:
528 # Add our name to the dictionary
529 self.spellcheck_initialized = True
530 user_name = cfg.get('user.name')
531 if user_name:
532 for part in user_name.split():
533 spellcheck.add_word(part)
535 # Add our email address to the dictionary
536 user_email = cfg.get('user.email')
537 if user_email:
538 for part in user_email.split('@'):
539 for elt in part.split('.'):
540 spellcheck.add_word(elt)
542 # git jargon
543 spellcheck.add_word('Acked')
544 spellcheck.add_word('Signed')
545 spellcheck.add_word('Closes')
546 spellcheck.add_word('Fixes')
548 self.description.highlighter.enable(enabled)
551 class MessageValidator(QtGui.QValidator):
552 """Prevent invalid branch names"""
554 config_updated = Signal()
556 def __init__(self, context, parent=None):
557 super(MessageValidator, self).__init__(parent)
558 self.context = context
559 self._comment_char = None
560 self._cfg = cfg = context.cfg
561 self.refresh()
562 self.config_updated.connect(self.refresh, type=Qt.QueuedConnection)
563 cfg.add_observer(cfg.message_updated, self.emit_config_updated)
564 self.destroyed.connect(self.teardown)
566 def teardown(self):
567 self._cfg.remove_observer(self.emit_config_updated)
569 def emit_config_updated(self):
570 self.config_updated.emit()
572 def refresh(self):
573 """Update comment char in response to config changes"""
574 self._comment_char = prefs.comment_char(self.context)
576 def validate(self, string, idx):
577 """Scrub whitespace and validate the commit message"""
578 string = string.lstrip()
579 if string.startswith(self._comment_char):
580 state = self.Invalid
581 else:
582 state = self.Acceptable
583 return (state, string, idx)
586 class CommitSummaryLineEdit(HintedLineEdit):
588 cursor = Signal(int, int)
590 def __init__(self, context, parent=None):
591 hint = N_('Commit summary')
592 HintedLineEdit.__init__(self, context, hint, parent=parent)
593 self.menu_actions = []
595 def build_menu(self):
596 menu = self.createStandardContextMenu()
597 add_menu_actions(menu, self.menu_actions)
598 return menu
600 def contextMenuEvent(self, event):
601 menu = self.build_menu()
602 menu.exec_(self.mapToGlobal(event.pos()))
605 class CommitMessageTextEdit(SpellCheckTextEdit):
606 leave = Signal()
608 def __init__(self, context, parent=None):
609 hint = N_('Extended description...')
610 SpellCheckTextEdit.__init__(self, context, hint, parent)
611 self.menu_actions = []
613 self.action_emit_leave = qtutils.add_action(
614 self, 'Shift Tab', self.leave.emit, hotkeys.LEAVE)
616 def build_menu(self):
617 menu, _ = self.context_menu()
618 add_menu_actions(menu, self.menu_actions)
619 return menu
621 def contextMenuEvent(self, event):
622 menu = self.build_menu()
623 menu.exec_(self.mapToGlobal(event.pos()))
625 def keyPressEvent(self, event):
626 if event.key() == Qt.Key_Up:
627 cursor = self.textCursor()
628 position = cursor.position()
629 if position == 0:
630 # The cursor is at the beginning of the line.
631 # If we have selection then simply reset the cursor.
632 # Otherwise, emit a signal so that the parent can
633 # change focus.
634 if cursor.hasSelection():
635 cursor.setPosition(0)
636 self.setTextCursor(cursor)
637 else:
638 self.leave.emit()
639 event.accept()
640 return
641 text_before = self.toPlainText()[:position]
642 lines_before = text_before.count('\n')
643 if lines_before == 0:
644 # If we're on the first line, but not at the
645 # beginning, then move the cursor to the beginning
646 # of the line.
647 if event.modifiers() & Qt.ShiftModifier:
648 mode = QtGui.QTextCursor.KeepAnchor
649 else:
650 mode = QtGui.QTextCursor.MoveAnchor
651 cursor.setPosition(0, mode)
652 self.setTextCursor(cursor)
653 event.accept()
654 return
655 elif event.key() == Qt.Key_Down:
656 cursor = self.textCursor()
657 position = cursor.position()
658 all_text = self.toPlainText()
659 text_after = all_text[position:]
660 lines_after = text_after.count('\n')
661 if lines_after == 0:
662 if event.modifiers() & Qt.ShiftModifier:
663 mode = QtGui.QTextCursor.KeepAnchor
664 else:
665 mode = QtGui.QTextCursor.MoveAnchor
666 cursor.setPosition(len(all_text), mode)
667 self.setTextCursor(cursor)
668 event.accept()
669 return
670 SpellCheckTextEdit.keyPressEvent(self, event)
672 def setFont(self, font):
673 SpellCheckTextEdit.setFont(self, font)
674 fm = self.fontMetrics()
675 self.setMinimumSize(QtCore.QSize(1, fm.height() * 2))
678 def add_menu_actions(menu, menu_actions):
679 """Add actions to a menu, treating None as a separator"""
680 for action in menu_actions:
681 if action is None:
682 menu.addSeparator()
683 else:
684 menu.addAction(action)