1 from __future__
import division
, absolute_import
, 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 ..interaction
import Interaction
19 from ..gitcmds
import commit_message_path
21 from ..models
import dag
22 from ..models
import prefs
23 from ..qtutils
import get
24 from ..utils
import Group
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)
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
49 self
.signoff_action
= qtutils
.add_action(
50 self
, cmds
.SignOff
.name(), cmds
.run(cmds
.SignOff
, context
),
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
)
69 self
.menu_actions
= menu_actions
= [
76 self
.stage_or_unstage
,
83 self
.summary
= CommitSummaryLineEdit(context
)
84 self
.summary
.setMinimumHeight(defs
.tool_button_height
)
85 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(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
,
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()
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
)
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)
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
)
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
)
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
))
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
,
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
))
213 commit_msg_path
= commit_message_path(context
)
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
:
228 if get(self
.check_spelling_action
) == value
:
230 self
.check_spelling_action
.setChecked(value
)
231 self
.toggle_check_spelling(value
)
234 enabled
= self
.model
.stageable() or self
.model
.unstageable()
235 if self
.model
.stageable():
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()
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
)
274 description
= get(self
.description
)
276 description
= self
.formatted_description()
277 if summary
and description
:
278 return summary
+ '\n\n' + description
282 return '\n\n' + description
285 def formatted_description(self
):
286 text
= get(self
.description
)
287 if not self
._linebreak
:
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.
301 summary
, description
= value
.split('\n', 1)
302 description
= description
.lstrip('\n')
303 cur_description
= get(self
.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()
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()):
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
)
350 # Message has a summary only
355 # Message has two lines; this is not a common case
357 description
= lines
[1]
360 # Summary and several description lines
363 # We usually skip this line but check just in case
364 description_lines
= lines
[1:]
366 description_lines
= lines
[2:]
367 description
= '\n'.join(description_lines
)
369 focus_summary
= not summary
370 focus_description
= not description
373 self
.summary
.set_value(summary
, block
=True)
376 self
.description
.set_value(description
, block
=True)
379 self
.refresh_palettes()
381 # Focus the empty summary or description
383 self
.summary
.setFocus()
384 elif focus_description
:
385 self
.description
.setFocus()
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
)
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
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
)
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
):
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()):
455 Interaction
.information(N_('Nothing to commit'), error_msg
)
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())):
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
,
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
)
492 for idx
, c
in enumerate(commits
.get()):
493 menu_commits
.insert(0, c
)
498 for c
in menu_commits
:
499 menu
.addAction(prefix
+ c
.summary
, cmds
.run(cmd
, context
, c
.oid
))
501 if len(commits
) == 6:
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)
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')
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')
538 for part
in user_email
.split('@'):
539 for elt
in part
.split('.'):
540 spellcheck
.add_word(elt
)
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
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
)
567 self
._cfg
.remove_observer(self
.emit_config_updated
)
569 def emit_config_updated(self
):
570 self
.config_updated
.emit()
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
):
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
)
600 def contextMenuEvent(self
, event
):
601 menu
= self
.build_menu()
602 menu
.exec_(self
.mapToGlobal(event
.pos()))
605 class CommitMessageTextEdit(SpellCheckTextEdit
):
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
)
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()
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
634 if cursor
.hasSelection():
635 cursor
.setPosition(0)
636 self
.setTextCursor(cursor
)
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
647 if event
.modifiers() & Qt
.ShiftModifier
:
648 mode
= QtGui
.QTextCursor
.KeepAnchor
650 mode
= QtGui
.QTextCursor
.MoveAnchor
651 cursor
.setPosition(0, mode
)
652 self
.setTextCursor(cursor
)
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')
662 if event
.modifiers() & Qt
.ShiftModifier
:
663 mode
= QtGui
.QTextCursor
.KeepAnchor
665 mode
= QtGui
.QTextCursor
.MoveAnchor
666 cursor
.setPosition(len(all_text
), mode
)
667 self
.setTextCursor(cursor
)
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
:
684 menu
.addAction(action
)