Merge pull request #1209 from living180/tmp_filename
[git-cola.git] / cola / widgets / commitmsg.py
blobbf94a8993979cdf1fd78c42d3c5ec77c9bedb2e4
1 from __future__ import absolute_import, division, print_function, 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.QFrame):
32 cursor_changed = Signal(int, int)
33 down = Signal()
34 up = Signal()
36 def __init__(self, context, parent):
37 QtWidgets.QFrame.__init__(self, parent)
38 self.context = context
39 self.model = model = context.model
40 self.spellcheck_initialized = False
42 self._linebreak = None
43 self._textwidth = None
44 self._tabwidth = None
46 # Actions
47 self.signoff_action = qtutils.add_action(
48 self, cmds.SignOff.name(), cmds.run(cmds.SignOff, context), hotkeys.SIGNOFF
50 self.signoff_action.setIcon(icons.style_dialog_apply())
51 self.signoff_action.setToolTip(N_('Sign off on this commit'))
53 self.commit_action = qtutils.add_action(
54 self, N_('Commit@@verb'), self.commit, hotkeys.APPLY
56 self.commit_action.setIcon(icons.commit())
57 self.commit_action.setToolTip(N_('Commit staged changes'))
58 self.clear_action = qtutils.add_action(self, N_('Clear...'), self.clear)
60 self.launch_editor = actions.launch_editor_at_line(context, self)
61 self.launch_difftool = actions.launch_difftool(context, self)
63 self.move_up = actions.move_up(self)
64 self.move_down = actions.move_down(self)
66 # Menu acctions
67 self.menu_actions = menu_actions = [
68 None,
69 self.signoff_action,
70 self.commit_action,
71 None,
72 self.launch_editor,
73 self.launch_difftool,
74 None,
75 self.move_up,
76 self.move_down,
79 # Widgets
80 self.summary = CommitSummaryLineEdit(context)
81 self.summary.setMinimumHeight(defs.tool_button_height)
82 self.summary.menu_actions.extend(menu_actions)
84 cfg = context.cfg
85 self.summary_validator = MessageValidator(context, parent=self.summary)
86 self.summary.setValidator(self.summary_validator)
88 self.description = CommitMessageTextEdit(context, parent=self)
89 self.description.set_dictionary(cfg.get('cola.dictionary', None))
90 self.description.menu_actions.extend(menu_actions)
92 commit_button_tooltip = N_('Commit staged changes\n' 'Shortcut: Ctrl+Enter')
93 self.commit_button = qtutils.create_button(
94 text=N_('Commit@@verb'), tooltip=commit_button_tooltip, icon=icons.commit()
96 self.commit_group = Group(self.commit_action, self.commit_button)
98 self.actions_menu = qtutils.create_menu(N_('Actions'), self)
99 self.actions_button = qtutils.create_toolbutton(
100 icon=icons.configure(), tooltip=N_('Actions...')
102 self.actions_button.setMenu(self.actions_menu)
104 self.actions_menu.addAction(self.signoff_action)
105 self.actions_menu.addAction(self.commit_action)
106 self.actions_menu.addSeparator()
108 # Amend checkbox
109 self.amend_action = self.actions_menu.addAction(N_('Amend Last Commit'))
110 self.amend_action.setIcon(icons.edit())
111 self.amend_action.setCheckable(True)
112 self.amend_action.setShortcut(hotkeys.AMEND)
113 self.amend_action.setShortcutContext(Qt.ApplicationShortcut)
115 # Bypass hooks
116 self.bypass_commit_hooks_action = self.actions_menu.addAction(
117 N_('Bypass Commit Hooks')
119 self.bypass_commit_hooks_action.setCheckable(True)
120 self.bypass_commit_hooks_action.setChecked(False)
122 # Sign commits
123 self.sign_action = self.actions_menu.addAction(N_('Create Signed Commit'))
124 self.sign_action.setCheckable(True)
125 signcommits = cfg.get('cola.signcommits', default=False)
126 self.sign_action.setChecked(signcommits)
128 # Spell checker
129 self.check_spelling_action = self.actions_menu.addAction(N_('Check Spelling'))
130 self.check_spelling_action.setCheckable(True)
131 spellcheck = prefs.spellcheck(context)
132 self.check_spelling_action.setChecked(spellcheck)
133 self.toggle_check_spelling(spellcheck)
135 # Line wrapping
136 self.autowrap_action = self.actions_menu.addAction(N_('Auto-Wrap Lines'))
137 self.autowrap_action.setCheckable(True)
138 self.autowrap_action.setChecked(prefs.linebreak(context))
140 # Commit message
141 self.actions_menu.addSeparator()
142 self.load_commitmsg_menu = self.actions_menu.addMenu(
143 N_('Load Previous Commit Message')
145 self.load_commitmsg_menu.aboutToShow.connect(self.build_commitmsg_menu)
147 self.fixup_commit_menu = self.actions_menu.addMenu(N_('Fixup Previous Commit'))
148 self.fixup_commit_menu.aboutToShow.connect(self.build_fixup_menu)
150 self.toplayout = qtutils.hbox(
151 defs.no_margin,
152 defs.spacing,
153 self.actions_button,
154 self.summary,
155 self.commit_button,
157 self.toplayout.setContentsMargins(
158 defs.margin, defs.no_margin, defs.no_margin, defs.no_margin
161 self.mainlayout = qtutils.vbox(
162 defs.no_margin, defs.spacing, self.toplayout, self.description
164 self.setLayout(self.mainlayout)
166 qtutils.connect_button(self.commit_button, self.commit)
168 # Broadcast the amend mode
169 qtutils.connect_action_bool(
170 self.amend_action, partial(cmds.run(cmds.AmendMode), context)
172 qtutils.connect_action_bool(
173 self.check_spelling_action, self.toggle_check_spelling
176 # Handle the one-off autowrapping
177 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
179 qtutils.add_action(
180 self.summary, N_('Move Down'), self.focus_description, *hotkeys.ACCEPT
183 qtutils.add_action(
184 self.summary, N_('Move Down'), self.summary_cursor_down, hotkeys.DOWN
187 self.model.commit_message_changed.connect(
188 self.set_commit_message, type=Qt.QueuedConnection
191 self.summary.cursor_changed.connect(self.cursor_changed.emit)
192 self.description.cursor_changed.connect(
193 # description starts at line 2
194 lambda row, col: self.cursor_changed.emit(row + 2, col)
197 # pylint: disable=no-member
198 self.summary.textChanged.connect(self.commit_summary_changed)
199 self.description.textChanged.connect(self._commit_message_changed)
200 self.description.leave.connect(self.focus_summary)
202 self.commit_group.setEnabled(False)
204 self.set_expandtab(prefs.expandtab(context))
205 self.set_tabwidth(prefs.tabwidth(context))
206 self.set_textwidth(prefs.textwidth(context))
207 self.set_linebreak(prefs.linebreak(context))
209 # Loading message
210 commit_msg = ''
211 commit_msg_path = commit_message_path(context)
212 if commit_msg_path:
213 commit_msg = core.read(commit_msg_path)
214 model.set_commitmsg(commit_msg)
216 # Allow tab to jump from the summary to the description
217 self.setTabOrder(self.summary, self.description)
218 self.setFont(qtutils.diff_font(context))
219 self.setFocusProxy(self.summary)
221 cfg.user_config_changed.connect(self.config_changed)
223 def config_changed(self, key, value):
224 if key != prefs.SPELL_CHECK:
225 return
226 if get(self.check_spelling_action) == value:
227 return
228 self.check_spelling_action.setChecked(value)
229 self.toggle_check_spelling(value)
231 def set_initial_size(self):
232 self.setMaximumHeight(133)
233 QtCore.QTimer.singleShot(1, self.restore_size)
235 def restore_size(self):
236 self.setMaximumHeight(2 ** 13)
238 def focus_summary(self):
239 self.summary.setFocus()
241 def focus_description(self):
242 self.description.setFocus()
244 def summary_cursor_down(self):
245 """Handle the down key in the summary field
247 If the cursor is at the end of the line then focus the description.
248 Otherwise, move the cursor to the end of the line so that a
249 subsequence "down" press moves to the end of the line.
252 cur_position = self.summary.cursorPosition()
253 end_position = len(get(self.summary))
254 if cur_position == end_position:
255 self.focus_description()
256 else:
257 self.summary.setCursorPosition(end_position)
259 def commit_message(self, raw=True):
260 """Return the commit message as a unicode string"""
261 summary = get(self.summary)
262 if raw:
263 description = get(self.description)
264 else:
265 description = self.formatted_description()
266 if summary and description:
267 return summary + '\n\n' + description
268 if summary:
269 return summary
270 if description:
271 return '\n\n' + description
272 return ''
274 def formatted_description(self):
275 text = get(self.description)
276 if not self._linebreak:
277 return text
278 return textwrap.word_wrap(text, self._tabwidth, self._textwidth)
280 def commit_summary_changed(self, value):
281 """Respond to changes to the `summary` field
283 Newlines can enter the `summary` field when pasting, which is
284 undesirable. Break the pasted value apart into the separate
285 (summary, description) values and move the description over to the
286 "extended description" field.
289 if '\n' in value:
290 summary, description = value.split('\n', 1)
291 description = description.lstrip('\n')
292 cur_description = get(self.description)
293 if cur_description:
294 description = description + '\n' + cur_description
295 # this callback is triggered by changing `summary`
296 # so disable signals for `summary` only.
297 self.summary.set_value(summary, block=True)
298 self.description.set_value(description)
299 self._commit_message_changed()
301 def _commit_message_changed(self, _value=None):
302 """Update the model when values change"""
303 message = self.commit_message()
304 self.model.set_commitmsg(message, notify=False)
305 self.refresh_palettes()
306 self.update_actions()
308 def clear(self):
309 if not Interaction.confirm(
310 N_('Clear commit message?'),
311 N_('The commit message will be cleared.'),
312 N_('This cannot be undone. Clear commit message?'),
313 N_('Clear commit message'),
314 default=True,
315 icon=icons.discard(),
317 return
318 self.model.set_commitmsg('')
320 def update_actions(self):
321 commit_enabled = bool(get(self.summary))
322 self.commit_group.setEnabled(commit_enabled)
324 def refresh_palettes(self):
325 """Update the color palette for the hint text"""
326 self.summary.hint.refresh()
327 self.description.hint.refresh()
329 def set_commit_message(self, message):
330 """Set the commit message to match the observed model"""
331 # Parse the "summary" and "description" fields
332 lines = message.splitlines()
334 num_lines = len(lines)
336 if num_lines == 0:
337 # Message is empty
338 summary = ''
339 description = ''
341 elif num_lines == 1:
342 # Message has a summary only
343 summary = lines[0]
344 description = ''
346 elif num_lines == 2:
347 # Message has two lines; this is not a common case
348 summary = lines[0]
349 description = lines[1]
351 else:
352 # Summary and several description lines
353 summary = lines[0]
354 if lines[1]:
355 # We usually skip this line but check just in case
356 description_lines = lines[1:]
357 else:
358 description_lines = lines[2:]
359 description = '\n'.join(description_lines)
361 focus_summary = not summary
362 focus_description = not description
364 # Update summary
365 self.summary.set_value(summary, block=True)
367 # Update description
368 self.description.set_value(description, block=True)
370 # Update text color
371 self.refresh_palettes()
373 # Focus the empty summary or description
374 if focus_summary:
375 self.summary.setFocus()
376 elif focus_description:
377 self.description.setFocus()
378 else:
379 self.summary.cursor_position.emit()
381 self.update_actions()
383 def set_expandtab(self, value):
384 self.description.set_expandtab(value)
386 def set_tabwidth(self, width):
387 self._tabwidth = width
388 self.description.set_tabwidth(width)
390 def set_textwidth(self, width):
391 self._textwidth = width
392 self.description.set_textwidth(width)
394 def set_linebreak(self, brk):
395 self._linebreak = brk
396 self.description.set_linebreak(brk)
397 with qtutils.BlockSignals(self.autowrap_action):
398 self.autowrap_action.setChecked(brk)
400 def setFont(self, font):
401 """Pass the setFont() calls down to the text widgets"""
402 self.summary.setFont(font)
403 self.description.setFont(font)
405 def set_mode(self, mode):
406 can_amend = not self.model.is_merging
407 checked = mode == self.model.mode_amend
408 with qtutils.BlockSignals(self.amend_action):
409 self.amend_action.setEnabled(can_amend)
410 self.amend_action.setChecked(checked)
412 def commit(self):
413 """Attempt to create a commit from the index and commit message."""
414 context = self.context
415 if not bool(get(self.summary)):
416 # Describe a good commit message
417 error_msg = N_(
418 'Please supply a commit message.\n\n'
419 'A good commit message has the following format:\n\n'
420 '- First line: Describe in one sentence what you did.\n'
421 '- Second line: Blank\n'
422 '- Remaining lines: Describe why this change is good.\n'
424 Interaction.log(error_msg)
425 Interaction.information(N_('Missing Commit Message'), error_msg)
426 return
428 msg = self.commit_message(raw=False)
430 # We either need to have something staged, or be merging.
431 # If there was a merge conflict resolved, there may not be anything
432 # to stage, but we still need to commit to complete the merge.
433 if not (self.model.staged or self.model.is_merging):
434 error_msg = N_(
435 'No changes to commit.\n\n'
436 'You must stage at least 1 file before you can commit.'
438 if self.model.modified:
439 informative_text = N_(
440 'Would you like to stage and ' 'commit all modified files?'
442 if not Interaction.confirm(
443 N_('Stage and commit?'),
444 error_msg,
445 informative_text,
446 N_('Stage and Commit'),
447 default=True,
448 icon=icons.save(),
450 return
451 else:
452 Interaction.information(N_('Nothing to commit'), error_msg)
453 return
454 cmds.do(cmds.StageModified, context)
456 # Warn that amending published commits is generally bad
457 amend = get(self.amend_action)
458 check_published = prefs.check_published_commits(context)
459 if (
460 amend
461 and check_published
462 and self.model.is_commit_published()
463 and not Interaction.confirm(
464 N_('Rewrite Published Commit?'),
466 'This commit has already been published.\n'
467 'This operation will rewrite published history.\n'
468 'You probably don\'t want to do this.'
470 N_('Amend the published commit?'),
471 N_('Amend Commit'),
472 default=False,
473 icon=icons.save(),
476 return
477 no_verify = get(self.bypass_commit_hooks_action)
478 sign = get(self.sign_action)
479 cmds.do(cmds.Commit, context, amend, msg, sign, no_verify=no_verify)
480 self.bypass_commit_hooks_action.setChecked(False)
482 def build_fixup_menu(self):
483 self.build_commits_menu(
484 cmds.LoadFixupMessage,
485 self.fixup_commit_menu,
486 self.choose_fixup_commit,
487 prefix='fixup! ',
490 def build_commitmsg_menu(self):
491 self.build_commits_menu(
492 cmds.LoadCommitMessageFromOID,
493 self.load_commitmsg_menu,
494 self.choose_commit_message,
497 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
498 context = self.context
499 params = dag.DAG('HEAD', 6)
500 commits = dag.RepoReader(context, params)
502 menu_commits = []
503 for idx, c in enumerate(commits.get()):
504 menu_commits.insert(0, c)
505 if idx > 5:
506 continue
508 menu.clear()
509 for c in menu_commits:
510 menu.addAction(prefix + c.summary, cmds.run(cmd, context, c.oid))
512 if len(commits) == 6:
513 menu.addSeparator()
514 menu.addAction(N_('More...'), chooser)
516 def choose_commit(self, cmd):
517 context = self.context
518 revs, summaries = gitcmds.log_helper(context)
519 oids = select_commits(
520 context, N_('Select Commit'), revs, summaries, multiselect=False
522 if not oids:
523 return
524 oid = oids[0]
525 cmds.do(cmd, context, oid)
527 def choose_commit_message(self):
528 self.choose_commit(cmds.LoadCommitMessageFromOID)
530 def choose_fixup_commit(self):
531 self.choose_commit(cmds.LoadFixupMessage)
533 def toggle_check_spelling(self, enabled):
534 spellcheck = self.description.spellcheck
535 cfg = self.context.cfg
537 if cfg.get_user(prefs.SPELL_CHECK) != enabled:
538 cfg.set_user(prefs.SPELL_CHECK, enabled)
539 if enabled and not self.spellcheck_initialized:
540 # Add our name to the dictionary
541 self.spellcheck_initialized = True
542 user_name = cfg.get('user.name')
543 if user_name:
544 for part in user_name.split():
545 spellcheck.add_word(part)
547 # Add our email address to the dictionary
548 user_email = cfg.get('user.email')
549 if user_email:
550 for part in user_email.split('@'):
551 for elt in part.split('.'):
552 spellcheck.add_word(elt)
554 # git jargon
555 spellcheck.add_word('Acked')
556 spellcheck.add_word('Signed')
557 spellcheck.add_word('Closes')
558 spellcheck.add_word('Fixes')
560 self.description.highlighter.enable(enabled)
563 class MessageValidator(QtGui.QValidator):
564 """Prevent invalid branch names"""
566 def __init__(self, context, parent=None):
567 super(MessageValidator, self).__init__(parent)
568 self.context = context
569 self._comment_char = None
570 self.refresh()
571 context.cfg.updated.connect(self.refresh, type=Qt.QueuedConnection)
573 def refresh(self):
574 """Update comment char in response to config changes"""
575 self._comment_char = prefs.comment_char(self.context)
577 def validate(self, string, idx):
578 """Scrub whitespace and validate the commit message"""
579 string = string.lstrip()
580 if string.startswith(self._comment_char):
581 state = self.Invalid
582 else:
583 state = self.Acceptable
584 return (state, string, idx)
587 class CommitSummaryLineEdit(HintedLineEdit):
589 cursor = Signal(int, int)
591 def __init__(self, context, parent=None):
592 hint = N_('Commit summary')
593 HintedLineEdit.__init__(self, context, hint, parent=parent)
594 self.menu_actions = []
596 def build_menu(self):
597 menu = self.createStandardContextMenu()
598 add_menu_actions(menu, self.menu_actions)
599 return menu
601 def contextMenuEvent(self, event):
602 menu = self.build_menu()
603 menu.exec_(self.mapToGlobal(event.pos()))
606 # pylint: disable=too-many-ancestors
607 class CommitMessageTextEdit(SpellCheckTextEdit):
608 leave = Signal()
610 def __init__(self, context, parent=None):
611 hint = N_('Extended description...')
612 SpellCheckTextEdit.__init__(self, context, hint, parent)
613 self.menu_actions = []
615 self.action_emit_leave = qtutils.add_action(
616 self, 'Shift Tab', self.leave.emit, hotkeys.LEAVE
619 def build_menu(self):
620 menu, _ = self.context_menu()
621 add_menu_actions(menu, self.menu_actions)
622 return menu
624 def contextMenuEvent(self, event):
625 menu = self.build_menu()
626 menu.exec_(self.mapToGlobal(event.pos()))
628 def keyPressEvent(self, event):
629 if event.key() == Qt.Key_Up:
630 cursor = self.textCursor()
631 position = cursor.position()
632 if position == 0:
633 # The cursor is at the beginning of the line.
634 # If we have selection then simply reset the cursor.
635 # Otherwise, emit a signal so that the parent can
636 # change focus.
637 if cursor.hasSelection():
638 cursor.setPosition(0)
639 self.setTextCursor(cursor)
640 else:
641 self.leave.emit()
642 event.accept()
643 return
644 text_before = self.toPlainText()[:position]
645 lines_before = text_before.count('\n')
646 if lines_before == 0:
647 # If we're on the first line, but not at the
648 # beginning, then move the cursor to the beginning
649 # of the line.
650 if event.modifiers() & Qt.ShiftModifier:
651 mode = QtGui.QTextCursor.KeepAnchor
652 else:
653 mode = QtGui.QTextCursor.MoveAnchor
654 cursor.setPosition(0, mode)
655 self.setTextCursor(cursor)
656 event.accept()
657 return
658 elif event.key() == Qt.Key_Down:
659 cursor = self.textCursor()
660 position = cursor.position()
661 all_text = self.toPlainText()
662 text_after = all_text[position:]
663 lines_after = text_after.count('\n')
664 if lines_after == 0:
665 if event.modifiers() & Qt.ShiftModifier:
666 mode = QtGui.QTextCursor.KeepAnchor
667 else:
668 mode = QtGui.QTextCursor.MoveAnchor
669 cursor.setPosition(len(all_text), mode)
670 self.setTextCursor(cursor)
671 event.accept()
672 return
673 SpellCheckTextEdit.keyPressEvent(self, event)
675 def setFont(self, font):
676 SpellCheckTextEdit.setFont(self, font)
677 fm = self.fontMetrics()
678 self.setMinimumSize(QtCore.QSize(fm.width('MMMM'), fm.height() * 2))
681 def add_menu_actions(menu, menu_actions):
682 """Add actions to a menu, treating None as a separator"""
683 for action in menu_actions:
684 if action is None:
685 menu.addSeparator()
686 else:
687 menu.addAction(action)