3 from __future__
import absolute_import
, division
, unicode_literals
7 from argparse
import ArgumentParser
8 from functools
import partial
12 Copyright (C) 2007-2020 David Aguilar and contributors
14 This program is free software; you can redistribute it and/or modify
15 it under the terms of the GNU General Public License version 2 as
16 published by the Free Software Foundation.
18 This program is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
26 def setup_environment():
27 """Provide access to the cola modules"""
28 abspath
= os
.path
.abspath(__file__
)
29 prefix
= os
.path
.dirname(os
.path
.dirname(os
.path
.realpath(abspath
)))
30 install_lib
= os
.path
.join(prefix
, str('share'), str('git-cola'), str('lib'))
31 win_pkgs
= os
.path
.join(prefix
, str('pkgs'))
32 # Use the private modules from share/git-cola/lib when they exist.
33 # It is assumed that that our modules to be present in sys.path through python's
34 # site-packages directory when the private modules do not exist.
35 if os
.path
.exists(install_lib
):
36 sys
.path
.insert(1, install_lib
)
37 elif os
.path
.exists(win_pkgs
):
38 sys
.path
.insert(1, win_pkgs
)
43 from cola
import app
# prints a message if Qt cannot be found
44 from qtpy
import QtCore
45 from qtpy
import QtGui
46 from qtpy
import QtWidgets
47 from qtpy
.QtCore
import Qt
48 from qtpy
.QtCore
import Signal
50 # pylint: disable=ungrouped-imports
52 from cola
import difftool
53 from cola
import hotkeys
54 from cola
import icons
55 from cola
import observable
56 from cola
import qtutils
57 from cola
import utils
58 from cola
.i18n
import N_
59 from cola
.models
import dag
60 from cola
.models
import prefs
61 from cola
.widgets
import defs
62 from cola
.widgets
import filelist
63 from cola
.widgets
import diff
64 from cola
.widgets
import standard
65 from cola
.widgets
import text
80 COMMAND_IDX
= dict([(cmd_
, idx_
) for idx_
, cmd_
in enumerate(COMMANDS
)])
92 """Start a git-cola-sequence-editor session"""
94 context
= app
.application_init(args
)
95 view
= new_window(context
, args
.filename
)
96 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
100 def stop(_context
, _view
):
101 """All done, cleanup"""
102 QtCore
.QThreadPool
.globalInstance().waitForDone()
106 parser
= ArgumentParser()
108 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
110 app
.add_common_arguments(parser
)
111 return parser
.parse_args()
114 def new_window(context
, filename
):
115 window
= MainWindow(context
)
116 editor
= Editor(context
, filename
, parent
=window
)
117 window
.set_editor(editor
)
122 """Expand shorthand commands into their full name"""
123 return ABBREV
.get(cmd
, cmd
)
126 class MainWindow(standard
.MainWindow
):
127 """The main git-cola application window"""
129 def __init__(self
, context
, parent
=None):
130 super(MainWindow
, self
).__init
__(parent
)
131 self
.context
= context
134 default_title
= '%s - git cola seqeuence editor' % core
.getcwd()
135 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
136 self
.setWindowTitle(title
)
138 self
.show_help_action
= qtutils
.add_action(
139 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
142 self
.menubar
= QtWidgets
.QMenuBar(self
)
143 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
144 self
.help_menu
.addAction(self
.show_help_action
)
145 self
.setMenuBar(self
.menubar
)
147 qtutils
.add_close_action(self
)
148 self
.init_state(context
.settings
, self
.init_window_size
)
150 def init_window_size(self
):
151 """Set the window size on the first initial view"""
152 context
= self
.context
153 if utils
.is_darwin():
154 desktop
= context
.app
.desktop()
155 self
.resize(desktop
.width(), desktop
.height())
159 def set_editor(self
, editor
):
161 self
.setCentralWidget(editor
)
162 editor
.exit
.connect(self
.exit
)
165 def start(self
, _context
, _view
):
168 def exit(self
, status
):
173 class Editor(QtWidgets
.QWidget
):
176 def __init__(self
, context
, filename
, parent
=None):
177 super(Editor
, self
).__init
__(parent
)
179 self
.widget_version
= 1
181 self
.context
= context
182 self
.filename
= filename
183 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
184 self
.cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
186 self
.notifier
= notifier
= observable
.Observable()
187 self
.diff
= diff
.DiffWidget(context
, notifier
, self
)
188 self
.tree
= RebaseTreeWidget(context
, notifier
, comment_char
, self
)
189 self
.filewidget
= filelist
.FileWidget(context
, notifier
, self
)
190 self
.setFocusProxy(self
.tree
)
192 self
.rebase_button
= qtutils
.create_button(
193 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
194 tooltip
=N_('Accept changes and rebase\n' 'Shortcut: Ctrl+Enter'),
199 self
.extdiff_button
= qtutils
.create_button(
200 text
=N_('Launch Diff Tool'),
201 tooltip
=N_('Launch external diff tool\n' 'Shortcut: Ctrl+D'),
203 self
.extdiff_button
.setEnabled(False)
205 self
.help_button
= qtutils
.create_button(
206 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
209 self
.cancel_button
= qtutils
.create_button(
211 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
215 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
216 top
.setSizes([75, 25])
218 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
219 main_split
.setSizes([25, 75])
221 controls_layout
= qtutils
.hbox(
230 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
231 self
.setLayout(layout
)
233 self
.action_rebase
= qtutils
.add_action(
234 self
, N_('Rebase'), self
.rebase
, hotkeys
.CTRL_RETURN
, hotkeys
.CTRL_ENTER
237 notifier
.add_observer(diff
.COMMITS_SELECTED
, self
.commits_selected
)
238 self
.tree
.external_diff
.connect(self
.external_diff
)
240 qtutils
.connect_button(self
.rebase_button
, self
.rebase
)
241 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
242 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
243 qtutils
.connect_button(self
.cancel_button
, self
.cancel
)
246 insns
= core
.read(self
.filename
)
247 self
.parse_sequencer_instructions(insns
)
250 def commits_selected(self
, commits
):
251 self
.extdiff_button
.setEnabled(bool(commits
))
254 def parse_sequencer_instructions(self
, insns
):
256 re_comment_char
= re
.escape(self
.comment_char
)
257 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
258 # The upper bound of 40 below must match git.OID_LENGTH.
259 # We'll have to update this to the new hash length when that happens.
260 pick_rgx
= re
.compile(
263 r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
264 r
'\s+([0-9a-f]{7,40})'
269 for line
in insns
.splitlines():
270 match
= pick_rgx
.match(line
)
272 enabled
= match
.group(1) is None
273 command
= unabbrev(match
.group(2))
275 summary
= match
.group(4)
276 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
279 match
= exec_rgx
.match(line
)
281 enabled
= match
.group(1) is None
282 command
= unabbrev(match
.group(2))
283 cmdexec
= match
.group(3)
284 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
288 self
.tree
.decorate(self
.tree
.items())
290 self
.tree
.select_first()
294 if self
.cancel_action
== 'save':
295 status
= self
.save('')
300 self
.exit
.emit(status
)
303 lines
= [item
.value() for item
in self
.tree
.items()]
304 sequencer_instructions
= '\n'.join(lines
) + '\n'
305 status
= self
.save(sequencer_instructions
)
307 self
.exit
.emit(status
)
309 def save(self
, string
):
310 """Save the instruction sheet"""
312 core
.write(self
.filename
, string
)
314 except (OSError, IOError, ValueError) as e
:
315 msg
, details
= utils
.format_exception(e
)
316 sys
.stderr
.write(msg
+ '\n\n' + details
)
320 def external_diff(self
):
321 items
= self
.tree
.selected_items()
325 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
328 # pylint: disable=too-many-ancestors
329 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
330 external_diff
= Signal()
331 move_rows
= Signal(object, object)
333 def __init__(self
, context
, notifier
, comment_char
, parent
=None):
334 super(RebaseTreeWidget
, self
).__init
__(parent
=parent
)
335 self
.context
= context
336 self
.notifier
= notifier
337 self
.comment_char
= comment_char
339 self
.setHeaderLabels(
348 self
.header().setStretchLastSection(True)
349 self
.setColumnCount(5)
352 self
.copy_oid_action
= qtutils
.add_action(
353 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
356 self
.external_diff_action
= qtutils
.add_action(
357 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
360 self
.toggle_enabled_action
= qtutils
.add_action(
361 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
364 self
.action_pick
= qtutils
.add_action(
365 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
368 self
.action_reword
= qtutils
.add_action(
371 lambda: self
.set_selected_to(REWORD
),
372 *hotkeys
.REBASE_REWORD
375 self
.action_edit
= qtutils
.add_action(
376 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
379 self
.action_fixup
= qtutils
.add_action(
382 lambda: self
.set_selected_to(FIXUP
),
383 *hotkeys
.REBASE_FIXUP
386 self
.action_squash
= qtutils
.add_action(
389 lambda: self
.set_selected_to(SQUASH
),
390 *hotkeys
.REBASE_SQUASH
393 self
.action_shift_down
= qtutils
.add_action(
394 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
397 self
.action_shift_up
= qtutils
.add_action(
398 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
401 # pylint: disable=no-member
402 self
.itemChanged
.connect(self
.item_changed
)
403 self
.itemSelectionChanged
.connect(self
.selection_changed
)
404 self
.move_rows
.connect(self
.move
)
405 self
.items_moved
.connect(self
.decorate
)
407 def add_item(self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
=''):
408 comment_char
= self
.comment_char
409 item
= RebaseTreeWidgetItem(
416 comment_char
=comment_char
,
418 self
.invisibleRootItem().addChild(item
)
420 def decorate(self
, items
):
425 self
.resizeColumnToContents(0)
426 self
.resizeColumnToContents(1)
427 self
.resizeColumnToContents(2)
428 self
.resizeColumnToContents(3)
429 self
.resizeColumnToContents(4)
432 def item_changed(self
, item
, column
):
433 if column
== item
.ENABLED_COLUMN
:
437 invalid_first_choice
= set([FIXUP
, SQUASH
])
438 for item
in self
.items():
439 if item
.is_enabled() and item
.is_commit():
440 if item
.command
in invalid_first_choice
:
441 item
.reset_command(PICK
)
444 def set_selected_to(self
, command
):
445 for i
in self
.selected_items():
446 i
.reset_command(command
)
449 def set_command(self
, item
, command
):
450 item
.reset_command(command
)
454 item
= self
.selected_item()
457 clipboard
= item
.oid
or item
.cmdexec
458 qtutils
.set_clipboard(clipboard
)
460 def selection_changed(self
):
461 item
= self
.selected_item()
462 if item
is None or not item
.is_commit():
464 context
= self
.context
466 params
= dag
.DAG(oid
, 2)
467 repo
= dag
.RepoReader(context
, params
)
472 commits
= commits
[-1:]
473 self
.notifier
.notify_observers(diff
.COMMITS_SELECTED
, commits
)
475 def toggle_enabled(self
):
476 item
= self
.selected_item()
479 item
.toggle_enabled()
481 def select_first(self
):
485 idx
= self
.model().index(0, 0)
487 self
.setCurrentIndex(idx
)
489 def shift_down(self
):
490 item
= self
.selected_item()
494 idx
= items
.index(item
)
495 if idx
< len(items
) - 1:
496 self
.move_rows
.emit([idx
], idx
+ 1)
499 item
= self
.selected_item()
503 idx
= items
.index(item
)
505 self
.move_rows
.emit([idx
], idx
- 1)
507 def move(self
, src_idxs
, dst_idx
):
510 for idx
in reversed(sorted(src_idxs
)):
511 item
= items
[idx
].copy()
512 self
.invisibleRootItem().takeChild(idx
)
513 new_items
.insert(0, item
)
516 self
.invisibleRootItem().insertChildren(dst_idx
, new_items
)
517 self
.setCurrentItem(new_items
[0])
518 # If we've moved to the top then we need to re-decorate all items.
519 # Otherwise, we can decorate just the new items.
521 self
.decorate(self
.items())
523 self
.decorate(new_items
)
528 def dropEvent(self
, event
):
529 super(RebaseTreeWidget
, self
).dropEvent(event
)
532 def contextMenuEvent(self
, event
):
533 menu
= qtutils
.create_menu(N_('Actions'), self
)
534 menu
.addAction(self
.action_pick
)
535 menu
.addAction(self
.action_reword
)
536 menu
.addAction(self
.action_edit
)
537 menu
.addAction(self
.action_fixup
)
538 menu
.addAction(self
.action_squash
)
540 menu
.addAction(self
.toggle_enabled_action
)
542 menu
.addAction(self
.copy_oid_action
)
543 menu
.addAction(self
.external_diff_action
)
544 menu
.exec_(self
.mapToGlobal(event
.pos()))
547 class ComboBox(QtWidgets
.QComboBox
):
551 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
568 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
570 self
.command
= command
573 self
.summary
= summary
574 self
.cmdexec
= cmdexec
575 self
.comment_char
= comment_char
577 # if core.abbrev is set to a higher value then we will notice by
578 # simply tracking the longest oid we've seen
579 oid_len
= self
.__class
__.OID_LENGTH
580 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
582 self
.setText(0, '%02d' % idx
)
583 self
.set_enabled(enabled
)
588 self
.setText(4, cmdexec
)
591 self
.setText(4, summary
)
593 flags
= self
.flags() | Qt
.ItemIsUserCheckable
594 flags
= flags | Qt
.ItemIsDragEnabled
595 flags
= flags
& ~Qt
.ItemIsDropEnabled
598 def __eq__(self
, other
):
605 return self
.__class
__(
610 summary
=self
.summary
,
611 cmdexec
=self
.cmdexec
,
614 def decorate(self
, parent
):
620 idx
= COMMAND_IDX
[self
.command
]
621 combo
= self
.combo
= ComboBox()
622 combo
.setEditable(False)
623 combo
.addItems(items
)
624 combo
.setCurrentIndex(idx
)
625 combo
.setEnabled(self
.is_commit())
627 signal
= combo
.currentIndexChanged
628 # pylint: disable=no-member
629 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
630 combo
.validate
.connect(parent
.validate
)
632 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
635 return self
.command
== EXEC
638 return bool(self
.command
!= EXEC
and self
.oid
and self
.summary
)
641 """Return the serialized representation of an item"""
642 if self
.is_enabled():
645 comment
= self
.comment_char
+ ' '
647 return '%s%s %s' % (comment
, self
.command
, self
.cmdexec
)
648 return '%s%s %s %s' % (comment
, self
.command
, self
.oid
, self
.summary
)
650 def is_enabled(self
):
651 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
653 def set_enabled(self
, enabled
):
654 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
656 def toggle_enabled(self
):
657 self
.set_enabled(not self
.is_enabled())
659 def set_command(self
, command
):
660 """Set the item to a different command, no-op for exec items"""
663 self
.command
= command
666 """Update the view to match the updated state"""
668 command
= self
.command
669 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
671 def reset_command(self
, command
):
672 """Set and refresh the item in one shot"""
673 self
.set_command(command
)
676 def set_command_and_validate(self
, combo
):
677 command
= COMMANDS
[combo
.currentIndex()]
678 self
.set_command(command
)
679 self
.combo
.validate
.emit()
682 def show_help(context
):
688 reword = use commit, but edit the commit message
689 edit = use commit, but stop for amending
690 squash = use commit, but meld into previous commit
691 fixup = like "squash", but discard this commit's log message
692 exec = run command (the rest of the line) using shell
694 These lines can be re-ordered; they are executed from top to bottom.
696 If you disable a line here THAT COMMIT WILL BE LOST.
698 However, if you disable everything, the rebase will be aborted.
713 spacebar = toggle enabled
715 ctrl+enter = accept changes and rebase
716 ctrl+q = cancel and abort the rebase
717 ctrl+d = launch difftool
720 title
= N_('Help - git-cola-sequence-editor')
721 return text
.text_dialog(context
, help_text
, title
)
724 if __name__
== '__main__':