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 SpellCheckLineEdit
, SpellCheckTextEdit
29 from .text
import anchor_mode
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
, check
=self
.spellcheck
)
85 self
.summary
.setMinimumHeight(defs
.tool_button_height
)
86 self
.summary
.menu_actions
.extend(menu_actions
)
88 self
.description
= CommitMessageTextEdit(
89 context
, check
=self
.spellcheck
, parent
=self
91 self
.description
.menu_actions
.extend(menu_actions
)
93 commit_button_tooltip
= N_('Commit staged changes\nShortcut: Ctrl+Enter')
94 self
.commit_button
= qtutils
.create_button(
95 text
=N_('Commit@@verb'), tooltip
=commit_button_tooltip
, icon
=icons
.commit()
97 self
.commit_group
= Group(self
.commit_action
, self
.commit_button
)
99 self
.actions_menu
= qtutils
.create_menu(N_('Actions'), self
)
100 self
.actions_button
= qtutils
.create_toolbutton(
101 icon
=icons
.configure(), tooltip
=N_('Actions...')
103 self
.actions_button
.setMenu(self
.actions_menu
)
105 self
.actions_menu
.addAction(self
.signoff_action
)
106 self
.actions_menu
.addAction(self
.commit_action
)
107 self
.actions_menu
.addSeparator()
110 self
.amend_action
= self
.actions_menu
.addAction(N_('Amend Last Commit'))
111 self
.amend_action
.setIcon(icons
.edit())
112 self
.amend_action
.setCheckable(True)
113 self
.amend_action
.setShortcut(hotkeys
.AMEND
)
114 self
.amend_action
.setShortcutContext(Qt
.ApplicationShortcut
)
117 self
.bypass_commit_hooks_action
= self
.actions_menu
.addAction(
118 N_('Bypass Commit Hooks')
120 self
.bypass_commit_hooks_action
.setCheckable(True)
121 self
.bypass_commit_hooks_action
.setChecked(False)
124 self
.sign_action
= self
.actions_menu
.addAction(N_('Create Signed Commit'))
125 self
.sign_action
.setCheckable(True)
126 signcommits
= cfg
.get('cola.signcommits', default
=False)
127 self
.sign_action
.setChecked(signcommits
)
130 self
.check_spelling_action
= self
.actions_menu
.addAction(N_('Check Spelling'))
131 self
.check_spelling_action
.setCheckable(True)
132 spell_check
= prefs
.spellcheck(context
)
133 self
.check_spelling_action
.setChecked(spell_check
)
134 self
.toggle_check_spelling(spell_check
)
137 self
.autowrap_action
= self
.actions_menu
.addAction(N_('Auto-Wrap Lines'))
138 self
.autowrap_action
.setCheckable(True)
139 self
.autowrap_action
.setChecked(prefs
.linebreak(context
))
142 self
.actions_menu
.addSeparator()
143 self
.load_commitmsg_menu
= self
.actions_menu
.addMenu(
144 N_('Load Previous Commit Message')
146 self
.load_commitmsg_menu
.aboutToShow
.connect(self
.build_commitmsg_menu
)
148 self
.fixup_commit_menu
= self
.actions_menu
.addMenu(N_('Fixup Previous Commit'))
149 self
.fixup_commit_menu
.aboutToShow
.connect(self
.build_fixup_menu
)
151 self
.toplayout
= qtutils
.hbox(
158 self
.toplayout
.setContentsMargins(
159 defs
.margin
, defs
.no_margin
, defs
.no_margin
, defs
.no_margin
162 self
.mainlayout
= qtutils
.vbox(
163 defs
.no_margin
, defs
.spacing
, 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
)
173 qtutils
.connect_action_bool(
174 self
.check_spelling_action
, self
.toggle_check_spelling
177 # Handle the one-off autowrapping
178 qtutils
.connect_action_bool(self
.autowrap_action
, self
.set_linebreak
)
180 self
.summary
.accepted
.connect(self
.focus_description
)
181 self
.summary
.down_pressed
.connect(self
.summary_cursor_down
)
183 self
.model
.commit_message_changed
.connect(
184 self
.set_commit_message
, type=Qt
.QueuedConnection
187 self
.summary
.cursor_changed
.connect(self
.cursor_changed
.emit
)
188 self
.description
.cursor_changed
.connect(
189 # description starts at line 2
190 lambda row
, col
: self
.cursor_changed
.emit(row
+ 2, col
)
193 # pylint: disable=no-member
194 self
.summary
.textChanged
.connect(
195 self
.commit_summary_changed
, Qt
.QueuedConnection
197 self
.description
.textChanged
.connect(
198 self
._commit
_message
_changed
, Qt
.QueuedConnection
200 self
.description
.leave
.connect(self
.focus_summary
, Qt
.QueuedConnection
)
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
))
211 commit_msg_path
= commit_message_path(context
)
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
:
226 if get(self
.check_spelling_action
) == value
:
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 self
.focus_description()
254 def commit_message(self
, raw
=True):
255 """Return the commit message as a unicode string"""
256 summary
= get(self
.summary
)
258 description
= get(self
.description
)
260 description
= self
.formatted_description()
261 if summary
and description
:
262 return summary
+ '\n\n' + description
266 return '\n\n' + description
269 def formatted_description(self
):
270 text
= get(self
.description
)
271 if not self
._linebreak
:
273 return textwrap
.word_wrap(text
, self
._tabwidth
, self
._textwidth
)
275 def commit_summary_changed(self
):
276 """Respond to changes to the `summary` field
278 Newlines can enter the `summary` field when pasting, which is
279 undesirable. Break the pasted value apart into the separate
280 (summary, description) values and move the description over to the
281 "extended description" field.
284 value
= self
.summary
.value()
286 summary
, description
= value
.split('\n', 1)
287 description
= description
.lstrip('\n')
288 cur_description
= get(self
.description
)
290 description
= description
+ '\n' + cur_description
291 # this callback is triggered by changing `summary`
292 # so disable signals for `summary` only.
293 self
.summary
.set_value(summary
, block
=True)
294 self
.description
.set_value(description
)
295 self
._commit
_message
_changed
()
297 def _commit_message_changed(self
, _value
=None):
298 """Update the model when values change"""
299 message
= self
.commit_message()
300 self
.model
.set_commitmsg(message
, notify
=False)
301 self
.refresh_palettes()
302 self
.update_actions()
305 if not Interaction
.confirm(
306 N_('Clear commit message?'),
307 N_('The commit message will be cleared.'),
308 N_('This cannot be undone. Clear commit message?'),
309 N_('Clear commit message'),
311 icon
=icons
.discard(),
314 self
.model
.set_commitmsg('')
316 def update_actions(self
):
317 commit_enabled
= bool(get(self
.summary
))
318 self
.commit_group
.setEnabled(commit_enabled
)
320 def refresh_palettes(self
):
321 """Update the color palette for the hint text"""
322 self
.summary
.hint
.refresh()
323 self
.description
.hint
.refresh()
325 def set_commit_message(self
, message
):
326 """Set the commit message to match the observed model"""
327 # Parse the "summary" and "description" fields
328 lines
= message
.splitlines()
330 num_lines
= len(lines
)
338 # Message has a summary only
343 # Message has two lines; this is not a common case
345 description
= lines
[1]
348 # Summary and several description lines
351 # We usually skip this line but check just in case
352 description_lines
= lines
[1:]
354 description_lines
= lines
[2:]
355 description
= '\n'.join(description_lines
)
357 focus_summary
= not summary
358 focus_description
= not description
361 self
.summary
.set_value(summary
, block
=True)
364 self
.description
.set_value(description
, block
=True)
367 self
.refresh_palettes()
369 # Focus the empty summary or description
371 self
.summary
.setFocus()
372 elif focus_description
:
373 self
.description
.setFocus()
375 self
.summary
.cursor_position
.emit()
377 self
.update_actions()
379 def set_expandtab(self
, value
):
380 self
.description
.set_expandtab(value
)
382 def set_tabwidth(self
, width
):
383 self
._tabwidth
= width
384 self
.description
.set_tabwidth(width
)
386 def set_textwidth(self
, width
):
387 self
._textwidth
= width
388 self
.description
.set_textwidth(width
)
390 def set_linebreak(self
, brk
):
391 self
._linebreak
= brk
392 self
.description
.set_linebreak(brk
)
393 with qtutils
.BlockSignals(self
.autowrap_action
):
394 self
.autowrap_action
.setChecked(brk
)
396 def setFont(self
, font
):
397 """Pass the setFont() calls down to the text widgets"""
398 self
.summary
.setFont(font
)
399 self
.description
.setFont(font
)
401 def set_mode(self
, mode
):
402 can_amend
= not self
.model
.is_merging
403 checked
= mode
== self
.model
.mode_amend
404 with qtutils
.BlockSignals(self
.amend_action
):
405 self
.amend_action
.setEnabled(can_amend
)
406 self
.amend_action
.setChecked(checked
)
409 """Attempt to create a commit from the index and commit message."""
410 context
= self
.context
411 if not bool(get(self
.summary
)):
412 # Describe a good commit message
414 'Please supply a commit message.\n\n'
415 'A good commit message has the following format:\n\n'
416 '- First line: Describe in one sentence what you did.\n'
417 '- Second line: Blank\n'
418 '- Remaining lines: Describe why this change is good.\n'
420 Interaction
.log(error_msg
)
421 Interaction
.information(N_('Missing Commit Message'), error_msg
)
424 msg
= self
.commit_message(raw
=False)
426 # We either need to have something staged, or be merging.
427 # If there was a merge conflict resolved, there may not be anything
428 # to stage, but we still need to commit to complete the merge.
429 if not (self
.model
.staged
or self
.model
.is_merging
):
431 'No changes to commit.\n\n'
432 'You must stage at least 1 file before you can commit.'
434 if self
.model
.modified
:
435 informative_text
= N_(
436 'Would you like to stage and commit all modified files?'
438 if not Interaction
.confirm(
439 N_('Stage and commit?'),
442 N_('Stage and Commit'),
448 Interaction
.information(N_('Nothing to commit'), error_msg
)
450 cmds
.do(cmds
.StageModified
, context
)
452 # Warn that amending published commits is generally bad
453 amend
= get(self
.amend_action
)
454 check_published
= prefs
.check_published_commits(context
)
458 and self
.model
.is_commit_published()
459 and not Interaction
.confirm(
460 N_('Rewrite Published Commit?'),
462 'This commit has already been published.\n'
463 'This operation will rewrite published history.\n'
464 'You probably don\'t want to do this.'
466 N_('Amend the published commit?'),
473 no_verify
= get(self
.bypass_commit_hooks_action
)
474 sign
= get(self
.sign_action
)
475 cmds
.do(cmds
.Commit
, context
, amend
, msg
, sign
, no_verify
=no_verify
)
476 self
.bypass_commit_hooks_action
.setChecked(False)
478 def build_fixup_menu(self
):
479 self
.build_commits_menu(
480 cmds
.LoadFixupMessage
,
481 self
.fixup_commit_menu
,
482 self
.choose_fixup_commit
,
486 def build_commitmsg_menu(self
):
487 self
.build_commits_menu(
488 cmds
.LoadCommitMessageFromOID
,
489 self
.load_commitmsg_menu
,
490 self
.choose_commit_message
,
493 def build_commits_menu(self
, cmd
, menu
, chooser
, prefix
=''):
494 context
= self
.context
495 params
= dag
.DAG('HEAD', 6)
496 commits
= dag
.RepoReader(context
, params
)
499 for idx
, c
in enumerate(commits
.get()):
500 menu_commits
.insert(0, c
)
505 for c
in menu_commits
:
506 menu
.addAction(prefix
+ c
.summary
, cmds
.run(cmd
, context
, c
.oid
))
508 if len(commits
) == 6:
510 menu
.addAction(N_('More...'), chooser
)
512 def choose_commit(self
, cmd
):
513 context
= self
.context
514 revs
, summaries
= gitcmds
.log_helper(context
)
515 oids
= select_commits(
516 context
, N_('Select Commit'), revs
, summaries
, multiselect
=False
521 cmds
.do(cmd
, context
, oid
)
523 def choose_commit_message(self
):
524 self
.choose_commit(cmds
.LoadCommitMessageFromOID
)
526 def choose_fixup_commit(self
):
527 self
.choose_commit(cmds
.LoadFixupMessage
)
529 def toggle_check_spelling(self
, enabled
):
530 spell_check
= self
.spellcheck
531 cfg
= self
.context
.cfg
533 if prefs
.spellcheck(self
.context
) != enabled
:
534 cfg
.set_user(prefs
.SPELL_CHECK
, enabled
)
535 if enabled
and not self
.spellcheck_initialized
:
536 # Add our name to the dictionary
537 self
.spellcheck_initialized
= True
538 user_name
= cfg
.get('user.name')
540 for part
in user_name
.split():
541 spell_check
.add_word(part
)
543 # Add our email address to the dictionary
544 user_email
= cfg
.get('user.email')
546 for part
in user_email
.split('@'):
547 for elt
in part
.split('.'):
548 spell_check
.add_word(elt
)
551 spell_check
.add_word('Acked')
552 spell_check
.add_word('Signed')
553 spell_check
.add_word('Closes')
554 spell_check
.add_word('Fixes')
556 self
.summary
.highlighter
.enable(enabled
)
557 self
.description
.highlighter
.enable(enabled
)
560 class CommitSummaryLineEdit(SpellCheckLineEdit
):
562 down_pressed
= Signal()
565 def __init__(self
, context
, check
=None, parent
=None):
566 hint
= N_('Commit summary')
567 SpellCheckLineEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
568 self
.menu_actions
= []
569 self
._comment
_char
= None
570 self
._refresh
_config
()
572 self
.textChanged
.connect(self
._update
_summary
_text
, Qt
.QueuedConnection
)
573 context
.cfg
.updated
.connect(self
._refresh
_config
, type=Qt
.QueuedConnection
)
575 def _refresh_config(self
):
576 """Update comment char in response to config changes"""
577 self
._comment
_char
= prefs
.comment_char(self
.context
)
579 def build_context_menu(self
, menu
):
580 """Add our custom context menu events"""
581 add_menu_actions(menu
, self
.menu_actions
)
583 def _update_summary_text(self
):
584 """Prevent commit messages from starting with comment characters"""
586 if self
._comment
_char
and value
.startswith(self
._comment
_char
):
587 cursor
= self
.textCursor()
588 position
= cursor
.position()
590 value
= value
.lstrip()
591 if self
._comment
_char
:
592 value
= value
.lstrip(self
._comment
_char
).lstrip()
594 self
.set_value(value
, block
=True)
598 position
= max(0, min(position
- 1, len(value
) - 1))
599 cursor
.setPosition(position
)
600 self
.setTextCursor(cursor
)
602 def keyPressEvent(self
, event
):
603 """Allow "Enter" to focus into the extended description field"""
604 event_key
= event
.key()
611 SpellCheckLineEdit
.keyPressEvent(self
, event
)
614 # pylint: disable=too-many-ancestors
615 class CommitMessageTextEdit(SpellCheckTextEdit
):
618 def __init__(self
, context
, check
=None, parent
=None):
619 hint
= N_('Extended description...')
620 SpellCheckTextEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
621 self
.menu_actions
= []
623 self
.action_emit_leave
= qtutils
.add_action(
624 self
, 'Shift Tab', self
.leave
.emit
, hotkeys
.LEAVE
627 def build_menu(self
):
628 menu
, _
= self
.context_menu()
629 add_menu_actions(menu
, self
.menu_actions
)
632 def contextMenuEvent(self
, event
):
633 menu
= self
.build_menu()
634 menu
.exec_(self
.mapToGlobal(event
.pos()))
636 def keyPressEvent(self
, event
):
637 if event
.key() == Qt
.Key_Up
:
638 cursor
= self
.textCursor()
639 position
= cursor
.position()
641 # The cursor is at the beginning of the line.
642 # If we have selection then simply reset the cursor.
643 # Otherwise, emit a signal so that the parent can
645 if cursor
.hasSelection():
646 self
.set_cursor_position(0)
651 text_before
= self
.toPlainText()[:position
]
652 lines_before
= text_before
.count('\n')
653 if lines_before
== 0:
654 # If we're on the first line, but not at the
655 # beginning, then move the cursor to the beginning
657 if event
.modifiers() & Qt
.ShiftModifier
:
658 mode
= QtGui
.QTextCursor
.KeepAnchor
660 mode
= QtGui
.QTextCursor
.MoveAnchor
661 cursor
.setPosition(0, mode
)
662 self
.setTextCursor(cursor
)
665 elif event
.key() == Qt
.Key_Down
:
666 cursor
= self
.textCursor()
667 position
= cursor
.position()
668 all_text
= self
.toPlainText()
669 text_after
= all_text
[position
:]
670 lines_after
= text_after
.count('\n')
672 select
= event
.modifiers() & Qt
.ShiftModifier
673 mode
= anchor_mode(select
)
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
)