1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
2 from functools
import partial
4 from qtpy
import QtCore
6 from qtpy
import QtWidgets
7 from qtpy
.QtCore
import Qt
8 from qtpy
.QtCore
import Signal
10 from .. import actions
13 from .. import gitcmds
14 from .. import hotkeys
16 from .. import textwrap
17 from .. import qtutils
18 from .. import spellcheck
19 from ..interaction
import Interaction
20 from ..gitcmds
import commit_message_path
22 from ..models
import dag
23 from ..models
import prefs
24 from ..qtutils
import get
25 from ..utils
import Group
27 from .selectcommits
import select_commits
28 from .spellcheck
import SpellCheckTextEdit
29 from .text
import HintedLineEdit
32 class CommitMessageEditor(QtWidgets
.QFrame
):
33 cursor_changed
= Signal(int, int)
37 def __init__(self
, context
, parent
):
38 QtWidgets
.QFrame
.__init
__(self
, parent
)
40 self
.context
= context
41 self
.model
= model
= context
.model
42 self
.spellcheck_initialized
= False
43 self
.spellcheck
= spellcheck
.NorvigSpellCheck()
44 self
.spellcheck
.set_dictionary(cfg
.get('cola.dictionary', None))
46 self
._linebreak
= None
47 self
._textwidth
= None
51 self
.signoff_action
= qtutils
.add_action(
52 self
, cmds
.SignOff
.name(), cmds
.run(cmds
.SignOff
, context
), hotkeys
.SIGNOFF
54 self
.signoff_action
.setIcon(icons
.style_dialog_apply())
55 self
.signoff_action
.setToolTip(N_('Sign off on this commit'))
57 self
.commit_action
= qtutils
.add_action(
58 self
, N_('Commit@@verb'), self
.commit
, hotkeys
.APPLY
60 self
.commit_action
.setIcon(icons
.commit())
61 self
.commit_action
.setToolTip(N_('Commit staged changes'))
62 self
.clear_action
= qtutils
.add_action(self
, N_('Clear...'), self
.clear
)
64 self
.launch_editor
= actions
.launch_editor_at_line(context
, self
)
65 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
67 self
.move_up
= actions
.move_up(self
)
68 self
.move_down
= actions
.move_down(self
)
71 self
.menu_actions
= menu_actions
= [
84 self
.summary
= CommitSummaryLineEdit(context
)
85 self
.summary
.setMinimumHeight(defs
.tool_button_height
)
86 self
.summary
.menu_actions
.extend(menu_actions
)
88 self
.summary_validator
= MessageValidator(context
, parent
=self
.summary
)
89 self
.summary
.setValidator(self
.summary_validator
)
91 self
.description
= CommitMessageTextEdit(
92 context
, check
=self
.spellcheck
, parent
=self
94 self
.description
.menu_actions
.extend(menu_actions
)
96 commit_button_tooltip
= N_('Commit staged changes\nShortcut: Ctrl+Enter')
97 self
.commit_button
= qtutils
.create_button(
98 text
=N_('Commit@@verb'), tooltip
=commit_button_tooltip
, 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...')
106 self
.actions_button
.setMenu(self
.actions_menu
)
108 self
.actions_menu
.addAction(self
.signoff_action
)
109 self
.actions_menu
.addAction(self
.commit_action
)
110 self
.actions_menu
.addSeparator()
113 self
.amend_action
= self
.actions_menu
.addAction(N_('Amend Last Commit'))
114 self
.amend_action
.setIcon(icons
.edit())
115 self
.amend_action
.setCheckable(True)
116 self
.amend_action
.setShortcut(hotkeys
.AMEND
)
117 self
.amend_action
.setShortcutContext(Qt
.ApplicationShortcut
)
120 self
.bypass_commit_hooks_action
= self
.actions_menu
.addAction(
121 N_('Bypass Commit Hooks')
123 self
.bypass_commit_hooks_action
.setCheckable(True)
124 self
.bypass_commit_hooks_action
.setChecked(False)
127 self
.sign_action
= self
.actions_menu
.addAction(N_('Create Signed Commit'))
128 self
.sign_action
.setCheckable(True)
129 signcommits
= cfg
.get('cola.signcommits', default
=False)
130 self
.sign_action
.setChecked(signcommits
)
133 self
.check_spelling_action
= self
.actions_menu
.addAction(N_('Check Spelling'))
134 self
.check_spelling_action
.setCheckable(True)
135 spell_check
= prefs
.spellcheck(context
)
136 self
.check_spelling_action
.setChecked(spell_check
)
137 self
.toggle_check_spelling(spell_check
)
140 self
.autowrap_action
= self
.actions_menu
.addAction(N_('Auto-Wrap Lines'))
141 self
.autowrap_action
.setCheckable(True)
142 self
.autowrap_action
.setChecked(prefs
.linebreak(context
))
145 self
.actions_menu
.addSeparator()
146 self
.load_commitmsg_menu
= self
.actions_menu
.addMenu(
147 N_('Load Previous Commit Message')
149 self
.load_commitmsg_menu
.aboutToShow
.connect(self
.build_commitmsg_menu
)
151 self
.fixup_commit_menu
= self
.actions_menu
.addMenu(N_('Fixup Previous Commit'))
152 self
.fixup_commit_menu
.aboutToShow
.connect(self
.build_fixup_menu
)
154 self
.toplayout
= qtutils
.hbox(
161 self
.toplayout
.setContentsMargins(
162 defs
.margin
, defs
.no_margin
, defs
.no_margin
, defs
.no_margin
165 self
.mainlayout
= qtutils
.vbox(
166 defs
.no_margin
, defs
.spacing
, self
.toplayout
, self
.description
168 self
.setLayout(self
.mainlayout
)
170 qtutils
.connect_button(self
.commit_button
, self
.commit
)
172 # Broadcast the amend mode
173 qtutils
.connect_action_bool(
174 self
.amend_action
, partial(cmds
.run(cmds
.AmendMode
), context
)
176 qtutils
.connect_action_bool(
177 self
.check_spelling_action
, self
.toggle_check_spelling
180 # Handle the one-off autowrapping
181 qtutils
.connect_action_bool(self
.autowrap_action
, self
.set_linebreak
)
184 self
.summary
, N_('Move Down'), self
.focus_description
, *hotkeys
.ACCEPT
188 self
.summary
, N_('Move Down'), self
.summary_cursor_down
, hotkeys
.DOWN
191 self
.model
.commit_message_changed
.connect(
192 self
.set_commit_message
, type=Qt
.QueuedConnection
195 self
.summary
.cursor_changed
.connect(self
.cursor_changed
.emit
)
196 self
.description
.cursor_changed
.connect(
197 # description starts at line 2
198 lambda row
, col
: self
.cursor_changed
.emit(row
+ 2, col
)
201 # pylint: disable=no-member
202 self
.summary
.textChanged
.connect(self
.commit_summary_changed
)
203 self
.description
.textChanged
.connect(self
._commit
_message
_changed
)
204 self
.description
.leave
.connect(self
.focus_summary
)
206 self
.commit_group
.setEnabled(False)
208 self
.set_expandtab(prefs
.expandtab(context
))
209 self
.set_tabwidth(prefs
.tabwidth(context
))
210 self
.set_textwidth(prefs
.textwidth(context
))
211 self
.set_linebreak(prefs
.linebreak(context
))
215 commit_msg_path
= commit_message_path(context
)
217 commit_msg
= core
.read(commit_msg_path
)
218 model
.set_commitmsg(commit_msg
)
220 # Allow tab to jump from the summary to the description
221 self
.setTabOrder(self
.summary
, self
.description
)
222 self
.setFont(qtutils
.diff_font(context
))
223 self
.setFocusProxy(self
.summary
)
225 cfg
.user_config_changed
.connect(self
.config_changed
)
227 def config_changed(self
, key
, value
):
228 if key
!= prefs
.SPELL_CHECK
:
230 if get(self
.check_spelling_action
) == value
:
232 self
.check_spelling_action
.setChecked(value
)
233 self
.toggle_check_spelling(value
)
235 def set_initial_size(self
):
236 self
.setMaximumHeight(133)
237 QtCore
.QTimer
.singleShot(1, self
.restore_size
)
239 def restore_size(self
):
240 self
.setMaximumHeight(2**13)
242 def focus_summary(self
):
243 self
.summary
.setFocus()
245 def focus_description(self
):
246 self
.description
.setFocus()
248 def summary_cursor_down(self
):
249 """Handle the down key in the summary field
251 If the cursor is at the end of the line then focus the description.
252 Otherwise, move the cursor to the end of the line so that a
253 subsequence "down" press moves to the end of the line.
256 cur_position
= self
.summary
.cursorPosition()
257 end_position
= len(get(self
.summary
))
258 if cur_position
== end_position
:
259 self
.focus_description()
261 self
.summary
.setCursorPosition(end_position
)
263 def commit_message(self
, raw
=True):
264 """Return the commit message as a unicode string"""
265 summary
= get(self
.summary
)
267 description
= get(self
.description
)
269 description
= self
.formatted_description()
270 if summary
and description
:
271 return summary
+ '\n\n' + description
275 return '\n\n' + description
278 def formatted_description(self
):
279 text
= get(self
.description
)
280 if not self
._linebreak
:
282 return textwrap
.word_wrap(text
, self
._tabwidth
, self
._textwidth
)
284 def commit_summary_changed(self
, value
):
285 """Respond to changes to the `summary` field
287 Newlines can enter the `summary` field when pasting, which is
288 undesirable. Break the pasted value apart into the separate
289 (summary, description) values and move the description over to the
290 "extended description" field.
294 summary
, description
= value
.split('\n', 1)
295 description
= description
.lstrip('\n')
296 cur_description
= get(self
.description
)
298 description
= description
+ '\n' + cur_description
299 # this callback is triggered by changing `summary`
300 # so disable signals for `summary` only.
301 self
.summary
.set_value(summary
, block
=True)
302 self
.description
.set_value(description
)
303 self
._commit
_message
_changed
()
305 def _commit_message_changed(self
, _value
=None):
306 """Update the model when values change"""
307 message
= self
.commit_message()
308 self
.model
.set_commitmsg(message
, notify
=False)
309 self
.refresh_palettes()
310 self
.update_actions()
313 if not Interaction
.confirm(
314 N_('Clear commit message?'),
315 N_('The commit message will be cleared.'),
316 N_('This cannot be undone. Clear commit message?'),
317 N_('Clear commit message'),
319 icon
=icons
.discard(),
322 self
.model
.set_commitmsg('')
324 def update_actions(self
):
325 commit_enabled
= bool(get(self
.summary
))
326 self
.commit_group
.setEnabled(commit_enabled
)
328 def refresh_palettes(self
):
329 """Update the color palette for the hint text"""
330 self
.summary
.hint
.refresh()
331 self
.description
.hint
.refresh()
333 def set_commit_message(self
, message
):
334 """Set the commit message to match the observed model"""
335 # Parse the "summary" and "description" fields
336 lines
= message
.splitlines()
338 num_lines
= len(lines
)
346 # Message has a summary only
351 # Message has two lines; this is not a common case
353 description
= lines
[1]
356 # Summary and several description lines
359 # We usually skip this line but check just in case
360 description_lines
= lines
[1:]
362 description_lines
= lines
[2:]
363 description
= '\n'.join(description_lines
)
365 focus_summary
= not summary
366 focus_description
= not description
369 self
.summary
.set_value(summary
, block
=True)
372 self
.description
.set_value(description
, block
=True)
375 self
.refresh_palettes()
377 # Focus the empty summary or description
379 self
.summary
.setFocus()
380 elif focus_description
:
381 self
.description
.setFocus()
383 self
.summary
.cursor_position
.emit()
385 self
.update_actions()
387 def set_expandtab(self
, value
):
388 self
.description
.set_expandtab(value
)
390 def set_tabwidth(self
, width
):
391 self
._tabwidth
= width
392 self
.description
.set_tabwidth(width
)
394 def set_textwidth(self
, width
):
395 self
._textwidth
= width
396 self
.description
.set_textwidth(width
)
398 def set_linebreak(self
, brk
):
399 self
._linebreak
= brk
400 self
.description
.set_linebreak(brk
)
401 with qtutils
.BlockSignals(self
.autowrap_action
):
402 self
.autowrap_action
.setChecked(brk
)
404 def setFont(self
, font
):
405 """Pass the setFont() calls down to the text widgets"""
406 self
.summary
.setFont(font
)
407 self
.description
.setFont(font
)
409 def set_mode(self
, mode
):
410 can_amend
= not self
.model
.is_merging
411 checked
= mode
== self
.model
.mode_amend
412 with qtutils
.BlockSignals(self
.amend_action
):
413 self
.amend_action
.setEnabled(can_amend
)
414 self
.amend_action
.setChecked(checked
)
417 """Attempt to create a commit from the index and commit message."""
418 context
= self
.context
419 if not bool(get(self
.summary
)):
420 # Describe a good commit message
422 'Please supply a commit message.\n\n'
423 'A good commit message has the following format:\n\n'
424 '- First line: Describe in one sentence what you did.\n'
425 '- Second line: Blank\n'
426 '- Remaining lines: Describe why this change is good.\n'
428 Interaction
.log(error_msg
)
429 Interaction
.information(N_('Missing Commit Message'), error_msg
)
432 msg
= self
.commit_message(raw
=False)
434 # We either need to have something staged, or be merging.
435 # If there was a merge conflict resolved, there may not be anything
436 # to stage, but we still need to commit to complete the merge.
437 if not (self
.model
.staged
or self
.model
.is_merging
):
439 'No changes to commit.\n\n'
440 'You must stage at least 1 file before you can commit.'
442 if self
.model
.modified
:
443 informative_text
= N_(
444 'Would you like to stage and commit all modified files?'
446 if not Interaction
.confirm(
447 N_('Stage and commit?'),
450 N_('Stage and Commit'),
456 Interaction
.information(N_('Nothing to commit'), error_msg
)
458 cmds
.do(cmds
.StageModified
, context
)
460 # Warn that amending published commits is generally bad
461 amend
= get(self
.amend_action
)
462 check_published
= prefs
.check_published_commits(context
)
466 and self
.model
.is_commit_published()
467 and not Interaction
.confirm(
468 N_('Rewrite Published Commit?'),
470 'This commit has already been published.\n'
471 'This operation will rewrite published history.\n'
472 'You probably don\'t want to do this.'
474 N_('Amend the published commit?'),
481 no_verify
= get(self
.bypass_commit_hooks_action
)
482 sign
= get(self
.sign_action
)
483 cmds
.do(cmds
.Commit
, context
, amend
, msg
, sign
, no_verify
=no_verify
)
484 self
.bypass_commit_hooks_action
.setChecked(False)
486 def build_fixup_menu(self
):
487 self
.build_commits_menu(
488 cmds
.LoadFixupMessage
,
489 self
.fixup_commit_menu
,
490 self
.choose_fixup_commit
,
494 def build_commitmsg_menu(self
):
495 self
.build_commits_menu(
496 cmds
.LoadCommitMessageFromOID
,
497 self
.load_commitmsg_menu
,
498 self
.choose_commit_message
,
501 def build_commits_menu(self
, cmd
, menu
, chooser
, prefix
=''):
502 context
= self
.context
503 params
= dag
.DAG('HEAD', 6)
504 commits
= dag
.RepoReader(context
, params
)
507 for idx
, c
in enumerate(commits
.get()):
508 menu_commits
.insert(0, c
)
513 for c
in menu_commits
:
514 menu
.addAction(prefix
+ c
.summary
, cmds
.run(cmd
, context
, c
.oid
))
516 if len(commits
) == 6:
518 menu
.addAction(N_('More...'), chooser
)
520 def choose_commit(self
, cmd
):
521 context
= self
.context
522 revs
, summaries
= gitcmds
.log_helper(context
)
523 oids
= select_commits(
524 context
, N_('Select Commit'), revs
, summaries
, multiselect
=False
529 cmds
.do(cmd
, context
, oid
)
531 def choose_commit_message(self
):
532 self
.choose_commit(cmds
.LoadCommitMessageFromOID
)
534 def choose_fixup_commit(self
):
535 self
.choose_commit(cmds
.LoadFixupMessage
)
537 def toggle_check_spelling(self
, enabled
):
538 spell_check
= self
.spellcheck
539 cfg
= self
.context
.cfg
541 if prefs
.spellcheck(self
.context
) != enabled
:
542 cfg
.set_user(prefs
.SPELL_CHECK
, enabled
)
543 if enabled
and not self
.spellcheck_initialized
:
544 # Add our name to the dictionary
545 self
.spellcheck_initialized
= True
546 user_name
= cfg
.get('user.name')
548 for part
in user_name
.split():
549 spell_check
.add_word(part
)
551 # Add our email address to the dictionary
552 user_email
= cfg
.get('user.email')
554 for part
in user_email
.split('@'):
555 for elt
in part
.split('.'):
556 spell_check
.add_word(elt
)
559 spell_check
.add_word('Acked')
560 spell_check
.add_word('Signed')
561 spell_check
.add_word('Closes')
562 spell_check
.add_word('Fixes')
564 self
.description
.highlighter
.enable(enabled
)
565 self
.summary
.highlighter
.enable(enabled
)
568 class MessageValidator(QtGui
.QValidator
):
569 """Prevent invalid commit messages"""
571 def __init__(self
, context
, parent
=None):
572 super(MessageValidator
, self
).__init
__(parent
)
573 self
.context
= context
574 self
._comment
_char
= None
576 context
.cfg
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
579 """Update comment char in response to config changes"""
580 self
._comment
_char
= prefs
.comment_char(self
.context
)
582 def validate(self
, string
, idx
):
583 """Scrub whitespace and validate the commit message"""
584 string
= string
.lstrip()
585 if string
.startswith(self
._comment
_char
):
588 state
= self
.Acceptable
589 return (state
, string
, idx
)
592 class CommitSummaryLineEdit(HintedLineEdit
):
594 cursor
= Signal(int, int)
596 def __init__(self
, context
, parent
=None):
597 hint
= N_('Commit summary')
598 HintedLineEdit
.__init
__(self
, context
, hint
, parent
=parent
)
599 self
.menu_actions
= []
601 def build_menu(self
):
602 menu
= self
.createStandardContextMenu()
603 add_menu_actions(menu
, self
.menu_actions
)
606 def contextMenuEvent(self
, event
):
607 menu
= self
.build_menu()
608 menu
.exec_(self
.mapToGlobal(event
.pos()))
611 # pylint: disable=too-many-ancestors
612 class CommitMessageTextEdit(SpellCheckTextEdit
):
615 def __init__(self
, context
, check
=None, parent
=None):
616 hint
= N_('Extended description...')
617 SpellCheckTextEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
618 self
.menu_actions
= []
620 self
.action_emit_leave
= qtutils
.add_action(
621 self
, 'Shift Tab', self
.leave
.emit
, hotkeys
.LEAVE
624 def build_menu(self
):
625 menu
, _
= self
.context_menu()
626 add_menu_actions(menu
, self
.menu_actions
)
629 def contextMenuEvent(self
, event
):
630 menu
= self
.build_menu()
631 menu
.exec_(self
.mapToGlobal(event
.pos()))
633 def keyPressEvent(self
, event
):
634 if event
.key() == Qt
.Key_Up
:
635 cursor
= self
.textCursor()
636 position
= cursor
.position()
638 # The cursor is at the beginning of the line.
639 # If we have selection then simply reset the cursor.
640 # Otherwise, emit a signal so that the parent can
642 if cursor
.hasSelection():
643 cursor
.setPosition(0)
644 self
.setTextCursor(cursor
)
649 text_before
= self
.toPlainText()[:position
]
650 lines_before
= text_before
.count('\n')
651 if lines_before
== 0:
652 # If we're on the first line, but not at the
653 # beginning, then move the cursor to the beginning
655 if event
.modifiers() & Qt
.ShiftModifier
:
656 mode
= QtGui
.QTextCursor
.KeepAnchor
658 mode
= QtGui
.QTextCursor
.MoveAnchor
659 cursor
.setPosition(0, mode
)
660 self
.setTextCursor(cursor
)
663 elif event
.key() == Qt
.Key_Down
:
664 cursor
= self
.textCursor()
665 position
= cursor
.position()
666 all_text
= self
.toPlainText()
667 text_after
= all_text
[position
:]
668 lines_after
= text_after
.count('\n')
670 if event
.modifiers() & Qt
.ShiftModifier
:
671 mode
= QtGui
.QTextCursor
.KeepAnchor
673 mode
= QtGui
.QTextCursor
.MoveAnchor
674 cursor
.setPosition(len(all_text
), mode
)
675 self
.setTextCursor(cursor
)
678 SpellCheckTextEdit
.keyPressEvent(self
, event
)
680 def setFont(self
, font
):
681 SpellCheckTextEdit
.setFont(self
, font
)
682 fm
= self
.fontMetrics()
683 self
.setMinimumSize(QtCore
.QSize(fm
.width('MMMM'), fm
.height() * 2))
686 def add_menu_actions(menu
, menu_actions
):
687 """Add actions to a menu, treating None as a separator"""
688 for action
in menu_actions
:
692 menu
.addAction(action
)