2 from __future__
import absolute_import
, division
, print_function
, unicode_literals
5 from argparse
import ArgumentParser
6 from functools
import partial
, reduce
8 from cola
import app
# prints a message if Qt cannot be found
9 from qtpy
import QtCore
10 from qtpy
import QtGui
11 from qtpy
import QtWidgets
12 from qtpy
.QtCore
import Qt
13 from qtpy
.QtCore
import Signal
15 # pylint: disable=ungrouped-imports
17 from cola
import difftool
18 from cola
import hotkeys
19 from cola
import icons
20 from cola
import qtutils
21 from cola
import utils
22 from cola
.i18n
import N_
23 from cola
.models
import dag
24 from cola
.models
import prefs
25 from cola
.widgets
import defs
26 from cola
.widgets
import filelist
27 from cola
.widgets
import diff
28 from cola
.widgets
import standard
29 from cola
.widgets
import text
37 UPDATE_REF
= 'update-ref'
46 COMMAND_IDX
= {cmd_
: idx_
for idx_
, cmd_
in enumerate(COMMANDS
)}
59 """Start a git-cola-sequence-editor session"""
61 context
= app
.application_init(args
)
62 view
= new_window(context
, args
.filename
)
63 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
67 def stop(_context
, _view
):
68 """All done, cleanup"""
69 QtCore
.QThreadPool
.globalInstance().waitForDone()
73 parser
= ArgumentParser()
75 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
77 app
.add_common_arguments(parser
)
78 return parser
.parse_args()
81 def new_window(context
, filename
):
82 window
= MainWindow(context
)
83 editor
= Editor(context
, filename
, parent
=window
)
84 window
.set_editor(editor
)
89 """Expand shorthand commands into their full name"""
90 return ABBREV
.get(cmd
, cmd
)
93 class MainWindow(standard
.MainWindow
):
94 """The main git-cola application window"""
96 def __init__(self
, context
, parent
=None):
97 super(MainWindow
, self
).__init
__(parent
)
98 self
.context
= context
101 default_title
= '%s - git cola sequence editor' % core
.getcwd()
102 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
103 self
.setWindowTitle(title
)
105 self
.show_help_action
= qtutils
.add_action(
106 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
109 self
.menubar
= QtWidgets
.QMenuBar(self
)
110 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
111 self
.help_menu
.addAction(self
.show_help_action
)
112 self
.setMenuBar(self
.menubar
)
114 qtutils
.add_close_action(self
)
115 self
.init_state(context
.settings
, self
.init_window_size
)
117 def init_window_size(self
):
118 """Set the window size on the first initial view"""
119 context
= self
.context
120 if utils
.is_darwin():
121 desktop
= context
.app
.desktop()
122 self
.resize(desktop
.width(), desktop
.height())
126 def set_editor(self
, editor
):
128 self
.setCentralWidget(editor
)
129 editor
.exit
.connect(self
.exit
)
132 def start(self
, _context
, _view
):
135 def exit(self
, status
):
140 class Editor(QtWidgets
.QWidget
):
143 def __init__(self
, context
, filename
, parent
=None):
144 super(Editor
, self
).__init
__(parent
)
146 self
.widget_version
= 1
148 self
.context
= context
149 self
.filename
= filename
150 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
151 self
.cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
153 self
.diff
= diff
.DiffWidget(context
, self
)
154 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
155 self
.filewidget
= filelist
.FileWidget(context
, self
)
156 self
.setFocusProxy(self
.tree
)
158 self
.rebase_button
= qtutils
.create_button(
159 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
160 tooltip
=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
165 self
.extdiff_button
= qtutils
.create_button(
166 text
=N_('Launch Diff Tool'),
167 tooltip
=N_('Launch external diff tool\nShortcut: Ctrl+D'),
169 self
.extdiff_button
.setEnabled(False)
171 self
.help_button
= qtutils
.create_button(
172 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
175 self
.cancel_button
= qtutils
.create_button(
177 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
181 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
182 top
.setSizes([75, 25])
184 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
185 main_split
.setSizes([25, 75])
187 controls_layout
= qtutils
.hbox(
196 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
197 self
.setLayout(layout
)
199 self
.action_rebase
= qtutils
.add_action(
200 self
, N_('Rebase'), self
.rebase
, hotkeys
.CTRL_RETURN
, hotkeys
.CTRL_ENTER
203 self
.tree
.commits_selected
.connect(self
.commits_selected
)
204 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
205 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
206 self
.tree
.external_diff
.connect(self
.external_diff
)
208 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
210 qtutils
.connect_button(self
.rebase_button
, self
.rebase
)
211 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
212 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
213 qtutils
.connect_button(self
.cancel_button
, self
.cancel
)
216 insns
= core
.read(self
.filename
)
217 self
.parse_sequencer_instructions(insns
)
220 def commits_selected(self
, commits
):
221 self
.extdiff_button
.setEnabled(bool(commits
))
224 def parse_sequencer_instructions(self
, insns
):
226 re_comment_char
= re
.escape(self
.comment_char
)
227 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
228 update_ref_rgx
= re
.compile(
229 r
'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
231 # The upper bound of 40 below must match git.OID_LENGTH.
232 # We'll have to update this to the new hash length when that happens.
233 pick_rgx
= re
.compile(
236 r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
237 r
'\s+([0-9a-f]{7,40})'
242 for line
in insns
.splitlines():
243 match
= pick_rgx
.match(line
)
245 enabled
= match
.group(1) is None
246 command
= unabbrev(match
.group(2))
248 summary
= match
.group(4)
249 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
252 match
= exec_rgx
.match(line
)
254 enabled
= match
.group(1) is None
255 command
= unabbrev(match
.group(2))
256 cmdexec
= match
.group(3)
257 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
260 match
= update_ref_rgx
.match(line
)
262 enabled
= match
.group(1) is None
263 command
= unabbrev(match
.group(2))
264 branch
= match
.group(3)
265 self
.tree
.add_item(idx
, enabled
, command
, branch
=branch
)
269 self
.tree
.decorate(self
.tree
.items())
271 self
.tree
.select_first()
275 if self
.cancel_action
== 'save':
276 status
= self
.save('')
281 self
.exit
.emit(status
)
284 lines
= [item
.value() for item
in self
.tree
.items()]
285 sequencer_instructions
= '\n'.join(lines
) + '\n'
286 status
= self
.save(sequencer_instructions
)
288 self
.exit
.emit(status
)
290 def save(self
, string
):
291 """Save the instruction sheet"""
293 core
.write(self
.filename
, string
)
295 except (OSError, IOError, ValueError) as e
:
296 msg
, details
= utils
.format_exception(e
)
297 sys
.stderr
.write(msg
+ '\n\n' + details
)
301 def external_diff(self
):
302 items
= self
.tree
.selected_items()
306 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
309 # pylint: disable=too-many-ancestors
310 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
311 commits_selected
= Signal(object)
312 external_diff
= Signal()
313 move_rows
= Signal(object, object)
315 def __init__(self
, context
, comment_char
, parent
):
316 super(RebaseTreeWidget
, self
).__init
__(parent
=parent
)
317 self
.context
= context
318 self
.comment_char
= comment_char
320 self
.setHeaderLabels(
329 self
.header().setStretchLastSection(True)
330 self
.setColumnCount(5)
331 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
334 self
.copy_oid_action
= qtutils
.add_action(
335 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
338 self
.external_diff_action
= qtutils
.add_action(
339 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
342 self
.toggle_enabled_action
= qtutils
.add_action(
343 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
346 self
.action_pick
= qtutils
.add_action(
347 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
350 self
.action_reword
= qtutils
.add_action(
353 lambda: self
.set_selected_to(REWORD
),
354 *hotkeys
.REBASE_REWORD
357 self
.action_edit
= qtutils
.add_action(
358 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
361 self
.action_fixup
= qtutils
.add_action(
364 lambda: self
.set_selected_to(FIXUP
),
365 *hotkeys
.REBASE_FIXUP
368 self
.action_squash
= qtutils
.add_action(
371 lambda: self
.set_selected_to(SQUASH
),
372 *hotkeys
.REBASE_SQUASH
375 self
.action_shift_down
= qtutils
.add_action(
376 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
379 self
.action_shift_up
= qtutils
.add_action(
380 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
383 # pylint: disable=no-member
384 self
.itemChanged
.connect(self
.item_changed
)
385 self
.itemSelectionChanged
.connect(self
.selection_changed
)
386 self
.move_rows
.connect(self
.move
)
387 self
.items_moved
.connect(self
.decorate
)
390 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
392 comment_char
= self
.comment_char
393 item
= RebaseTreeWidgetItem(
401 comment_char
=comment_char
,
403 self
.invisibleRootItem().addChild(item
)
405 def decorate(self
, items
):
410 self
.resizeColumnToContents(0)
411 self
.resizeColumnToContents(1)
412 self
.resizeColumnToContents(2)
413 self
.resizeColumnToContents(3)
414 self
.resizeColumnToContents(4)
417 def item_changed(self
, item
, column
):
418 if column
== item
.ENABLED_COLUMN
:
422 invalid_first_choice
= set([FIXUP
, SQUASH
])
423 for item
in self
.items():
424 if item
.is_enabled() and item
.is_commit():
425 if item
.command
in invalid_first_choice
:
426 item
.reset_command(PICK
)
429 def set_selected_to(self
, command
):
430 for i
in self
.selected_items():
431 i
.reset_command(command
)
434 def set_command(self
, item
, command
):
435 item
.reset_command(command
)
439 item
= self
.selected_item()
442 clipboard
= item
.oid
or item
.cmdexec
443 qtutils
.set_clipboard(clipboard
)
445 def selection_changed(self
):
446 item
= self
.selected_item()
447 if item
is None or not item
.is_commit():
449 context
= self
.context
451 params
= dag
.DAG(oid
, 2)
452 repo
= dag
.RepoReader(context
, params
)
457 commits
= commits
[-1:]
458 self
.commits_selected
.emit(commits
)
460 def toggle_enabled(self
):
461 items
= self
.selected_items()
462 logic_or
= reduce(lambda res
, item
: res
or item
.is_enabled(), items
, False)
464 item
.set_enabled(not logic_or
)
466 def select_first(self
):
470 idx
= self
.model().index(0, 0)
472 self
.setCurrentIndex(idx
)
474 def shift_down(self
):
475 sel_items
= self
.selected_items()
476 all_items
= self
.items()
477 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
482 idx
> len(all_items
) - len(sel_items
)
483 or all_items
[sel_idx
[-1]] is all_items
[-1]
485 self
.move_rows
.emit(sel_idx
, idx
)
488 sel_items
= self
.selected_items()
489 all_items
= self
.items()
490 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
495 self
.move_rows
.emit(sel_idx
, idx
)
497 def move(self
, src_idxs
, dst_idx
):
499 src_base
= sorted(src_idxs
)[0]
500 for idx
in reversed(sorted(src_idxs
)):
501 item
= self
.invisibleRootItem().takeChild(idx
)
502 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
504 for item
in moved_items
:
505 self
.invisibleRootItem().insertChild(item
[0], item
[1])
506 self
.setCurrentItem(item
[1])
509 moved_items
= [item
[1] for item
in moved_items
]
510 # If we've moved to the top then we need to re-decorate all items.
511 # Otherwise, we can decorate just the new items.
513 self
.decorate(self
.items())
515 self
.decorate(moved_items
)
517 for item
in moved_items
:
518 item
.setSelected(True)
523 def dropEvent(self
, event
):
524 super(RebaseTreeWidget
, self
).dropEvent(event
)
527 def contextMenuEvent(self
, event
):
528 items
= self
.selected_items()
529 menu
= qtutils
.create_menu(N_('Actions'), self
)
530 menu
.addAction(self
.action_pick
)
531 menu
.addAction(self
.action_reword
)
532 menu
.addAction(self
.action_edit
)
533 menu
.addAction(self
.action_fixup
)
534 menu
.addAction(self
.action_squash
)
536 menu
.addAction(self
.toggle_enabled_action
)
538 menu
.addAction(self
.copy_oid_action
)
540 self
.copy_oid_action
.setDisabled(True)
541 menu
.addAction(self
.external_diff_action
)
543 self
.external_diff_action
.setDisabled(True)
544 menu
.exec_(self
.mapToGlobal(event
.pos()))
547 class ComboBox(QtWidgets
.QComboBox
):
551 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
569 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
571 self
.command
= command
574 self
.summary
= summary
575 self
.cmdexec
= cmdexec
577 self
.comment_char
= comment_char
579 # if core.abbrev is set to a higher value then we will notice by
580 # simply tracking the longest oid we've seen
581 oid_len
= self
.__class
__.OID_LENGTH
582 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
584 self
.setText(0, '%02d' % idx
)
585 self
.set_enabled(enabled
)
590 self
.setText(4, cmdexec
)
591 elif self
.is_update_ref():
593 self
.setText(4, branch
)
596 self
.setText(4, summary
)
598 flags
= self
.flags() | Qt
.ItemIsUserCheckable
599 flags
= flags | Qt
.ItemIsDragEnabled
600 flags
= flags
& ~Qt
.ItemIsDropEnabled
603 def __eq__(self
, other
):
610 return self
.__class
__(
615 summary
=self
.summary
,
616 cmdexec
=self
.cmdexec
,
618 comment_char
=self
.comment_char
,
621 def decorate(self
, parent
):
625 elif self
.is_update_ref():
630 idx
= COMMAND_IDX
[self
.command
]
631 combo
= self
.combo
= ComboBox()
632 combo
.setEditable(False)
633 combo
.addItems(items
)
634 combo
.setCurrentIndex(idx
)
635 combo
.setEnabled(self
.is_commit())
637 signal
= combo
.currentIndexChanged
638 # pylint: disable=no-member
639 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
640 combo
.validate
.connect(parent
.validate
)
642 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
645 return self
.command
== EXEC
647 def is_update_ref(self
):
648 return self
.command
== UPDATE_REF
652 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
656 """Return the serialized representation of an item"""
657 if self
.is_enabled():
660 comment
= self
.comment_char
+ ' '
662 return '%s%s %s' % (comment
, self
.command
, self
.cmdexec
)
663 if self
.is_update_ref():
664 return '%s%s %s' % (comment
, self
.command
, self
.branch
)
665 return '%s%s %s %s' % (comment
, self
.command
, self
.oid
, self
.summary
)
667 def is_enabled(self
):
668 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
670 def set_enabled(self
, enabled
):
671 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
673 def toggle_enabled(self
):
674 self
.set_enabled(not self
.is_enabled())
676 def set_command(self
, command
):
677 """Set the item to a different command, no-op for exec items"""
680 self
.command
= command
683 """Update the view to match the updated state"""
685 command
= self
.command
686 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
688 def reset_command(self
, command
):
689 """Set and refresh the item in one shot"""
690 self
.set_command(command
)
693 def set_command_and_validate(self
, combo
):
694 command
= COMMANDS
[combo
.currentIndex()]
695 self
.set_command(command
)
696 self
.combo
.validate
.emit()
699 def show_help(context
):
705 reword = use commit, but edit the commit message
706 edit = use commit, but stop for amending
707 squash = use commit, but meld into previous commit
708 fixup = like "squash", but discard this commit's log message
709 exec = run command (the rest of the line) using shell
710 update-ref = update branches that point to commits
712 These lines can be re-ordered; they are executed from top to bottom.
714 If you disable a line here THAT COMMIT WILL BE LOST.
716 However, if you disable everything, the rebase will be aborted.
731 spacebar = toggle enabled
733 ctrl+enter = accept changes and rebase
734 ctrl+q = cancel and abort the rebase
735 ctrl+d = launch difftool
738 title
= N_('Help - git-cola-sequence-editor')
739 return text
.text_dialog(context
, help_text
, title
)