1 from functools
import partial
3 from qtpy
import QtCore
5 from qtpy
import QtWidgets
6 from qtpy
.QtCore
import Qt
7 from qtpy
.QtCore
import Signal
12 from .. import gitcmds
13 from .. import hotkeys
15 from .. import textwrap
16 from .. import qtutils
17 from .. import spellcheck
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 . import standard
27 from .selectcommits
import select_commits
28 from .spellcheck
import SpellCheckLineEdit
, SpellCheckTextEdit
29 from .text
import anchor_mode
32 class CommitMessageEditor(QtWidgets
.QFrame
):
33 commit_finished
= Signal(object)
34 cursor_changed
= Signal(int, int)
38 def __init__(self
, context
, parent
):
39 QtWidgets
.QFrame
.__init
__(self
, parent
)
41 self
.context
= context
42 self
.model
= model
= context
.model
43 self
.spellcheck_initialized
= False
44 self
.spellcheck
= spellcheck
.NorvigSpellCheck()
45 self
.spellcheck
.set_dictionary(cfg
.get('cola.dictionary', None))
47 self
._linebreak
= None
48 self
._textwidth
= None
52 self
.signoff_action
= qtutils
.add_action(
53 self
, cmds
.SignOff
.name(), cmds
.run(cmds
.SignOff
, context
), hotkeys
.SIGNOFF
55 self
.signoff_action
.setIcon(icons
.style_dialog_apply())
56 self
.signoff_action
.setToolTip(N_('Sign off on this commit'))
58 self
.commit_action
= qtutils
.add_action(
59 self
, N_('Commit@@verb'), self
.commit
, hotkeys
.APPLY
61 self
.commit_action
.setIcon(icons
.commit())
62 self
.commit_action
.setToolTip(N_('Commit staged changes'))
63 self
.clear_action
= qtutils
.add_action(self
, N_('Clear...'), self
.clear
)
65 self
.launch_editor
= actions
.launch_editor_at_line(context
, self
)
66 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
68 self
.move_up
= actions
.move_up(self
)
69 self
.move_down
= actions
.move_down(self
)
72 self
.menu_actions
= menu_actions
= [
85 self
.summary
= CommitSummaryLineEdit(context
, check
=self
.spellcheck
)
86 self
.summary
.setMinimumHeight(defs
.tool_button_height
)
87 self
.summary
.menu_actions
.extend(menu_actions
)
89 self
.description
= CommitMessageTextEdit(
90 context
, check
=self
.spellcheck
, parent
=self
92 self
.description
.menu_actions
.extend(menu_actions
)
94 commit_button_tooltip
= N_('Commit staged changes\nShortcut: Ctrl+Enter')
95 self
.commit_button
= qtutils
.create_button(
96 text
=N_('Commit@@verb'), tooltip
=commit_button_tooltip
, icon
=icons
.commit()
98 self
.commit_group
= Group(self
.commit_action
, self
.commit_button
)
99 self
.commit_progress_bar
= standard
.progress_bar(
101 disable
=(self
.commit_button
, self
.summary
, self
.description
),
103 self
.commit_progress_bar
.setMaximumHeight(defs
.small_icon
)
105 self
.actions_menu
= qtutils
.create_menu(N_('Actions'), self
)
106 self
.actions_button
= qtutils
.create_toolbutton(
107 icon
=icons
.configure(), tooltip
=N_('Actions...')
109 self
.actions_button
.setMenu(self
.actions_menu
)
111 self
.actions_menu
.addAction(self
.signoff_action
)
112 self
.actions_menu
.addAction(self
.commit_action
)
113 self
.actions_menu
.addSeparator()
116 self
.amend_action
= self
.actions_menu
.addAction(N_('Amend Last Commit'))
117 self
.amend_action
.setIcon(icons
.edit())
118 self
.amend_action
.setCheckable(True)
119 self
.amend_action
.setShortcut(hotkeys
.AMEND
)
120 self
.amend_action
.setShortcutContext(Qt
.ApplicationShortcut
)
123 self
.bypass_commit_hooks_action
= self
.actions_menu
.addAction(
124 N_('Bypass Commit Hooks')
126 self
.bypass_commit_hooks_action
.setCheckable(True)
127 self
.bypass_commit_hooks_action
.setChecked(False)
130 self
.sign_action
= self
.actions_menu
.addAction(N_('Create Signed Commit'))
131 self
.sign_action
.setCheckable(True)
132 signcommits
= cfg
.get('cola.signcommits', default
=False)
133 self
.sign_action
.setChecked(signcommits
)
136 self
.check_spelling_action
= self
.actions_menu
.addAction(N_('Check Spelling'))
137 self
.check_spelling_action
.setCheckable(True)
138 spell_check
= prefs
.spellcheck(context
)
139 self
.check_spelling_action
.setChecked(spell_check
)
140 self
.toggle_check_spelling(spell_check
)
143 self
.autowrap_action
= self
.actions_menu
.addAction(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')
152 self
.load_commitmsg_menu
.aboutToShow
.connect(self
.build_commitmsg_menu
)
154 self
.fixup_commit_menu
= self
.actions_menu
.addMenu(N_('Fixup Previous Commit'))
155 self
.fixup_commit_menu
.aboutToShow
.connect(self
.build_fixup_menu
)
157 self
.toplayout
= qtutils
.hbox(
164 self
.toplayout
.setContentsMargins(
165 defs
.margin
, defs
.no_margin
, defs
.no_margin
, defs
.no_margin
168 self
.mainlayout
= qtutils
.vbox(
169 defs
.no_margin
, defs
.spacing
, self
.toplayout
, self
.description
171 self
.setLayout(self
.mainlayout
)
173 qtutils
.connect_button(self
.commit_button
, self
.commit
)
175 # Broadcast the amend mode
176 qtutils
.connect_action_bool(
177 self
.amend_action
, partial(cmds
.run(cmds
.AmendMode
), context
)
179 qtutils
.connect_action_bool(
180 self
.check_spelling_action
, self
.toggle_check_spelling
183 # Handle the one-off autowrapping
184 qtutils
.connect_action_bool(self
.autowrap_action
, self
.set_linebreak
)
186 self
.summary
.accepted
.connect(self
.focus_description
)
187 self
.summary
.down_pressed
.connect(self
.summary_cursor_down
)
189 self
.model
.commit_message_changed
.connect(
190 self
.set_commit_message
, type=Qt
.QueuedConnection
192 self
.commit_finished
.connect(self
._commit
_finished
, 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
)
200 # pylint: disable=no-member
201 self
.summary
.textChanged
.connect(self
.commit_summary_changed
)
202 self
.description
.textChanged
.connect(self
._commit
_message
_changed
)
203 self
.description
.leave
.connect(self
.focus_summary
)
205 self
.commit_group
.setEnabled(False)
207 self
.set_expandtab(prefs
.expandtab(context
))
208 self
.set_tabwidth(prefs
.tabwidth(context
))
209 self
.set_textwidth(prefs
.textwidth(context
))
210 self
.set_linebreak(prefs
.linebreak(context
))
214 commit_msg_path
= commit_message_path(context
)
216 commit_msg
= core
.read(commit_msg_path
)
217 model
.set_commitmsg(commit_msg
)
219 # Allow tab to jump from the summary to the description
220 self
.setTabOrder(self
.summary
, self
.description
)
221 self
.setFont(qtutils
.diff_font(context
))
222 self
.setFocusProxy(self
.summary
)
224 cfg
.user_config_changed
.connect(self
.config_changed
)
226 def config_changed(self
, key
, value
):
227 if key
!= prefs
.SPELL_CHECK
:
229 if get(self
.check_spelling_action
) == value
:
231 self
.check_spelling_action
.setChecked(value
)
232 self
.toggle_check_spelling(value
)
234 def set_initial_size(self
):
235 self
.setMaximumHeight(133)
236 QtCore
.QTimer
.singleShot(1, self
.restore_size
)
238 def restore_size(self
):
239 self
.setMaximumHeight(2**13)
241 def focus_summary(self
):
242 self
.summary
.setFocus()
244 def focus_description(self
):
245 self
.description
.setFocus()
247 def summary_cursor_down(self
):
248 """Handle the down key in the summary field
250 If the cursor is at the end of the line then focus the description.
251 Otherwise, move the cursor to the end of the line so that a
252 subsequence "down" press moves to the end of the line.
255 self
.focus_description()
257 def commit_message(self
, raw
=True):
258 """Return the commit message as a unicode string"""
259 summary
= get(self
.summary
)
261 description
= get(self
.description
)
263 description
= self
.formatted_description()
264 if summary
and description
:
265 return summary
+ '\n\n' + description
269 return '\n\n' + description
272 def formatted_description(self
):
273 text
= get(self
.description
)
274 if not self
._linebreak
:
276 return textwrap
.word_wrap(text
, self
._tabwidth
, self
._textwidth
)
278 def commit_summary_changed(self
):
279 """Respond to changes to the `summary` field
281 Newlines can enter the `summary` field when pasting, which is
282 undesirable. Break the pasted value apart into the separate
283 (summary, description) values and move the description over to the
284 "extended description" field.
287 value
= self
.summary
.value()
289 summary
, description
= value
.split('\n', 1)
290 description
= description
.lstrip('\n')
291 cur_description
= get(self
.description
)
293 description
= description
+ '\n' + cur_description
294 # this callback is triggered by changing `summary`
295 # so disable signals for `summary` only.
296 self
.summary
.set_value(summary
, block
=True)
297 self
.description
.set_value(description
)
298 self
._commit
_message
_changed
()
300 def _commit_message_changed(self
, _value
=None):
301 """Update the model when values change"""
302 message
= self
.commit_message()
303 self
.model
.set_commitmsg(message
, notify
=False)
304 self
.refresh_palettes()
305 self
.update_actions()
308 if not Interaction
.confirm(
309 N_('Clear commit message?'),
310 N_('The commit message will be cleared.'),
311 N_('This cannot be undone. Clear commit message?'),
312 N_('Clear commit message'),
314 icon
=icons
.discard(),
317 self
.model
.set_commitmsg('')
319 def update_actions(self
):
320 commit_enabled
= bool(get(self
.summary
))
321 self
.commit_group
.setEnabled(commit_enabled
)
323 def refresh_palettes(self
):
324 """Update the color palette for the hint text"""
325 self
.summary
.hint
.refresh()
326 self
.description
.hint
.refresh()
328 def set_commit_message(self
, message
):
329 """Set the commit message to match the observed model"""
330 # Parse the "summary" and "description" fields
331 lines
= message
.splitlines()
333 num_lines
= len(lines
)
341 # Message has a summary only
346 # Message has two lines; this is not a common case
348 description
= lines
[1]
351 # Summary and several description lines
354 # We usually skip this line but check just in case
355 description_lines
= lines
[1:]
357 description_lines
= lines
[2:]
358 description
= '\n'.join(description_lines
)
360 focus_summary
= not summary
361 focus_description
= not description
364 self
.summary
.set_value(summary
, block
=True)
367 self
.description
.set_value(description
, block
=True)
370 self
.refresh_palettes()
372 # Focus the empty summary or description
374 self
.summary
.setFocus()
375 elif focus_description
:
376 self
.description
.setFocus()
378 self
.summary
.cursor_position
.emit()
380 self
.update_actions()
382 def set_expandtab(self
, value
):
383 self
.description
.set_expandtab(value
)
385 def set_tabwidth(self
, width
):
386 self
._tabwidth
= width
387 self
.description
.set_tabwidth(width
)
389 def set_textwidth(self
, width
):
390 self
._textwidth
= width
391 self
.description
.set_textwidth(width
)
393 def set_linebreak(self
, brk
):
394 self
._linebreak
= brk
395 self
.description
.set_linebreak(brk
)
396 with qtutils
.BlockSignals(self
.autowrap_action
):
397 self
.autowrap_action
.setChecked(brk
)
399 def setFont(self
, font
):
400 """Pass the setFont() calls down to the text widgets"""
401 self
.summary
.setFont(font
)
402 self
.description
.setFont(font
)
404 def set_mode(self
, mode
):
405 can_amend
= not self
.model
.is_merging
406 checked
= mode
== self
.model
.mode_amend
407 with qtutils
.BlockSignals(self
.amend_action
):
408 self
.amend_action
.setEnabled(can_amend
)
409 self
.amend_action
.setChecked(checked
)
412 """Attempt to create a commit from the index and commit message."""
413 context
= self
.context
414 if not bool(get(self
.summary
)):
415 # Describe a good commit message
417 'Please supply a commit message.\n\n'
418 'A good commit message has the following format:\n\n'
419 '- First line: Describe in one sentence what you did.\n'
420 '- Second line: Blank\n'
421 '- Remaining lines: Describe why this change is good.\n'
423 Interaction
.log(error_msg
)
424 Interaction
.information(N_('Missing Commit Message'), error_msg
)
427 msg
= self
.commit_message(raw
=False)
429 # We either need to have something staged, or be merging.
430 # If there was a merge conflict resolved, there may not be anything
431 # to stage, but we still need to commit to complete the merge.
432 if not (self
.model
.staged
or self
.model
.is_merging
):
434 'No changes to commit.\n\n'
435 'You must stage at least 1 file before you can commit.'
437 if self
.model
.modified
:
438 informative_text
= N_(
439 'Would you like to stage and commit all modified files?'
441 if not Interaction
.confirm(
442 N_('Stage and commit?'),
445 N_('Stage and Commit'),
451 Interaction
.information(N_('Nothing to commit'), error_msg
)
453 cmds
.do(cmds
.StageModified
, context
)
455 # Warn that amending published commits is generally bad
456 amend
= get(self
.amend_action
)
457 check_published
= prefs
.check_published_commits(context
)
461 and self
.model
.is_commit_published()
462 and not Interaction
.confirm(
463 N_('Rewrite Published Commit?'),
465 'This commit has already been published.\n'
466 'This operation will rewrite published history.\n'
467 "You probably don't want to do this."
469 N_('Amend the published commit?'),
476 no_verify
= get(self
.bypass_commit_hooks_action
)
477 sign
= get(self
.sign_action
)
478 self
.bypass_commit_hooks_action
.setChecked(False)
480 task
= qtutils
.SimpleTask(
481 cmds
.run(cmds
.Commit
, context
, amend
, msg
, sign
, no_verify
=no_verify
)
483 self
.context
.runtask
.start(
485 finish
=self
.commit_finished
.emit
,
486 progress
=self
.commit_progress_bar
,
489 def _commit_finished(self
, task
):
490 """Reset widget state on completion of the commit task"""
491 title
= N_('Commit failed')
492 status
, out
, err
= task
.result
493 Interaction
.command(title
, 'git commit', status
, out
, err
)
496 def build_fixup_menu(self
):
497 self
.build_commits_menu(
498 cmds
.LoadFixupMessage
,
499 self
.fixup_commit_menu
,
500 self
.choose_fixup_commit
,
504 def build_commitmsg_menu(self
):
505 self
.build_commits_menu(
506 cmds
.LoadCommitMessageFromOID
,
507 self
.load_commitmsg_menu
,
508 self
.choose_commit_message
,
511 def build_commits_menu(self
, cmd
, menu
, chooser
, prefix
=''):
512 context
= self
.context
513 params
= dag
.DAG('HEAD', 6)
514 commits
= dag
.RepoReader(context
, params
)
517 for idx
, commit
in enumerate(commits
.get()):
518 menu_commits
.insert(0, commit
)
523 for commit
in menu_commits
:
524 menu
.addAction(prefix
+ commit
.summary
, cmds
.run(cmd
, context
, commit
.oid
))
526 if len(commits
) == 6:
528 menu
.addAction(N_('More...'), chooser
)
530 def choose_commit(self
, cmd
):
531 context
= self
.context
532 revs
, summaries
= gitcmds
.log_helper(context
)
533 oids
= select_commits(
534 context
, N_('Select Commit'), revs
, summaries
, multiselect
=False
539 cmds
.do(cmd
, context
, oid
)
541 def choose_commit_message(self
):
542 self
.choose_commit(cmds
.LoadCommitMessageFromOID
)
544 def choose_fixup_commit(self
):
545 self
.choose_commit(cmds
.LoadFixupMessage
)
547 def toggle_check_spelling(self
, enabled
):
548 spell_check
= self
.spellcheck
549 cfg
= self
.context
.cfg
551 if prefs
.spellcheck(self
.context
) != enabled
:
552 cfg
.set_user(prefs
.SPELL_CHECK
, enabled
)
553 if enabled
and not self
.spellcheck_initialized
:
554 # Add our name to the dictionary
555 self
.spellcheck_initialized
= True
556 user_name
= cfg
.get('user.name')
558 for part
in user_name
.split():
559 spell_check
.add_word(part
)
561 # Add our email address to the dictionary
562 user_email
= cfg
.get('user.email')
564 for part
in user_email
.split('@'):
565 for elt
in part
.split('.'):
566 spell_check
.add_word(elt
)
569 spell_check
.add_word('Acked')
570 spell_check
.add_word('Signed')
571 spell_check
.add_word('Closes')
572 spell_check
.add_word('Fixes')
574 self
.summary
.highlighter
.enable(enabled
)
575 self
.description
.highlighter
.enable(enabled
)
578 # pylint: disable=too-many-ancestors
579 class CommitSummaryLineEdit(SpellCheckLineEdit
):
580 """Text input field for the commit summary"""
582 down_pressed
= Signal()
585 def __init__(self
, context
, check
=None, parent
=None):
586 hint
= N_('Commit summary')
587 SpellCheckLineEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
588 self
._comment
_char
= None
589 self
._refresh
_config
()
591 self
.textChanged
.connect(self
._update
_summary
_text
, Qt
.QueuedConnection
)
592 context
.cfg
.updated
.connect(self
._refresh
_config
, type=Qt
.QueuedConnection
)
594 def _refresh_config(self
):
595 """Update comment char in response to config changes"""
596 self
._comment
_char
= prefs
.comment_char(self
.context
)
598 def _update_summary_text(self
):
599 """Prevent commit messages from starting with comment characters"""
601 if self
._comment
_char
and value
.startswith(self
._comment
_char
):
602 cursor
= self
.textCursor()
603 position
= cursor
.position()
605 value
= value
.lstrip()
606 if self
._comment
_char
:
607 value
= value
.lstrip(self
._comment
_char
).lstrip()
609 self
.set_value(value
, block
=True)
613 position
= max(0, min(position
- 1, len(value
) - 1))
614 cursor
.setPosition(position
)
615 self
.setTextCursor(cursor
)
617 def keyPressEvent(self
, event
):
618 """Allow "Enter" to focus into the extended description field"""
619 event_key
= event
.key()
626 SpellCheckLineEdit
.keyPressEvent(self
, event
)
629 # pylint: disable=too-many-ancestors
630 class CommitMessageTextEdit(SpellCheckTextEdit
):
633 def __init__(self
, context
, check
=None, parent
=None):
634 hint
= N_('Extended description...')
635 SpellCheckTextEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
637 self
.action_emit_leave
= qtutils
.add_action(
638 self
, 'Shift Tab', self
.leave
.emit
, hotkeys
.LEAVE
641 def keyPressEvent(self
, event
):
642 if event
.key() == Qt
.Key_Up
:
643 cursor
= self
.textCursor()
644 position
= cursor
.position()
646 # The cursor is at the beginning of the line.
647 # If we have selection then simply reset the cursor.
648 # Otherwise, emit a signal so that the parent can
650 if cursor
.hasSelection():
651 self
.set_cursor_position(0)
656 text_before
= self
.toPlainText()[:position
]
657 lines_before
= text_before
.count('\n')
658 if lines_before
== 0:
659 # If we're on the first line, but not at the
660 # beginning, then move the cursor to the beginning
662 if event
.modifiers() & Qt
.ShiftModifier
:
663 mode
= QtGui
.QTextCursor
.KeepAnchor
665 mode
= QtGui
.QTextCursor
.MoveAnchor
666 cursor
.setPosition(0, mode
)
667 self
.setTextCursor(cursor
)
670 elif event
.key() == Qt
.Key_Down
:
671 cursor
= self
.textCursor()
672 position
= cursor
.position()
673 all_text
= self
.toPlainText()
674 text_after
= all_text
[position
:]
675 lines_after
= text_after
.count('\n')
677 select
= event
.modifiers() & Qt
.ShiftModifier
678 mode
= anchor_mode(select
)
679 cursor
.setPosition(len(all_text
), mode
)
680 self
.setTextCursor(cursor
)
683 SpellCheckTextEdit
.keyPressEvent(self
, event
)
685 def setFont(self
, font
):
686 SpellCheckTextEdit
.setFont(self
, font
)
687 width
, height
= qtutils
.text_size(font
, 'MMMM')
688 self
.setMinimumSize(QtCore
.QSize(width
, height
* 2))