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
45 COMMAND_IDX
= dict([(cmd_
, idx_
) for idx_
, cmd_
in enumerate(COMMANDS
)])
57 """Start a git-cola-sequence-editor session"""
59 context
= app
.application_init(args
)
60 view
= new_window(context
, args
.filename
)
61 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
65 def stop(_context
, _view
):
66 """All done, cleanup"""
67 QtCore
.QThreadPool
.globalInstance().waitForDone()
71 parser
= ArgumentParser()
73 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
75 app
.add_common_arguments(parser
)
76 return parser
.parse_args()
79 def new_window(context
, filename
):
80 window
= MainWindow(context
)
81 editor
= Editor(context
, filename
, parent
=window
)
82 window
.set_editor(editor
)
87 """Expand shorthand commands into their full name"""
88 return ABBREV
.get(cmd
, cmd
)
91 class MainWindow(standard
.MainWindow
):
92 """The main git-cola application window"""
94 def __init__(self
, context
, parent
=None):
95 super(MainWindow
, self
).__init
__(parent
)
96 self
.context
= context
99 default_title
= '%s - git cola seqeuence editor' % core
.getcwd()
100 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
101 self
.setWindowTitle(title
)
103 self
.show_help_action
= qtutils
.add_action(
104 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
107 self
.menubar
= QtWidgets
.QMenuBar(self
)
108 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
109 self
.help_menu
.addAction(self
.show_help_action
)
110 self
.setMenuBar(self
.menubar
)
112 qtutils
.add_close_action(self
)
113 self
.init_state(context
.settings
, self
.init_window_size
)
115 def init_window_size(self
):
116 """Set the window size on the first initial view"""
117 context
= self
.context
118 if utils
.is_darwin():
119 desktop
= context
.app
.desktop()
120 self
.resize(desktop
.width(), desktop
.height())
124 def set_editor(self
, editor
):
126 self
.setCentralWidget(editor
)
127 editor
.exit
.connect(self
.exit
)
130 def start(self
, _context
, _view
):
133 def exit(self
, status
):
138 class Editor(QtWidgets
.QWidget
):
141 def __init__(self
, context
, filename
, parent
=None):
142 super(Editor
, self
).__init
__(parent
)
144 self
.widget_version
= 1
146 self
.context
= context
147 self
.filename
= filename
148 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
149 self
.cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
151 self
.diff
= diff
.DiffWidget(context
, self
)
152 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
153 self
.filewidget
= filelist
.FileWidget(context
, self
)
154 self
.setFocusProxy(self
.tree
)
156 self
.rebase_button
= qtutils
.create_button(
157 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
158 tooltip
=N_('Accept changes and rebase\n' 'Shortcut: Ctrl+Enter'),
163 self
.extdiff_button
= qtutils
.create_button(
164 text
=N_('Launch Diff Tool'),
165 tooltip
=N_('Launch external diff tool\n' 'Shortcut: Ctrl+D'),
167 self
.extdiff_button
.setEnabled(False)
169 self
.help_button
= qtutils
.create_button(
170 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
173 self
.cancel_button
= qtutils
.create_button(
175 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
179 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
180 top
.setSizes([75, 25])
182 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
183 main_split
.setSizes([25, 75])
185 controls_layout
= qtutils
.hbox(
194 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
195 self
.setLayout(layout
)
197 self
.action_rebase
= qtutils
.add_action(
198 self
, N_('Rebase'), self
.rebase
, hotkeys
.CTRL_RETURN
, hotkeys
.CTRL_ENTER
201 self
.tree
.commits_selected
.connect(self
.commits_selected
)
202 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
203 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
204 self
.tree
.external_diff
.connect(self
.external_diff
)
206 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
208 qtutils
.connect_button(self
.rebase_button
, self
.rebase
)
209 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
210 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
211 qtutils
.connect_button(self
.cancel_button
, self
.cancel
)
214 insns
= core
.read(self
.filename
)
215 self
.parse_sequencer_instructions(insns
)
218 def commits_selected(self
, commits
):
219 self
.extdiff_button
.setEnabled(bool(commits
))
222 def parse_sequencer_instructions(self
, insns
):
224 re_comment_char
= re
.escape(self
.comment_char
)
225 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
226 # The upper bound of 40 below must match git.OID_LENGTH.
227 # We'll have to update this to the new hash length when that happens.
228 pick_rgx
= re
.compile(
231 r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
232 r
'\s+([0-9a-f]{7,40})'
237 for line
in insns
.splitlines():
238 match
= pick_rgx
.match(line
)
240 enabled
= match
.group(1) is None
241 command
= unabbrev(match
.group(2))
243 summary
= match
.group(4)
244 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
247 match
= exec_rgx
.match(line
)
249 enabled
= match
.group(1) is None
250 command
= unabbrev(match
.group(2))
251 cmdexec
= match
.group(3)
252 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
256 self
.tree
.decorate(self
.tree
.items())
258 self
.tree
.select_first()
262 if self
.cancel_action
== 'save':
263 status
= self
.save('')
268 self
.exit
.emit(status
)
271 lines
= [item
.value() for item
in self
.tree
.items()]
272 sequencer_instructions
= '\n'.join(lines
) + '\n'
273 status
= self
.save(sequencer_instructions
)
275 self
.exit
.emit(status
)
277 def save(self
, string
):
278 """Save the instruction sheet"""
280 core
.write(self
.filename
, string
)
282 except (OSError, IOError, ValueError) as e
:
283 msg
, details
= utils
.format_exception(e
)
284 sys
.stderr
.write(msg
+ '\n\n' + details
)
288 def external_diff(self
):
289 items
= self
.tree
.selected_items()
293 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
296 # pylint: disable=too-many-ancestors
297 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
298 commits_selected
= Signal(object)
299 external_diff
= Signal()
300 move_rows
= Signal(object, object)
302 def __init__(self
, context
, comment_char
, parent
):
303 super(RebaseTreeWidget
, self
).__init
__(parent
=parent
)
304 self
.context
= context
305 self
.comment_char
= comment_char
307 self
.setHeaderLabels(
316 self
.header().setStretchLastSection(True)
317 self
.setColumnCount(5)
318 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
321 self
.copy_oid_action
= qtutils
.add_action(
322 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
325 self
.external_diff_action
= qtutils
.add_action(
326 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
329 self
.toggle_enabled_action
= qtutils
.add_action(
330 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
333 self
.action_pick
= qtutils
.add_action(
334 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
337 self
.action_reword
= qtutils
.add_action(
340 lambda: self
.set_selected_to(REWORD
),
341 *hotkeys
.REBASE_REWORD
344 self
.action_edit
= qtutils
.add_action(
345 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
348 self
.action_fixup
= qtutils
.add_action(
351 lambda: self
.set_selected_to(FIXUP
),
352 *hotkeys
.REBASE_FIXUP
355 self
.action_squash
= qtutils
.add_action(
358 lambda: self
.set_selected_to(SQUASH
),
359 *hotkeys
.REBASE_SQUASH
362 self
.action_shift_down
= qtutils
.add_action(
363 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
366 self
.action_shift_up
= qtutils
.add_action(
367 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
370 # pylint: disable=no-member
371 self
.itemChanged
.connect(self
.item_changed
)
372 self
.itemSelectionChanged
.connect(self
.selection_changed
)
373 self
.move_rows
.connect(self
.move
)
374 self
.items_moved
.connect(self
.decorate
)
376 def add_item(self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
=''):
377 comment_char
= self
.comment_char
378 item
= RebaseTreeWidgetItem(
385 comment_char
=comment_char
,
387 self
.invisibleRootItem().addChild(item
)
389 def decorate(self
, items
):
394 self
.resizeColumnToContents(0)
395 self
.resizeColumnToContents(1)
396 self
.resizeColumnToContents(2)
397 self
.resizeColumnToContents(3)
398 self
.resizeColumnToContents(4)
401 def item_changed(self
, item
, column
):
402 if column
== item
.ENABLED_COLUMN
:
406 invalid_first_choice
= set([FIXUP
, SQUASH
])
407 for item
in self
.items():
408 if item
.is_enabled() and item
.is_commit():
409 if item
.command
in invalid_first_choice
:
410 item
.reset_command(PICK
)
413 def set_selected_to(self
, command
):
414 for i
in self
.selected_items():
415 i
.reset_command(command
)
418 def set_command(self
, item
, command
):
419 item
.reset_command(command
)
423 item
= self
.selected_item()
426 clipboard
= item
.oid
or item
.cmdexec
427 qtutils
.set_clipboard(clipboard
)
429 def selection_changed(self
):
430 item
= self
.selected_item()
431 if item
is None or not item
.is_commit():
433 context
= self
.context
435 params
= dag
.DAG(oid
, 2)
436 repo
= dag
.RepoReader(context
, params
)
441 commits
= commits
[-1:]
442 self
.commits_selected
.emit(commits
)
444 def toggle_enabled(self
):
445 items
= self
.selected_items()
446 logic_or
= reduce(lambda res
, item
: res
or item
.is_enabled(), items
, False)
448 item
.set_enabled(not logic_or
)
450 def select_first(self
):
454 idx
= self
.model().index(0, 0)
456 self
.setCurrentIndex(idx
)
458 def shift_down(self
):
459 sel_items
= self
.selected_items()
460 all_items
= self
.items()
461 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
466 idx
> len(all_items
) - len(sel_items
)
467 or all_items
[sel_idx
[-1]] is all_items
[-1]
469 self
.move_rows
.emit(sel_idx
, idx
)
472 sel_items
= self
.selected_items()
473 all_items
= self
.items()
474 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
479 self
.move_rows
.emit(sel_idx
, idx
)
481 def move(self
, src_idxs
, dst_idx
):
483 src_base
= sorted(src_idxs
)[0]
484 for idx
in reversed(sorted(src_idxs
)):
485 item
= self
.invisibleRootItem().takeChild(idx
)
486 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
488 for item
in moved_items
:
489 self
.invisibleRootItem().insertChild(item
[0], item
[1])
490 self
.setCurrentItem(item
[1])
493 moved_items
= [item
[1] for item
in moved_items
]
494 # If we've moved to the top then we need to re-decorate all items.
495 # Otherwise, we can decorate just the new items.
497 self
.decorate(self
.items())
499 self
.decorate(moved_items
)
501 for item
in moved_items
:
502 item
.setSelected(True)
507 def dropEvent(self
, event
):
508 super(RebaseTreeWidget
, self
).dropEvent(event
)
511 def contextMenuEvent(self
, event
):
512 items
= self
.selected_items()
513 menu
= qtutils
.create_menu(N_('Actions'), self
)
514 menu
.addAction(self
.action_pick
)
515 menu
.addAction(self
.action_reword
)
516 menu
.addAction(self
.action_edit
)
517 menu
.addAction(self
.action_fixup
)
518 menu
.addAction(self
.action_squash
)
520 menu
.addAction(self
.toggle_enabled_action
)
522 menu
.addAction(self
.copy_oid_action
)
524 self
.copy_oid_action
.setDisabled(True)
525 menu
.addAction(self
.external_diff_action
)
527 self
.external_diff_action
.setDisabled(True)
528 menu
.exec_(self
.mapToGlobal(event
.pos()))
531 class ComboBox(QtWidgets
.QComboBox
):
535 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
552 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
554 self
.command
= command
557 self
.summary
= summary
558 self
.cmdexec
= cmdexec
559 self
.comment_char
= comment_char
561 # if core.abbrev is set to a higher value then we will notice by
562 # simply tracking the longest oid we've seen
563 oid_len
= self
.__class
__.OID_LENGTH
564 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
566 self
.setText(0, '%02d' % idx
)
567 self
.set_enabled(enabled
)
572 self
.setText(4, cmdexec
)
575 self
.setText(4, summary
)
577 flags
= self
.flags() | Qt
.ItemIsUserCheckable
578 flags
= flags | Qt
.ItemIsDragEnabled
579 flags
= flags
& ~Qt
.ItemIsDropEnabled
582 def __eq__(self
, other
):
589 return self
.__class
__(
594 summary
=self
.summary
,
595 cmdexec
=self
.cmdexec
,
598 def decorate(self
, parent
):
604 idx
= COMMAND_IDX
[self
.command
]
605 combo
= self
.combo
= ComboBox()
606 combo
.setEditable(False)
607 combo
.addItems(items
)
608 combo
.setCurrentIndex(idx
)
609 combo
.setEnabled(self
.is_commit())
611 signal
= combo
.currentIndexChanged
612 # pylint: disable=no-member
613 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
614 combo
.validate
.connect(parent
.validate
)
616 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
619 return self
.command
== EXEC
622 return bool(self
.command
!= EXEC
and self
.oid
and self
.summary
)
625 """Return the serialized representation of an item"""
626 if self
.is_enabled():
629 comment
= self
.comment_char
+ ' '
631 return '%s%s %s' % (comment
, self
.command
, self
.cmdexec
)
632 return '%s%s %s %s' % (comment
, self
.command
, self
.oid
, self
.summary
)
634 def is_enabled(self
):
635 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
637 def set_enabled(self
, enabled
):
638 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
640 def toggle_enabled(self
):
641 self
.set_enabled(not self
.is_enabled())
643 def set_command(self
, command
):
644 """Set the item to a different command, no-op for exec items"""
647 self
.command
= command
650 """Update the view to match the updated state"""
652 command
= self
.command
653 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
655 def reset_command(self
, command
):
656 """Set and refresh the item in one shot"""
657 self
.set_command(command
)
660 def set_command_and_validate(self
, combo
):
661 command
= COMMANDS
[combo
.currentIndex()]
662 self
.set_command(command
)
663 self
.combo
.validate
.emit()
666 def show_help(context
):
672 reword = use commit, but edit the commit message
673 edit = use commit, but stop for amending
674 squash = use commit, but meld into previous commit
675 fixup = like "squash", but discard this commit's log message
676 exec = run command (the rest of the line) using shell
678 These lines can be re-ordered; they are executed from top to bottom.
680 If you disable a line here THAT COMMIT WILL BE LOST.
682 However, if you disable everything, the rebase will be aborted.
697 spacebar = toggle enabled
699 ctrl+enter = accept changes and rebase
700 ctrl+q = cancel and abort the rebase
701 ctrl+d = launch difftool
704 title
= N_('Help - git-cola-sequence-editor')
705 return text
.text_dialog(context
, help_text
, title
)