1 from __future__
import division
, absolute_import
, unicode_literals
4 from PyQt4
import QtGui
5 from PyQt4
import QtCore
6 from PyQt4
.QtCore
import Qt
7 from PyQt4
.QtCore
import SIGNAL
9 from cola
import actions
12 from cola
import gitcmds
13 from cola
import gitcfg
14 from cola
import hotkeys
15 from cola
import icons
16 from cola
import textwrap
17 from cola
import qtutils
18 from cola
.cmds
import Interaction
19 from cola
.gitcmds
import commit_message_path
20 from cola
.i18n
import N_
21 from cola
.models
import dag
22 from cola
.models
import prefs
23 from cola
.models
import selection
24 from cola
.utils
import Group
25 from cola
.widgets
import defs
26 from cola
.widgets
.selectcommits
import select_commits
27 from cola
.widgets
.spellcheck
import SpellCheckTextEdit
28 from cola
.widgets
.text
import HintedLineEdit
29 from cola
.compat
import ustr
32 class CommitMessageEditor(QtGui
.QWidget
):
33 def __init__(self
, model
, parent
):
34 QtGui
.QWidget
.__init
__(self
, parent
)
37 self
.spellcheck_initialized
= False
39 self
._linebreak
= None
40 self
._textwidth
= None
44 self
.signoff_action
= qtutils
.add_action(self
, cmds
.SignOff
.name(),
45 cmds
.run(cmds
.SignOff
),
47 self
.signoff_action
.setToolTip(N_('Sign off on this commit'))
49 self
.commit_action
= qtutils
.add_action(self
,
51 self
.commit
, hotkeys
.COMMIT
)
52 self
.commit_action
.setToolTip(N_('Commit staged changes'))
53 self
.clear_action
= qtutils
.add_action(self
, N_('Clear...'), self
.clear
)
55 self
.launch_editor
= actions
.launch_editor(self
)
56 self
.launch_difftool
= actions
.launch_difftool(self
)
57 self
.stage_or_unstage
= actions
.stage_or_unstage(self
)
59 self
.move_up
= actions
.move_up(self
)
60 self
.move_down
= actions
.move_down(self
)
63 self
.summary
= CommitSummaryLineEdit()
64 self
.summary
.setMinimumHeight(defs
.tool_button_height
)
65 self
.summary
.extra_actions
.append(self
.clear_action
)
66 self
.summary
.extra_actions
.append(None)
67 self
.summary
.extra_actions
.append(self
.signoff_action
)
68 self
.summary
.extra_actions
.append(self
.commit_action
)
69 self
.summary
.extra_actions
.append(None)
70 self
.summary
.extra_actions
.append(self
.launch_editor
)
71 self
.summary
.extra_actions
.append(self
.launch_difftool
)
72 self
.summary
.extra_actions
.append(self
.stage_or_unstage
)
73 self
.summary
.extra_actions
.append(None)
74 self
.summary
.extra_actions
.append(self
.move_up
)
75 self
.summary
.extra_actions
.append(self
.move_down
)
77 self
.description
= CommitMessageTextEdit()
78 self
.description
.extra_actions
.append(self
.clear_action
)
79 self
.description
.extra_actions
.append(None)
80 self
.description
.extra_actions
.append(self
.signoff_action
)
81 self
.description
.extra_actions
.append(self
.commit_action
)
82 self
.description
.extra_actions
.append(None)
83 self
.description
.extra_actions
.append(self
.launch_editor
)
84 self
.description
.extra_actions
.append(self
.launch_difftool
)
85 self
.description
.extra_actions
.append(self
.stage_or_unstage
)
86 self
.description
.extra_actions
.append(None)
87 self
.description
.extra_actions
.append(self
.move_up
)
88 self
.description
.extra_actions
.append(self
.move_down
)
90 commit_button_tooltip
= N_('Commit staged changes\n'
91 'Shortcut: Ctrl+Enter')
92 self
.commit_button
= qtutils
.create_toolbutton(
93 text
=N_('Commit@@verb'), tooltip
=commit_button_tooltip
,
94 icon
=icons
.download())
95 self
.commit_group
= Group(self
.commit_action
, self
.commit_button
)
97 self
.actions_menu
= QtGui
.QMenu()
98 self
.actions_button
= qtutils
.create_toolbutton(
99 icon
=icons
.configure(), tooltip
=N_('Actions...'))
100 self
.actions_button
.setMenu(self
.actions_menu
)
101 self
.actions_button
.setPopupMode(QtGui
.QToolButton
.InstantPopup
)
102 qtutils
.hide_button_menu_indicator(self
.actions_button
)
104 self
.actions_menu
.addAction(self
.signoff_action
)
105 self
.actions_menu
.addAction(self
.commit_action
)
106 self
.actions_menu
.addSeparator()
109 self
.amend_action
= self
.actions_menu
.addAction(
110 N_('Amend Last Commit'))
111 self
.amend_action
.setCheckable(True)
112 self
.amend_action
.setShortcut(hotkeys
.AMEND
)
113 self
.amend_action
.setShortcutContext(Qt
.ApplicationShortcut
)
116 self
.bypass_commit_hooks_action
= self
.actions_menu
.addAction(
117 N_('Bypass Commit Hooks'))
118 self
.bypass_commit_hooks_action
.setCheckable(True)
119 self
.bypass_commit_hooks_action
.setChecked(False)
122 cfg
= gitcfg
.current()
123 self
.sign_action
= self
.actions_menu
.addAction(
124 N_('Create Signed Commit'))
125 self
.sign_action
.setCheckable(True)
126 self
.sign_action
.setChecked(cfg
.get('cola.signcommits', False))
129 self
.check_spelling_action
= self
.actions_menu
.addAction(
130 N_('Check Spelling'))
131 self
.check_spelling_action
.setCheckable(True)
132 self
.check_spelling_action
.setChecked(False)
135 self
.autowrap_action
= self
.actions_menu
.addAction(
136 N_('Auto-Wrap Lines'))
137 self
.autowrap_action
.setCheckable(True)
138 self
.autowrap_action
.setChecked(prefs
.linebreak())
141 self
.actions_menu
.addSeparator()
142 self
.load_commitmsg_menu
= self
.actions_menu
.addMenu(
143 N_('Load Previous Commit Message'))
144 self
.connect(self
.load_commitmsg_menu
, SIGNAL('aboutToShow()'),
145 self
.build_commitmsg_menu
)
147 self
.fixup_commit_menu
= self
.actions_menu
.addMenu(
148 N_('Fixup Previous Commit'))
149 self
.connect(self
.fixup_commit_menu
, SIGNAL('aboutToShow()'),
150 self
.build_fixup_menu
)
152 self
.toplayout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
,
153 self
.actions_button
, self
.summary
,
155 self
.toplayout
.setContentsMargins(defs
.margin
, defs
.no_margin
,
156 defs
.no_margin
, defs
.no_margin
)
158 self
.mainlayout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
,
159 self
.toplayout
, self
.description
)
160 self
.setLayout(self
.mainlayout
)
162 qtutils
.connect_button(self
.commit_button
, self
.commit
)
164 # Broadcast the amend mode
165 qtutils
.connect_action_bool(self
.amend_action
, cmds
.run(cmds
.AmendMode
))
166 qtutils
.connect_action_bool(self
.check_spelling_action
,
167 self
.toggle_check_spelling
)
169 # Handle the one-off autowrapping
170 qtutils
.connect_action_bool(self
.autowrap_action
, self
.set_linebreak
)
172 qtutils
.add_action(self
.summary
, N_('Move Down'),
173 self
.focus_description
, *hotkeys
.ACCEPT
)
175 qtutils
.add_action(self
.summary
, N_('Move Down'),
176 self
.summary_cursor_down
, hotkeys
.DOWN
)
178 self
.selection_model
= selection_model
= selection
.selection_model()
179 selection_model
.add_observer(selection_model
.message_selection_changed
,
182 self
.model
.add_observer(self
.model
.message_commit_message_changed
,
183 self
._set
_commit
_message
)
185 self
.connect(self
, SIGNAL('set_commit_message(PyQt_PyObject)'),
186 self
.set_commit_message
, Qt
.QueuedConnection
)
188 self
.connect(self
.summary
, SIGNAL('cursorPosition(int,int)'),
191 self
.connect(self
.description
, SIGNAL('cursorPosition(int,int)'),
192 # description starts at line 2
193 lambda row
, col
: self
.emit_position(row
+ 2, col
))
195 # Keep model informed of changes
196 self
.connect(self
.summary
, SIGNAL('textChanged(QString)'),
197 self
.commit_summary_changed
)
199 self
.connect(self
.description
, SIGNAL('textChanged()'),
200 self
.commit_message_changed
)
202 self
.connect(self
.description
, SIGNAL('leave()'),
205 self
.connect(self
, SIGNAL('update()'),
206 self
._update
_callback
, Qt
.QueuedConnection
)
208 self
.setFont(qtutils
.diff_font())
210 self
.summary
.hint
.enable(True)
211 self
.description
.hint
.enable(True)
213 self
.commit_group
.setEnabled(False)
215 self
.setFocusProxy(self
.summary
)
217 self
.set_tabwidth(prefs
.tabwidth())
218 self
.set_textwidth(prefs
.textwidth())
219 self
.set_linebreak(prefs
.linebreak())
223 commit_msg_path
= commit_message_path()
225 commit_msg
= core
.read(commit_msg_path
)
226 self
.set_commit_message(commit_msg
)
228 # Allow tab to jump from the summary to the description
229 self
.setTabOrder(self
.summary
, self
.description
)
232 self
.emit(SIGNAL('update()'))
234 def _update_callback(self
):
235 enabled
= self
.model
.stageable() or self
.model
.unstageable()
236 if self
.model
.stageable():
240 self
.stage_or_unstage
.setEnabled(enabled
)
241 self
.stage_or_unstage
.setText(text
)
243 def set_initial_size(self
):
244 self
.setMaximumHeight(133)
245 QtCore
.QTimer
.singleShot(1, self
.restore_size
)
247 def restore_size(self
):
248 self
.setMaximumHeight(2 ** 13)
250 def focus_summary(self
):
251 self
.summary
.setFocus()
253 def focus_description(self
):
254 self
.description
.setFocus()
256 def summary_cursor_down(self
):
257 """Handle the down key in the summary field
259 If the cursor is at the end of the line then focus the description.
260 Otherwise, move the cursor to the end of the line so that a
261 subsequence "down" press moves to the end of the line.
264 cur_position
= self
.summary
.cursorPosition()
265 end_position
= len(self
.summary
.value())
266 if cur_position
== end_position
:
267 self
.focus_description()
269 self
.summary
.setCursorPosition(end_position
)
271 def commit_message(self
, raw
=True):
272 """Return the commit message as a unicode string"""
273 summary
= self
.summary
.value()
275 description
= self
.description
.value()
277 description
= self
.formatted_description()
278 if summary
and description
:
279 return summary
+ '\n\n' + description
283 return '\n\n' + description
287 def formatted_description(self
):
288 text
= self
.description
.value()
289 if not self
._linebreak
:
291 return textwrap
.word_wrap(text
, self
._tabwidth
, self
._textwidth
)
293 def commit_summary_changed(self
, value
):
294 """Respond to changes to the `summary` field
296 Newlines can enter the `summary` field when pasting, which is
297 undesirable. Break the pasted value apart into the separate
298 (summary, description) values and move the description over to the
299 "extended description" field.
304 summary
, description
= value
.split('\n', 1)
305 description
= description
.lstrip('\n')
306 cur_description
= self
.description
.value()
308 description
= description
+ '\n' + cur_description
309 # this callback is triggered by changing `summary`
310 # so disable signals for `summary` only.
311 self
.summary
.set_value(summary
, block
=True)
312 self
.description
.set_value(description
)
313 self
.commit_message_changed()
315 def commit_message_changed(self
, value
=None):
316 """Update the model when values change"""
317 message
= self
.commit_message()
318 self
.model
.set_commitmsg(message
, notify
=False)
319 self
.refresh_palettes()
320 self
.update_actions()
323 if not qtutils
.confirm(
324 N_('Clear commit message?'),
325 N_('The commit message will be cleared.'),
326 N_('This cannot be undone. Clear commit message?'),
327 N_('Clear commit message'), default
=True, icon
=icons
.discard()):
329 self
.model
.set_commitmsg('')
331 def update_actions(self
):
332 commit_enabled
= bool(self
.summary
.value())
333 self
.commit_group
.setEnabled(commit_enabled
)
335 def refresh_palettes(self
):
336 """Update the color palette for the hint text"""
337 self
.summary
.hint
.refresh()
338 self
.description
.hint
.refresh()
340 def _set_commit_message(self
, message
):
341 self
.emit(SIGNAL('set_commit_message(PyQt_PyObject)'), message
)
343 def set_commit_message(self
, message
):
344 """Set the commit message to match the observed model"""
345 # Parse the "summary" and "description" fields
347 lines
= umsg
.splitlines()
349 num_lines
= len(lines
)
357 # Message has a summary only
362 # Message has two lines; this is not a common case
364 description
= lines
[1]
367 # Summary and several description lines
370 # We usually skip this line but check just in case
371 description_lines
= lines
[1:]
373 description_lines
= lines
[2:]
374 description
= '\n'.join(description_lines
)
376 focus_summary
= not summary
377 focus_description
= not description
380 if not summary
and not self
.summary
.hasFocus():
381 self
.summary
.hint
.enable(True)
383 self
.summary
.set_value(summary
, block
=True)
386 if not description
and not self
.description
.hasFocus():
387 self
.description
.hint
.enable(True)
389 self
.description
.set_value(description
, block
=True)
392 self
.refresh_palettes()
394 # Focus the empty summary or description
396 self
.summary
.setFocus()
397 elif focus_description
:
398 self
.description
.setFocus()
400 self
.summary
.cursor_position
.emit()
402 self
.update_actions()
404 def set_tabwidth(self
, width
):
405 self
._tabwidth
= width
406 self
.description
.set_tabwidth(width
)
408 def set_textwidth(self
, width
):
409 self
._textwidth
= width
410 self
.description
.set_textwidth(width
)
412 def set_linebreak(self
, brk
):
413 self
._linebreak
= brk
414 self
.description
.set_linebreak(brk
)
415 blocksignals
= self
.autowrap_action
.blockSignals(True)
416 self
.autowrap_action
.setChecked(brk
)
417 self
.autowrap_action
.blockSignals(blocksignals
)
419 def setFont(self
, font
):
420 """Pass the setFont() calls down to the text widgets"""
421 self
.summary
.setFont(font
)
422 self
.description
.setFont(font
)
424 def set_mode(self
, mode
):
425 can_amend
= not self
.model
.is_merging
426 checked
= (mode
== self
.model
.mode_amend
)
427 blocksignals
= self
.amend_action
.blockSignals(True)
428 self
.amend_action
.setEnabled(can_amend
)
429 self
.amend_action
.setChecked(checked
)
430 self
.amend_action
.blockSignals(blocksignals
)
432 def emit_position(self
, row
, col
):
433 self
.emit(SIGNAL('cursorPosition(int,int)'), row
, col
)
436 """Attempt to create a commit from the index and commit message."""
437 if not bool(self
.summary
.value()):
438 # Describe a good commit message
440 'Please supply a commit message.\n\n'
441 'A good commit message has the following format:\n\n'
442 '- First line: Describe in one sentence what you did.\n'
443 '- Second line: Blank\n'
444 '- Remaining lines: Describe why this change is good.\n')
445 Interaction
.log(error_msg
)
446 Interaction
.information(N_('Missing Commit Message'), error_msg
)
449 msg
= self
.commit_message(raw
=False)
451 if not self
.model
.staged
:
453 'No changes to commit.\n\n'
454 'You must stage at least 1 file before you can commit.')
455 if self
.model
.modified
:
456 informative_text
= N_('Would you like to stage and '
457 'commit all modified files?')
458 if not qtutils
.confirm(
459 N_('Stage and commit?'), error_msg
, informative_text
,
460 N_('Stage and Commit'),
461 default
=True, icon
=icons
.save()):
464 Interaction
.information(N_('Nothing to commit'), error_msg
)
466 cmds
.do(cmds
.StageModified
)
468 # Warn that amending published commits is generally bad
469 amend
= self
.amend_action
.isChecked()
470 if (amend
and self
.model
.is_commit_published() and
472 N_('Rewrite Published Commit?'),
473 N_('This commit has already been published.\n'
474 'This operation will rewrite published history.\n'
475 'You probably don\'t want to do this.'),
476 N_('Amend the published commit?'),
477 N_('Amend Commit'), default
=False, icon
=icons
.save())):
479 no_verify
= self
.bypass_commit_hooks_action
.isChecked()
480 sign
= self
.sign_action
.isChecked()
481 status
, out
, err
= cmds
.do(cmds
.Commit
, amend
, msg
, sign
,
484 Interaction
.critical(N_('Commit failed'),
485 N_('"git commit" returned exit code %s') %
489 def build_fixup_menu(self
):
490 self
.build_commits_menu(cmds
.LoadFixupMessage
,
491 self
.fixup_commit_menu
,
492 self
.choose_fixup_commit
,
495 def build_commitmsg_menu(self
):
496 self
.build_commits_menu(cmds
.LoadCommitMessageFromSHA1
,
497 self
.load_commitmsg_menu
,
498 self
.choose_commit_message
)
500 def build_commits_menu(self
, cmd
, menu
, chooser
, prefix
=''):
501 ctx
= dag
.DAG('HEAD', 6)
502 commits
= dag
.RepoReader(ctx
)
505 for idx
, c
in enumerate(commits
):
506 menu_commits
.insert(0, c
)
511 for c
in menu_commits
:
512 menu
.addAction(prefix
+ c
.summary
, cmds
.run(cmd
, c
.sha1
))
514 if len(commits
) == 6:
516 menu
.addAction(N_('More...'), chooser
)
519 def choose_commit(self
, cmd
):
520 revs
, summaries
= gitcmds
.log_helper()
521 sha1s
= select_commits(N_('Select Commit'), revs
, summaries
,
528 def choose_commit_message(self
):
529 self
.choose_commit(cmds
.LoadCommitMessageFromSHA1
)
531 def choose_fixup_commit(self
):
532 self
.choose_commit(cmds
.LoadFixupMessage
)
534 def toggle_check_spelling(self
, enabled
):
535 spellcheck
= self
.description
.spellcheck
537 if enabled
and not self
.spellcheck_initialized
:
538 # Add our name to the dictionary
539 self
.spellcheck_initialized
= True
540 cfg
= gitcfg
.current()
541 user_name
= cfg
.get('user.name')
543 for part
in user_name
.split():
544 spellcheck
.add_word(part
)
546 # Add our email address to the dictionary
547 user_email
= cfg
.get('user.email')
549 for part
in user_email
.split('@'):
550 for elt
in part
.split('.'):
551 spellcheck
.add_word(elt
)
554 spellcheck
.add_word('Acked')
555 spellcheck
.add_word('Signed')
556 spellcheck
.add_word('Closes')
557 spellcheck
.add_word('Fixes')
559 self
.description
.highlighter
.enable(enabled
)
562 class CommitSummaryLineEdit(HintedLineEdit
):
564 def __init__(self
, parent
=None):
565 hint
= N_('Commit summary')
566 HintedLineEdit
.__init
__(self
, hint
, parent
)
567 self
.extra_actions
= []
569 comment_char
= prefs
.comment_char()
570 re_comment_char
= re
.escape(comment_char
)
571 regex
= QtCore
.QRegExp(r
'^[^%s \t].*' % re_comment_char
)
572 self
._validator
= QtGui
.QRegExpValidator(regex
, self
)
573 self
.setValidator(self
._validator
)
575 def contextMenuEvent(self
, event
):
576 menu
= self
.createStandardContextMenu()
577 if self
.extra_actions
:
579 for action
in self
.extra_actions
:
583 menu
.addAction(action
)
584 menu
.exec_(self
.mapToGlobal(event
.pos()))
587 class CommitMessageTextEdit(SpellCheckTextEdit
):
589 def __init__(self
, parent
=None):
590 hint
= N_('Extended description...')
591 SpellCheckTextEdit
.__init
__(self
, hint
, parent
)
592 self
.extra_actions
= []
594 self
.action_emit_leave
= qtutils
.add_action(self
,
595 'Shift Tab', self
.emit_leave
, hotkeys
.LEAVE
)
597 def contextMenuEvent(self
, event
):
598 menu
, spell_menu
= self
.context_menu()
599 if self
.extra_actions
:
601 for action
in self
.extra_actions
:
605 menu
.addAction(action
)
606 menu
.exec_(self
.mapToGlobal(event
.pos()))
608 def keyPressEvent(self
, event
):
609 if event
.key() == Qt
.Key_Up
:
610 cursor
= self
.textCursor()
611 position
= cursor
.position()
613 # The cursor is at the beginning of the line.
614 # If we have selection then simply reset the cursor.
615 # Otherwise, emit a signal so that the parent can
617 if cursor
.hasSelection():
618 cursor
.setPosition(0)
619 self
.setTextCursor(cursor
)
624 text_before
= ustr(self
.toPlainText())[:position
]
625 lines_before
= text_before
.count('\n')
626 if lines_before
== 0:
627 # If we're on the first line, but not at the
628 # beginning, then move the cursor to the beginning
630 if event
.modifiers() & Qt
.ShiftModifier
:
631 mode
= QtGui
.QTextCursor
.KeepAnchor
633 mode
= QtGui
.QTextCursor
.MoveAnchor
634 cursor
.setPosition(0, mode
)
635 self
.setTextCursor(cursor
)
638 elif event
.key() == Qt
.Key_Down
:
639 cursor
= self
.textCursor()
640 position
= cursor
.position()
641 all_text
= ustr(self
.toPlainText())
642 text_after
= all_text
[position
:]
643 lines_after
= text_after
.count('\n')
645 if event
.modifiers() & Qt
.ShiftModifier
:
646 mode
= QtGui
.QTextCursor
.KeepAnchor
648 mode
= QtGui
.QTextCursor
.MoveAnchor
649 cursor
.setPosition(len(all_text
), mode
)
650 self
.setTextCursor(cursor
)
653 SpellCheckTextEdit
.keyPressEvent(self
, event
)
655 def emit_leave(self
):
656 self
.emit(SIGNAL('leave()'))
658 def setFont(self
, font
):
659 SpellCheckTextEdit
.setFont(self
, font
)
660 fm
= self
.fontMetrics()
661 self
.setMinimumSize(QtCore
.QSize(1, fm
.height() * 2))