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 auto-wrapping
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
)
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
)
203 self
.commit_group
.setEnabled(False)
205 self
.set_expandtab(prefs
.expandtab(context
))
206 self
.set_tabwidth(prefs
.tabwidth(context
))
207 self
.set_textwidth(prefs
.textwidth(context
))
208 self
.set_linebreak(prefs
.linebreak(context
))
212 commit_msg_path
= commit_message_path(context
)
214 commit_msg
= core
.read(commit_msg_path
)
215 model
.set_commitmsg(commit_msg
)
217 # Allow tab to jump from the summary to the description
218 self
.setTabOrder(self
.summary
, self
.description
)
219 self
.setFont(qtutils
.diff_font(context
))
220 self
.setFocusProxy(self
.summary
)
222 cfg
.user_config_changed
.connect(self
.config_changed
)
224 def config_changed(self
, key
, value
):
225 if key
!= prefs
.SPELL_CHECK
:
227 if get(self
.check_spelling_action
) == value
:
229 self
.check_spelling_action
.setChecked(value
)
230 self
.toggle_check_spelling(value
)
232 def set_initial_size(self
):
233 self
.setMaximumHeight(133)
234 QtCore
.QTimer
.singleShot(1, self
.restore_size
)
236 def restore_size(self
):
237 self
.setMaximumHeight(2**13)
239 def focus_summary(self
):
240 self
.summary
.setFocus()
242 def focus_description(self
):
243 self
.description
.setFocus()
245 def summary_cursor_down(self
):
246 """Handle the down key in the summary field
248 If the cursor is at the end of the line then focus the description.
249 Otherwise, move the cursor to the end of the line so that a
250 subsequence "down" press moves to the end of the line.
253 self
.focus_description()
255 def commit_message(self
, raw
=True):
256 """Return the commit message as a Unicode string"""
257 summary
= get(self
.summary
)
259 description
= get(self
.description
)
261 description
= self
.formatted_description()
262 if summary
and description
:
263 return summary
+ '\n\n' + description
267 return '\n\n' + description
270 def formatted_description(self
):
271 text
= get(self
.description
)
272 if not self
._linebreak
:
274 return textwrap
.word_wrap(text
, self
._tabwidth
, self
._textwidth
)
276 def commit_summary_changed(self
):
277 """Respond to changes to the `summary` field
279 Newlines can enter the `summary` field when pasting, which is
280 undesirable. Break the pasted value apart into the separate
281 (summary, description) values and move the description over to the
282 "extended description" field.
285 value
= self
.summary
.value()
287 summary
, description
= value
.split('\n', 1)
288 description
= description
.lstrip('\n')
289 cur_description
= get(self
.description
)
291 description
= description
+ '\n' + cur_description
292 # this callback is triggered by changing `summary`
293 # so disable signals for `summary` only.
294 self
.summary
.set_value(summary
, block
=True)
295 self
.description
.set_value(description
)
296 self
._commit
_message
_changed
()
298 def _commit_message_changed(self
, _value
=None):
299 """Update the model when values change"""
300 message
= self
.commit_message()
301 self
.model
.set_commitmsg(message
, notify
=False)
302 self
.refresh_palettes()
303 self
.update_actions()
306 if not Interaction
.confirm(
307 N_('Clear commit message?'),
308 N_('The commit message will be cleared.'),
309 N_('This cannot be undone. Clear commit message?'),
310 N_('Clear commit message'),
312 icon
=icons
.discard(),
315 self
.model
.set_commitmsg('')
317 def update_actions(self
):
318 commit_enabled
= bool(get(self
.summary
))
319 self
.commit_group
.setEnabled(commit_enabled
)
321 def refresh_palettes(self
):
322 """Update the color palette for the hint text"""
323 self
.summary
.hint
.refresh()
324 self
.description
.hint
.refresh()
326 def set_commit_message(self
, message
):
327 """Set the commit message to match the observed model"""
328 # Parse the "summary" and "description" fields
329 lines
= message
.splitlines()
331 num_lines
= len(lines
)
339 # Message has a summary only
344 # Message has two lines; this is not a common case
346 description
= lines
[1]
349 # Summary and several description lines
352 # We usually skip this line but check just in case
353 description_lines
= lines
[1:]
355 description_lines
= lines
[2:]
356 description
= '\n'.join(description_lines
)
358 focus_summary
= not summary
359 focus_description
= not description
362 self
.summary
.set_value(summary
, block
=True)
365 self
.description
.set_value(description
, block
=True)
368 self
.refresh_palettes()
370 # Focus the empty summary or description
372 self
.summary
.setFocus()
373 elif focus_description
:
374 self
.description
.setFocus()
376 self
.summary
.cursor_position
.emit()
378 self
.update_actions()
380 def set_expandtab(self
, value
):
381 self
.description
.set_expandtab(value
)
383 def set_tabwidth(self
, width
):
384 self
._tabwidth
= width
385 self
.description
.set_tabwidth(width
)
387 def set_textwidth(self
, width
):
388 self
._textwidth
= width
389 self
.description
.set_textwidth(width
)
391 def set_linebreak(self
, brk
):
392 self
._linebreak
= brk
393 self
.description
.set_linebreak(brk
)
394 with qtutils
.BlockSignals(self
.autowrap_action
):
395 self
.autowrap_action
.setChecked(brk
)
397 def setFont(self
, font
):
398 """Pass the setFont() calls down to the text widgets"""
399 self
.summary
.setFont(font
)
400 self
.description
.setFont(font
)
402 def set_mode(self
, mode
):
403 can_amend
= not self
.model
.is_merging
404 checked
= mode
== self
.model
.mode_amend
405 with qtutils
.BlockSignals(self
.amend_action
):
406 self
.amend_action
.setEnabled(can_amend
)
407 self
.amend_action
.setChecked(checked
)
410 """Attempt to create a commit from the index and commit message."""
411 context
= self
.context
412 if not bool(get(self
.summary
)):
413 # Describe a good commit message
415 'Please supply a commit message.\n\n'
416 'A good commit message has the following format:\n\n'
417 '- First line: Describe in one sentence what you did.\n'
418 '- Second line: Blank\n'
419 '- Remaining lines: Describe why this change is good.\n'
421 Interaction
.log(error_msg
)
422 Interaction
.information(N_('Missing Commit Message'), error_msg
)
425 msg
= self
.commit_message(raw
=False)
427 # We either need to have something staged, or be merging.
428 # If there was a merge conflict resolved, there may not be anything
429 # to stage, but we still need to commit to complete the merge.
430 if not (self
.model
.staged
or self
.model
.is_merging
):
432 'No changes to commit.\n\n'
433 'You must stage at least 1 file before you can commit.'
435 if self
.model
.modified
:
436 informative_text
= N_(
437 'Would you like to stage and commit all modified files?'
439 if not Interaction
.confirm(
440 N_('Stage and commit?'),
443 N_('Stage and Commit'),
449 Interaction
.information(N_('Nothing to commit'), error_msg
)
451 cmds
.do(cmds
.StageModified
, context
)
453 # Warn that amending published commits is generally bad
454 amend
= get(self
.amend_action
)
455 check_published
= prefs
.check_published_commits(context
)
459 and self
.model
.is_commit_published()
460 and not Interaction
.confirm(
461 N_('Rewrite Published Commit?'),
463 'This commit has already been published.\n'
464 'This operation will rewrite published history.\n'
465 "You probably don't want to do this."
467 N_('Amend the published commit?'),
474 no_verify
= get(self
.bypass_commit_hooks_action
)
475 sign
= get(self
.sign_action
)
476 self
.bypass_commit_hooks_action
.setChecked(False)
478 task
= qtutils
.SimpleTask(
479 cmds
.run(cmds
.Commit
, context
, amend
, msg
, sign
, no_verify
=no_verify
)
481 self
.context
.runtask
.start(
483 finish
=self
.commit_finished
.emit
,
484 progress
=self
.commit_progress_bar
,
487 def _commit_finished(self
, task
):
488 """Reset widget state on completion of the commit task"""
489 title
= N_('Commit failed')
490 status
, out
, err
= task
.result
491 Interaction
.command(title
, 'git commit', status
, out
, err
)
494 def build_fixup_menu(self
):
495 self
.build_commits_menu(
496 cmds
.LoadFixupMessage
,
497 self
.fixup_commit_menu
,
498 self
.choose_fixup_commit
,
502 def build_commitmsg_menu(self
):
503 self
.build_commits_menu(
504 cmds
.LoadCommitMessageFromOID
,
505 self
.load_commitmsg_menu
,
506 self
.choose_commit_message
,
509 def build_commits_menu(self
, cmd
, menu
, chooser
, prefix
=''):
510 context
= self
.context
511 params
= dag
.DAG('HEAD', 6)
512 commits
= dag
.RepoReader(context
, params
)
515 for idx
, commit
in enumerate(commits
.get()):
516 menu_commits
.insert(0, commit
)
521 for commit
in menu_commits
:
522 menu
.addAction(prefix
+ commit
.summary
, cmds
.run(cmd
, context
, commit
.oid
))
524 if len(commits
) == 6:
526 menu
.addAction(N_('More...'), chooser
)
528 def choose_commit(self
, cmd
):
529 context
= self
.context
530 revs
, summaries
= gitcmds
.log_helper(context
)
531 oids
= select_commits(
532 context
, N_('Select Commit'), revs
, summaries
, multiselect
=False
537 cmds
.do(cmd
, context
, oid
)
539 def choose_commit_message(self
):
540 self
.choose_commit(cmds
.LoadCommitMessageFromOID
)
542 def choose_fixup_commit(self
):
543 self
.choose_commit(cmds
.LoadFixupMessage
)
545 def toggle_check_spelling(self
, enabled
):
546 spell_check
= self
.spellcheck
547 cfg
= self
.context
.cfg
549 if prefs
.spellcheck(self
.context
) != enabled
:
550 cfg
.set_user(prefs
.SPELL_CHECK
, enabled
)
551 if enabled
and not self
.spellcheck_initialized
:
552 # Add our name to the dictionary
553 self
.spellcheck_initialized
= True
554 user_name
= cfg
.get('user.name')
556 for part
in user_name
.split():
557 spell_check
.add_word(part
)
559 # Add our email address to the dictionary
560 user_email
= cfg
.get('user.email')
562 for part
in user_email
.split('@'):
563 for elt
in part
.split('.'):
564 spell_check
.add_word(elt
)
567 spell_check
.add_word('Acked')
568 spell_check
.add_word('Signed')
569 spell_check
.add_word('Closes')
570 spell_check
.add_word('Fixes')
572 self
.summary
.highlighter
.enable(enabled
)
573 self
.description
.highlighter
.enable(enabled
)
576 class CommitSummaryLineEdit(SpellCheckLineEdit
):
577 """Text input field for the commit summary"""
579 down_pressed
= Signal()
582 def __init__(self
, context
, check
=None, parent
=None):
583 hint
= N_('Commit summary')
584 SpellCheckLineEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
585 self
._comment
_char
= None
586 self
._refresh
_config
()
588 self
.textChanged
.connect(self
._update
_summary
_text
, Qt
.QueuedConnection
)
589 context
.cfg
.updated
.connect(self
._refresh
_config
, type=Qt
.QueuedConnection
)
591 def _refresh_config(self
):
592 """Update comment char in response to config changes"""
593 self
._comment
_char
= prefs
.comment_char(self
.context
)
595 def _update_summary_text(self
):
596 """Prevent commit messages from starting with comment characters"""
598 if self
._comment
_char
and value
.startswith(self
._comment
_char
):
599 cursor
= self
.textCursor()
600 position
= cursor
.position()
602 value
= value
.lstrip()
603 if self
._comment
_char
:
604 value
= value
.lstrip(self
._comment
_char
).lstrip()
606 self
.set_value(value
, block
=True)
610 position
= max(0, min(position
- 1, len(value
) - 1))
611 cursor
.setPosition(position
)
612 self
.setTextCursor(cursor
)
614 def keyPressEvent(self
, event
):
615 """Allow "Enter" to focus into the extended description field"""
616 event_key
= event
.key()
623 SpellCheckLineEdit
.keyPressEvent(self
, event
)
626 class CommitMessageTextEdit(SpellCheckTextEdit
):
629 def __init__(self
, context
, check
=None, parent
=None):
630 hint
= N_('Extended description...')
631 SpellCheckTextEdit
.__init
__(self
, context
, hint
, check
=check
, parent
=parent
)
633 self
.action_emit_leave
= qtutils
.add_action(
634 self
, 'Shift Tab', self
.leave
.emit
, hotkeys
.LEAVE
637 def keyPressEvent(self
, event
):
638 if event
.key() == Qt
.Key_Up
:
639 cursor
= self
.textCursor()
640 position
= cursor
.position()
642 # The cursor is at the beginning of the line.
643 # If we have selection then simply reset the cursor.
644 # Otherwise, emit a signal so that the parent can
646 if cursor
.hasSelection():
647 self
.set_cursor_position(0)
652 text_before
= self
.toPlainText()[:position
]
653 lines_before
= text_before
.count('\n')
654 if lines_before
== 0:
655 # If we're on the first line, but not at the
656 # beginning, then move the cursor to the beginning
658 if event
.modifiers() & Qt
.ShiftModifier
:
659 mode
= QtGui
.QTextCursor
.KeepAnchor
661 mode
= QtGui
.QTextCursor
.MoveAnchor
662 cursor
.setPosition(0, mode
)
663 self
.setTextCursor(cursor
)
666 elif event
.key() == Qt
.Key_Down
:
667 cursor
= self
.textCursor()
668 position
= cursor
.position()
669 all_text
= self
.toPlainText()
670 text_after
= all_text
[position
:]
671 lines_after
= text_after
.count('\n')
673 select
= event
.modifiers() & Qt
.ShiftModifier
674 mode
= anchor_mode(select
)
675 cursor
.setPosition(len(all_text
), mode
)
676 self
.setTextCursor(cursor
)
679 SpellCheckTextEdit
.keyPressEvent(self
, event
)
681 def setFont(self
, font
):
682 SpellCheckTextEdit
.setFont(self
, font
)
683 width
, height
= qtutils
.text_size(font
, 'MMMM')
684 self
.setMinimumSize(QtCore
.QSize(width
, height
* 2))