commit: use a simpler commit button icon
[git-cola.git] / cola / widgets / commitmsg.py
blob68824744a225d8eb86115e46ba7148bd8c19cadd
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 hotkeys
15 from cola import icons
16 from cola import textwrap
17 from cola import qtutils
18 from cola.cmds import Interaction
19 from cola.gitcmds import commit_message_path
20 from cola.i18n import N_
21 from cola.models import dag
22 from cola.models import prefs
23 from cola.models import selection
24 from cola.utils import Group
25 from cola.widgets import defs
26 from cola.widgets.selectcommits import select_commits
27 from cola.widgets.spellcheck import SpellCheckTextEdit
28 from cola.widgets.text import HintedLineEdit
29 from cola.compat import ustr
32 class CommitMessageEditor(QtGui.QWidget):
33 def __init__(self, model, parent):
34 QtGui.QWidget.__init__(self, parent)
36 self.model = model
37 self.spellcheck_initialized = False
39 self._linebreak = None
40 self._textwidth = None
41 self._tabwidth = None
43 # Actions
44 self.signoff_action = qtutils.add_action(self, cmds.SignOff.name(),
45 cmds.run(cmds.SignOff),
46 hotkeys.SIGNOFF)
47 self.signoff_action.setToolTip(N_('Sign off on this commit'))
49 self.commit_action = qtutils.add_action(self,
50 N_('Commit@@verb'),
51 self.commit, hotkeys.COMMIT)
52 self.commit_action.setToolTip(N_('Commit staged changes'))
53 self.clear_action = qtutils.add_action(self, N_('Clear...'), self.clear)
55 self.launch_editor = actions.launch_editor(self)
56 self.launch_difftool = actions.launch_difftool(self)
57 self.stage_or_unstage = actions.stage_or_unstage(self)
59 self.move_up = actions.move_up(self)
60 self.move_down = actions.move_down(self)
62 # Widgets
63 self.summary = CommitSummaryLineEdit()
64 self.summary.setMinimumHeight(defs.tool_button_height)
65 self.summary.extra_actions.append(self.clear_action)
66 self.summary.extra_actions.append(None)
67 self.summary.extra_actions.append(self.signoff_action)
68 self.summary.extra_actions.append(self.commit_action)
69 self.summary.extra_actions.append(None)
70 self.summary.extra_actions.append(self.launch_editor)
71 self.summary.extra_actions.append(self.launch_difftool)
72 self.summary.extra_actions.append(self.stage_or_unstage)
73 self.summary.extra_actions.append(None)
74 self.summary.extra_actions.append(self.move_up)
75 self.summary.extra_actions.append(self.move_down)
77 self.description = CommitMessageTextEdit()
78 self.description.extra_actions.append(self.clear_action)
79 self.description.extra_actions.append(None)
80 self.description.extra_actions.append(self.signoff_action)
81 self.description.extra_actions.append(self.commit_action)
82 self.description.extra_actions.append(None)
83 self.description.extra_actions.append(self.launch_editor)
84 self.description.extra_actions.append(self.launch_difftool)
85 self.description.extra_actions.append(self.stage_or_unstage)
86 self.description.extra_actions.append(None)
87 self.description.extra_actions.append(self.move_up)
88 self.description.extra_actions.append(self.move_down)
90 commit_button_tooltip = N_('Commit staged changes\n'
91 'Shortcut: Ctrl+Enter')
92 self.commit_button = qtutils.create_toolbutton(
93 text=N_('Commit@@verb'), tooltip=commit_button_tooltip,
94 icon=icons.download())
95 self.commit_group = Group(self.commit_action, self.commit_button)
97 self.actions_menu = QtGui.QMenu()
98 self.actions_button = qtutils.create_toolbutton(
99 icon=icons.configure(), tooltip=N_('Actions...'))
100 self.actions_button.setMenu(self.actions_menu)
101 self.actions_button.setPopupMode(QtGui.QToolButton.InstantPopup)
102 qtutils.hide_button_menu_indicator(self.actions_button)
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(
110 N_('Amend Last Commit'))
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'))
118 self.bypass_commit_hooks_action.setCheckable(True)
119 self.bypass_commit_hooks_action.setChecked(False)
121 # Sign commits
122 cfg = gitcfg.current()
123 self.sign_action = self.actions_menu.addAction(
124 N_('Create Signed Commit'))
125 self.sign_action.setCheckable(True)
126 self.sign_action.setChecked(cfg.get('cola.signcommits', False))
128 # Spell checker
129 self.check_spelling_action = self.actions_menu.addAction(
130 N_('Check Spelling'))
131 self.check_spelling_action.setCheckable(True)
132 self.check_spelling_action.setChecked(False)
134 # Line wrapping
135 self.autowrap_action = self.actions_menu.addAction(
136 N_('Auto-Wrap Lines'))
137 self.autowrap_action.setCheckable(True)
138 self.autowrap_action.setChecked(prefs.linebreak())
140 # Commit message
141 self.actions_menu.addSeparator()
142 self.load_commitmsg_menu = self.actions_menu.addMenu(
143 N_('Load Previous Commit Message'))
144 self.connect(self.load_commitmsg_menu, SIGNAL('aboutToShow()'),
145 self.build_commitmsg_menu)
147 self.fixup_commit_menu = self.actions_menu.addMenu(
148 N_('Fixup Previous Commit'))
149 self.connect(self.fixup_commit_menu, SIGNAL('aboutToShow()'),
150 self.build_fixup_menu)
152 self.toplayout = qtutils.hbox(defs.no_margin, defs.spacing,
153 self.actions_button, self.summary,
154 self.commit_button)
155 self.toplayout.setContentsMargins(defs.margin, defs.no_margin,
156 defs.no_margin, defs.no_margin)
158 self.mainlayout = qtutils.vbox(defs.no_margin, defs.spacing,
159 self.toplayout, self.description)
160 self.setLayout(self.mainlayout)
162 qtutils.connect_button(self.commit_button, self.commit)
164 # Broadcast the amend mode
165 qtutils.connect_action_bool(self.amend_action, cmds.run(cmds.AmendMode))
166 qtutils.connect_action_bool(self.check_spelling_action,
167 self.toggle_check_spelling)
169 # Handle the one-off autowrapping
170 qtutils.connect_action_bool(self.autowrap_action, self.set_linebreak)
172 qtutils.add_action(self.summary, N_('Move Down'),
173 self.focus_description, *hotkeys.ACCEPT)
175 qtutils.add_action(self.summary, N_('Move Down'),
176 self.summary_cursor_down, hotkeys.DOWN)
178 self.selection_model = selection_model = selection.selection_model()
179 selection_model.add_observer(selection_model.message_selection_changed,
180 self._update)
182 self.model.add_observer(self.model.message_commit_message_changed,
183 self._set_commit_message)
185 self.connect(self, SIGNAL('set_commit_message(PyQt_PyObject)'),
186 self.set_commit_message, Qt.QueuedConnection)
188 self.connect(self.summary, SIGNAL('cursorPosition(int,int)'),
189 self.emit_position)
191 self.connect(self.description, SIGNAL('cursorPosition(int,int)'),
192 # description starts at line 2
193 lambda row, col: self.emit_position(row + 2, col))
195 # Keep model informed of changes
196 self.connect(self.summary, SIGNAL('textChanged(QString)'),
197 self.commit_summary_changed)
199 self.connect(self.description, SIGNAL('textChanged()'),
200 self.commit_message_changed)
202 self.connect(self.description, SIGNAL('leave()'),
203 self.focus_summary)
205 self.connect(self, SIGNAL('update()'),
206 self._update_callback, Qt.QueuedConnection)
208 self.setFont(qtutils.diff_font())
210 self.summary.hint.enable(True)
211 self.description.hint.enable(True)
213 self.commit_group.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'), default=True, icon=icons.discard()):
328 return
329 self.model.set_commitmsg('')
331 def update_actions(self):
332 commit_enabled = bool(self.summary.value())
333 self.commit_group.setEnabled(commit_enabled)
335 def refresh_palettes(self):
336 """Update the color palette for the hint text"""
337 self.summary.hint.refresh()
338 self.description.hint.refresh()
340 def _set_commit_message(self, message):
341 self.emit(SIGNAL('set_commit_message(PyQt_PyObject)'), message)
343 def set_commit_message(self, message):
344 """Set the commit message to match the observed model"""
345 # Parse the "summary" and "description" fields
346 umsg = ustr(message)
347 lines = umsg.splitlines()
349 num_lines = len(lines)
351 if num_lines == 0:
352 # Message is empty
353 summary = ''
354 description = ''
356 elif num_lines == 1:
357 # Message has a summary only
358 summary = lines[0]
359 description = ''
361 elif num_lines == 2:
362 # Message has two lines; this is not a common case
363 summary = lines[0]
364 description = lines[1]
366 else:
367 # Summary and several description lines
368 summary = lines[0]
369 if lines[1]:
370 # We usually skip this line but check just in case
371 description_lines = lines[1:]
372 else:
373 description_lines = lines[2:]
374 description = '\n'.join(description_lines)
376 focus_summary = not summary
377 focus_description = not description
379 # Update summary
380 if not summary and not self.summary.hasFocus():
381 self.summary.hint.enable(True)
382 else:
383 self.summary.set_value(summary, block=True)
385 # Update description
386 if not description and not self.description.hasFocus():
387 self.description.hint.enable(True)
388 else:
389 self.description.set_value(description, block=True)
391 # Update text color
392 self.refresh_palettes()
394 # Focus the empty summary or description
395 if focus_summary:
396 self.summary.setFocus()
397 elif focus_description:
398 self.description.setFocus()
399 else:
400 self.summary.cursor_position.emit()
402 self.update_actions()
404 def set_tabwidth(self, width):
405 self._tabwidth = width
406 self.description.set_tabwidth(width)
408 def set_textwidth(self, width):
409 self._textwidth = width
410 self.description.set_textwidth(width)
412 def set_linebreak(self, brk):
413 self._linebreak = brk
414 self.description.set_linebreak(brk)
415 blocksignals = self.autowrap_action.blockSignals(True)
416 self.autowrap_action.setChecked(brk)
417 self.autowrap_action.blockSignals(blocksignals)
419 def setFont(self, font):
420 """Pass the setFont() calls down to the text widgets"""
421 self.summary.setFont(font)
422 self.description.setFont(font)
424 def set_mode(self, mode):
425 can_amend = not self.model.is_merging
426 checked = (mode == self.model.mode_amend)
427 blocksignals = self.amend_action.blockSignals(True)
428 self.amend_action.setEnabled(can_amend)
429 self.amend_action.setChecked(checked)
430 self.amend_action.blockSignals(blocksignals)
432 def emit_position(self, row, col):
433 self.emit(SIGNAL('cursorPosition(int,int)'), row, col)
435 def commit(self):
436 """Attempt to create a commit from the index and commit message."""
437 if not bool(self.summary.value()):
438 # Describe a good commit message
439 error_msg = N_(''
440 'Please supply a commit message.\n\n'
441 'A good commit message has the following format:\n\n'
442 '- First line: Describe in one sentence what you did.\n'
443 '- Second line: Blank\n'
444 '- Remaining lines: Describe why this change is good.\n')
445 Interaction.log(error_msg)
446 Interaction.information(N_('Missing Commit Message'), error_msg)
447 return
449 msg = self.commit_message(raw=False)
451 if not self.model.staged:
452 error_msg = N_(''
453 'No changes to commit.\n\n'
454 'You must stage at least 1 file before you can commit.')
455 if self.model.modified:
456 informative_text = N_('Would you like to stage and '
457 'commit all modified files?')
458 if not qtutils.confirm(
459 N_('Stage and commit?'), error_msg, informative_text,
460 N_('Stage and Commit'),
461 default=True, icon=icons.save()):
462 return
463 else:
464 Interaction.information(N_('Nothing to commit'), error_msg)
465 return
466 cmds.do(cmds.StageModified)
468 # Warn that amending published commits is generally bad
469 amend = self.amend_action.isChecked()
470 if (amend and self.model.is_commit_published() and
471 not qtutils.confirm(
472 N_('Rewrite Published Commit?'),
473 N_('This commit has already been published.\n'
474 'This operation will rewrite published history.\n'
475 'You probably don\'t want to do this.'),
476 N_('Amend the published commit?'),
477 N_('Amend Commit'), default=False, icon=icons.save())):
478 return
479 no_verify = self.bypass_commit_hooks_action.isChecked()
480 sign = self.sign_action.isChecked()
481 status, out, err = cmds.do(cmds.Commit, amend, msg, sign,
482 no_verify=no_verify)
483 if status != 0:
484 Interaction.critical(N_('Commit failed'),
485 N_('"git commit" returned exit code %s') %
486 (status,),
487 out + err)
489 def build_fixup_menu(self):
490 self.build_commits_menu(cmds.LoadFixupMessage,
491 self.fixup_commit_menu,
492 self.choose_fixup_commit,
493 prefix='fixup! ')
495 def build_commitmsg_menu(self):
496 self.build_commits_menu(cmds.LoadCommitMessageFromSHA1,
497 self.load_commitmsg_menu,
498 self.choose_commit_message)
500 def build_commits_menu(self, cmd, menu, chooser, prefix=''):
501 ctx = dag.DAG('HEAD', 6)
502 commits = dag.RepoReader(ctx)
504 menu_commits = []
505 for idx, c in enumerate(commits):
506 menu_commits.insert(0, c)
507 if idx > 5:
508 continue
510 menu.clear()
511 for c in menu_commits:
512 menu.addAction(prefix + c.summary, cmds.run(cmd, c.sha1))
514 if len(commits) == 6:
515 menu.addSeparator()
516 menu.addAction(N_('More...'), chooser)
519 def choose_commit(self, cmd):
520 revs, summaries = gitcmds.log_helper()
521 sha1s = select_commits(N_('Select Commit'), revs, summaries,
522 multiselect=False)
523 if not sha1s:
524 return
525 sha1 = sha1s[0]
526 cmds.do(cmd, sha1)
528 def choose_commit_message(self):
529 self.choose_commit(cmds.LoadCommitMessageFromSHA1)
531 def choose_fixup_commit(self):
532 self.choose_commit(cmds.LoadFixupMessage)
534 def toggle_check_spelling(self, enabled):
535 spellcheck = self.description.spellcheck
537 if enabled and not self.spellcheck_initialized:
538 # Add our name to the dictionary
539 self.spellcheck_initialized = True
540 cfg = gitcfg.current()
541 user_name = cfg.get('user.name')
542 if user_name:
543 for part in user_name.split():
544 spellcheck.add_word(part)
546 # Add our email address to the dictionary
547 user_email = cfg.get('user.email')
548 if user_email:
549 for part in user_email.split('@'):
550 for elt in part.split('.'):
551 spellcheck.add_word(elt)
553 # git jargon
554 spellcheck.add_word('Acked')
555 spellcheck.add_word('Signed')
556 spellcheck.add_word('Closes')
557 spellcheck.add_word('Fixes')
559 self.description.highlighter.enable(enabled)
562 class CommitSummaryLineEdit(HintedLineEdit):
564 def __init__(self, parent=None):
565 hint = N_('Commit summary')
566 HintedLineEdit.__init__(self, hint, parent)
567 self.extra_actions = []
569 comment_char = prefs.comment_char()
570 re_comment_char = re.escape(comment_char)
571 regex = QtCore.QRegExp(r'^[^%s \t].*' % re_comment_char)
572 self._validator = QtGui.QRegExpValidator(regex, self)
573 self.setValidator(self._validator)
575 def contextMenuEvent(self, event):
576 menu = self.createStandardContextMenu()
577 if self.extra_actions:
578 menu.addSeparator()
579 for action in self.extra_actions:
580 if action is None:
581 menu.addSeparator()
582 else:
583 menu.addAction(action)
584 menu.exec_(self.mapToGlobal(event.pos()))
587 class CommitMessageTextEdit(SpellCheckTextEdit):
589 def __init__(self, parent=None):
590 hint = N_('Extended description...')
591 SpellCheckTextEdit.__init__(self, hint, parent)
592 self.extra_actions = []
594 self.action_emit_leave = qtutils.add_action(self,
595 'Shift Tab', self.emit_leave, hotkeys.LEAVE)
597 def contextMenuEvent(self, event):
598 menu, spell_menu = self.context_menu()
599 if self.extra_actions:
600 menu.addSeparator()
601 for action in self.extra_actions:
602 if action is None:
603 menu.addSeparator()
604 else:
605 menu.addAction(action)
606 menu.exec_(self.mapToGlobal(event.pos()))
608 def keyPressEvent(self, event):
609 if event.key() == Qt.Key_Up:
610 cursor = self.textCursor()
611 position = cursor.position()
612 if position == 0:
613 # The cursor is at the beginning of the line.
614 # If we have selection then simply reset the cursor.
615 # Otherwise, emit a signal so that the parent can
616 # change focus.
617 if cursor.hasSelection():
618 cursor.setPosition(0)
619 self.setTextCursor(cursor)
620 else:
621 self.emit_leave()
622 event.accept()
623 return
624 text_before = ustr(self.toPlainText())[:position]
625 lines_before = text_before.count('\n')
626 if lines_before == 0:
627 # If we're on the first line, but not at the
628 # beginning, then move the cursor to the beginning
629 # of the line.
630 if event.modifiers() & Qt.ShiftModifier:
631 mode = QtGui.QTextCursor.KeepAnchor
632 else:
633 mode = QtGui.QTextCursor.MoveAnchor
634 cursor.setPosition(0, mode)
635 self.setTextCursor(cursor)
636 event.accept()
637 return
638 elif event.key() == Qt.Key_Down:
639 cursor = self.textCursor()
640 position = cursor.position()
641 all_text = ustr(self.toPlainText())
642 text_after = all_text[position:]
643 lines_after = text_after.count('\n')
644 if lines_after == 0:
645 if event.modifiers() & Qt.ShiftModifier:
646 mode = QtGui.QTextCursor.KeepAnchor
647 else:
648 mode = QtGui.QTextCursor.MoveAnchor
649 cursor.setPosition(len(all_text), mode)
650 self.setTextCursor(cursor)
651 event.accept()
652 return
653 SpellCheckTextEdit.keyPressEvent(self, event)
655 def emit_leave(self):
656 self.emit(SIGNAL('leave()'))
658 def setFont(self, font):
659 SpellCheckTextEdit.setFont(self, font)
660 fm = self.fontMetrics()
661 self.setMinimumSize(QtCore.QSize(1, fm.height() * 2))