2 from __future__
import absolute_import
, division
, unicode_literals
5 from argparse
import ArgumentParser
6 from functools
import partial
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 observable
21 from cola
import qtutils
22 from cola
import utils
23 from cola
.i18n
import N_
24 from cola
.models
import dag
25 from cola
.models
import prefs
26 from cola
.widgets
import defs
27 from cola
.widgets
import filelist
28 from cola
.widgets
import diff
29 from cola
.widgets
import standard
30 from cola
.widgets
import text
46 COMMAND_IDX
= dict([(cmd_
, idx_
) for idx_
, cmd_
in enumerate(COMMANDS
)])
58 """Start a git-cola-sequence-editor session"""
60 context
= app
.application_init(args
)
61 view
= new_window(context
, args
.filename
)
62 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
67 """Windows git-cola-sequence-editor entrypoint"""
68 return app
.winmain(main
)
71 def stop(_context
, _view
):
72 """All done, cleanup"""
73 QtCore
.QThreadPool
.globalInstance().waitForDone()
77 parser
= ArgumentParser()
79 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
81 app
.add_common_arguments(parser
)
82 return parser
.parse_args()
85 def new_window(context
, filename
):
86 window
= MainWindow(context
)
87 editor
= Editor(context
, filename
, parent
=window
)
88 window
.set_editor(editor
)
93 """Expand shorthand commands into their full name"""
94 return ABBREV
.get(cmd
, cmd
)
97 class MainWindow(standard
.MainWindow
):
98 """The main git-cola application window"""
100 def __init__(self
, context
, parent
=None):
101 super(MainWindow
, self
).__init
__(parent
)
102 self
.context
= context
105 default_title
= '%s - git cola seqeuence editor' % core
.getcwd()
106 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
107 self
.setWindowTitle(title
)
109 self
.show_help_action
= qtutils
.add_action(
110 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
113 self
.menubar
= QtWidgets
.QMenuBar(self
)
114 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
115 self
.help_menu
.addAction(self
.show_help_action
)
116 self
.setMenuBar(self
.menubar
)
118 qtutils
.add_close_action(self
)
119 self
.init_state(context
.settings
, self
.init_window_size
)
121 def init_window_size(self
):
122 """Set the window size on the first initial view"""
123 context
= self
.context
124 if utils
.is_darwin():
125 desktop
= context
.app
.desktop()
126 self
.resize(desktop
.width(), desktop
.height())
130 def set_editor(self
, editor
):
132 self
.setCentralWidget(editor
)
133 editor
.exit
.connect(self
.exit
)
136 def start(self
, _context
, _view
):
139 def exit(self
, status
):
144 class Editor(QtWidgets
.QWidget
):
147 def __init__(self
, context
, filename
, parent
=None):
148 super(Editor
, self
).__init
__(parent
)
150 self
.widget_version
= 1
152 self
.context
= context
153 self
.filename
= filename
154 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
155 self
.cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
157 self
.notifier
= notifier
= observable
.Observable()
158 self
.diff
= diff
.DiffWidget(context
, notifier
, self
)
159 self
.tree
= RebaseTreeWidget(context
, notifier
, comment_char
, self
)
160 self
.filewidget
= filelist
.FileWidget(context
, notifier
, self
)
161 self
.setFocusProxy(self
.tree
)
163 self
.rebase_button
= qtutils
.create_button(
164 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
165 tooltip
=N_('Accept changes and rebase\n' 'Shortcut: Ctrl+Enter'),
170 self
.extdiff_button
= qtutils
.create_button(
171 text
=N_('Launch Diff Tool'),
172 tooltip
=N_('Launch external diff tool\n' 'Shortcut: Ctrl+D'),
174 self
.extdiff_button
.setEnabled(False)
176 self
.help_button
= qtutils
.create_button(
177 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
180 self
.cancel_button
= qtutils
.create_button(
182 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
186 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
187 top
.setSizes([75, 25])
189 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
190 main_split
.setSizes([25, 75])
192 controls_layout
= qtutils
.hbox(
201 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
202 self
.setLayout(layout
)
204 self
.action_rebase
= qtutils
.add_action(
205 self
, N_('Rebase'), self
.rebase
, hotkeys
.CTRL_RETURN
, hotkeys
.CTRL_ENTER
208 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
209 self
.tree
.external_diff
.connect(self
.external_diff
)
211 qtutils
.connect_button(self
.rebase_button
, self
.rebase
)
212 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
213 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
214 qtutils
.connect_button(self
.cancel_button
, self
.cancel
)
217 insns
= core
.read(self
.filename
)
218 self
.parse_sequencer_instructions(insns
)
221 def commits_selected(self
, commits
):
222 self
.extdiff_button
.setEnabled(bool(commits
))
225 def parse_sequencer_instructions(self
, insns
):
227 re_comment_char
= re
.escape(self
.comment_char
)
228 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
229 # The upper bound of 40 below must match git.OID_LENGTH.
230 # We'll have to update this to the new hash length when that happens.
231 pick_rgx
= re
.compile(
234 r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
235 r
'\s+([0-9a-f]{7,40})'
240 for line
in insns
.splitlines():
241 match
= pick_rgx
.match(line
)
243 enabled
= match
.group(1) is None
244 command
= unabbrev(match
.group(2))
246 summary
= match
.group(4)
247 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
250 match
= exec_rgx
.match(line
)
252 enabled
= match
.group(1) is None
253 command
= unabbrev(match
.group(2))
254 cmdexec
= match
.group(3)
255 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
259 self
.tree
.decorate(self
.tree
.items())
261 self
.tree
.select_first()
265 if self
.cancel_action
== 'save':
266 status
= self
.save('')
271 self
.exit
.emit(status
)
274 lines
= [item
.value() for item
in self
.tree
.items()]
275 sequencer_instructions
= '\n'.join(lines
) + '\n'
276 status
= self
.save(sequencer_instructions
)
278 self
.exit
.emit(status
)
280 def save(self
, string
):
281 """Save the instruction sheet"""
283 core
.write(self
.filename
, string
)
285 except (OSError, IOError, ValueError) as e
:
286 msg
, details
= utils
.format_exception(e
)
287 sys
.stderr
.write(msg
+ '\n\n' + details
)
291 def external_diff(self
):
292 items
= self
.tree
.selected_items()
296 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
299 # pylint: disable=too-many-ancestors
300 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
301 external_diff
= Signal()
302 move_rows
= Signal(object, object)
304 def __init__(self
, context
, notifier
, comment_char
, parent
=None):
305 super(RebaseTreeWidget
, self
).__init
__(parent
=parent
)
306 self
.context
= context
307 self
.notifier
= notifier
308 self
.comment_char
= comment_char
310 self
.setHeaderLabels(
319 self
.header().setStretchLastSection(True)
320 self
.setColumnCount(5)
323 self
.copy_oid_action
= qtutils
.add_action(
324 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
327 self
.external_diff_action
= qtutils
.add_action(
328 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
331 self
.toggle_enabled_action
= qtutils
.add_action(
332 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
335 self
.action_pick
= qtutils
.add_action(
336 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
339 self
.action_reword
= qtutils
.add_action(
342 lambda: self
.set_selected_to(REWORD
),
343 *hotkeys
.REBASE_REWORD
346 self
.action_edit
= qtutils
.add_action(
347 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
350 self
.action_fixup
= qtutils
.add_action(
353 lambda: self
.set_selected_to(FIXUP
),
354 *hotkeys
.REBASE_FIXUP
357 self
.action_squash
= qtutils
.add_action(
360 lambda: self
.set_selected_to(SQUASH
),
361 *hotkeys
.REBASE_SQUASH
364 self
.action_shift_down
= qtutils
.add_action(
365 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
368 self
.action_shift_up
= qtutils
.add_action(
369 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
372 # pylint: disable=no-member
373 self
.itemChanged
.connect(self
.item_changed
)
374 self
.itemSelectionChanged
.connect(self
.selection_changed
)
375 self
.move_rows
.connect(self
.move
)
376 self
.items_moved
.connect(self
.decorate
)
378 def add_item(self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
=''):
379 comment_char
= self
.comment_char
380 item
= RebaseTreeWidgetItem(
387 comment_char
=comment_char
,
389 self
.invisibleRootItem().addChild(item
)
391 def decorate(self
, items
):
396 self
.resizeColumnToContents(0)
397 self
.resizeColumnToContents(1)
398 self
.resizeColumnToContents(2)
399 self
.resizeColumnToContents(3)
400 self
.resizeColumnToContents(4)
403 def item_changed(self
, item
, column
):
404 if column
== item
.ENABLED_COLUMN
:
408 invalid_first_choice
= set([FIXUP
, SQUASH
])
409 for item
in self
.items():
410 if item
.is_enabled() and item
.is_commit():
411 if item
.command
in invalid_first_choice
:
412 item
.reset_command(PICK
)
415 def set_selected_to(self
, command
):
416 for i
in self
.selected_items():
417 i
.reset_command(command
)
420 def set_command(self
, item
, command
):
421 item
.reset_command(command
)
425 item
= self
.selected_item()
428 clipboard
= item
.oid
or item
.cmdexec
429 qtutils
.set_clipboard(clipboard
)
431 def selection_changed(self
):
432 item
= self
.selected_item()
433 if item
is None or not item
.is_commit():
435 context
= self
.context
437 params
= dag
.DAG(oid
, 2)
438 repo
= dag
.RepoReader(context
, params
)
443 commits
= commits
[-1:]
444 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, commits
)
446 def toggle_enabled(self
):
447 item
= self
.selected_item()
450 item
.toggle_enabled()
452 def select_first(self
):
456 idx
= self
.model().index(0, 0)
458 self
.setCurrentIndex(idx
)
460 def shift_down(self
):
461 item
= self
.selected_item()
465 idx
= items
.index(item
)
466 if idx
< len(items
) - 1:
467 self
.move_rows
.emit([idx
], idx
+ 1)
470 item
= self
.selected_item()
474 idx
= items
.index(item
)
476 self
.move_rows
.emit([idx
], idx
- 1)
478 def move(self
, src_idxs
, dst_idx
):
481 for idx
in reversed(sorted(src_idxs
)):
482 item
= items
[idx
].copy()
483 self
.invisibleRootItem().takeChild(idx
)
484 new_items
.insert(0, item
)
487 self
.invisibleRootItem().insertChildren(dst_idx
, new_items
)
488 self
.setCurrentItem(new_items
[0])
489 # If we've moved to the top then we need to re-decorate all items.
490 # Otherwise, we can decorate just the new items.
492 self
.decorate(self
.items())
494 self
.decorate(new_items
)
499 def dropEvent(self
, event
):
500 super(RebaseTreeWidget
, self
).dropEvent(event
)
503 def contextMenuEvent(self
, event
):
504 menu
= qtutils
.create_menu(N_('Actions'), self
)
505 menu
.addAction(self
.action_pick
)
506 menu
.addAction(self
.action_reword
)
507 menu
.addAction(self
.action_edit
)
508 menu
.addAction(self
.action_fixup
)
509 menu
.addAction(self
.action_squash
)
511 menu
.addAction(self
.toggle_enabled_action
)
513 menu
.addAction(self
.copy_oid_action
)
514 menu
.addAction(self
.external_diff_action
)
515 menu
.exec_(self
.mapToGlobal(event
.pos()))
518 class ComboBox(QtWidgets
.QComboBox
):
522 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
539 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
541 self
.command
= command
544 self
.summary
= summary
545 self
.cmdexec
= cmdexec
546 self
.comment_char
= comment_char
548 # if core.abbrev is set to a higher value then we will notice by
549 # simply tracking the longest oid we've seen
550 oid_len
= self
.__class
__.OID_LENGTH
551 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
553 self
.setText(0, '%02d' % idx
)
554 self
.set_enabled(enabled
)
559 self
.setText(4, cmdexec
)
562 self
.setText(4, summary
)
564 flags
= self
.flags() | Qt
.ItemIsUserCheckable
565 flags
= flags | Qt
.ItemIsDragEnabled
566 flags
= flags
& ~Qt
.ItemIsDropEnabled
569 def __eq__(self
, other
):
576 return self
.__class
__(
581 summary
=self
.summary
,
582 cmdexec
=self
.cmdexec
,
585 def decorate(self
, parent
):
591 idx
= COMMAND_IDX
[self
.command
]
592 combo
= self
.combo
= ComboBox()
593 combo
.setEditable(False)
594 combo
.addItems(items
)
595 combo
.setCurrentIndex(idx
)
596 combo
.setEnabled(self
.is_commit())
598 signal
= combo
.currentIndexChanged
599 # pylint: disable=no-member
600 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
601 combo
.validate
.connect(parent
.validate
)
603 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
606 return self
.command
== EXEC
609 return bool(self
.command
!= EXEC
and self
.oid
and self
.summary
)
612 """Return the serialized representation of an item"""
613 if self
.is_enabled():
616 comment
= self
.comment_char
+ ' '
618 return '%s%s %s' % (comment
, self
.command
, self
.cmdexec
)
619 return '%s%s %s %s' % (comment
, self
.command
, self
.oid
, self
.summary
)
621 def is_enabled(self
):
622 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
624 def set_enabled(self
, enabled
):
625 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
627 def toggle_enabled(self
):
628 self
.set_enabled(not self
.is_enabled())
630 def set_command(self
, command
):
631 """Set the item to a different command, no-op for exec items"""
634 self
.command
= command
637 """Update the view to match the updated state"""
639 command
= self
.command
640 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
642 def reset_command(self
, command
):
643 """Set and refresh the item in one shot"""
644 self
.set_command(command
)
647 def set_command_and_validate(self
, combo
):
648 command
= COMMANDS
[combo
.currentIndex()]
649 self
.set_command(command
)
650 self
.combo
.validate
.emit()
653 def show_help(context
):
659 reword = use commit, but edit the commit message
660 edit = use commit, but stop for amending
661 squash = use commit, but meld into previous commit
662 fixup = like "squash", but discard this commit's log message
663 exec = run command (the rest of the line) using shell
665 These lines can be re-ordered; they are executed from top to bottom.
667 If you disable a line here THAT COMMIT WILL BE LOST.
669 However, if you disable everything, the rebase will be aborted.
684 spacebar = toggle enabled
686 ctrl+enter = accept changes and rebase
687 ctrl+q = cancel and abort the rebase
688 ctrl+d = launch difftool
691 title
= N_('Help - git-cola-sequence-editor')
692 return text
.text_dialog(context
, help_text
, title
)