2 from __future__
import absolute_import
, division
, print_function
, 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 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
)
66 """Windows git-cola-sequence-editor entrypoint"""
67 return app
.winmain(main
)
70 def stop(_context
, _view
):
71 """All done, cleanup"""
72 QtCore
.QThreadPool
.globalInstance().waitForDone()
76 parser
= ArgumentParser()
78 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
80 app
.add_common_arguments(parser
)
81 return parser
.parse_args()
84 def new_window(context
, filename
):
85 window
= MainWindow(context
)
86 editor
= Editor(context
, filename
, parent
=window
)
87 window
.set_editor(editor
)
92 """Expand shorthand commands into their full name"""
93 return ABBREV
.get(cmd
, cmd
)
96 class MainWindow(standard
.MainWindow
):
97 """The main git-cola application window"""
99 def __init__(self
, context
, parent
=None):
100 super(MainWindow
, self
).__init
__(parent
)
101 self
.context
= context
104 default_title
= '%s - git cola seqeuence editor' % core
.getcwd()
105 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
106 self
.setWindowTitle(title
)
108 self
.show_help_action
= qtutils
.add_action(
109 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
112 self
.menubar
= QtWidgets
.QMenuBar(self
)
113 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
114 self
.help_menu
.addAction(self
.show_help_action
)
115 self
.setMenuBar(self
.menubar
)
117 qtutils
.add_close_action(self
)
118 self
.init_state(context
.settings
, self
.init_window_size
)
120 def init_window_size(self
):
121 """Set the window size on the first initial view"""
122 context
= self
.context
123 if utils
.is_darwin():
124 desktop
= context
.app
.desktop()
125 self
.resize(desktop
.width(), desktop
.height())
129 def set_editor(self
, editor
):
131 self
.setCentralWidget(editor
)
132 editor
.exit
.connect(self
.exit
)
135 def start(self
, _context
, _view
):
138 def exit(self
, status
):
143 class Editor(QtWidgets
.QWidget
):
146 def __init__(self
, context
, filename
, parent
=None):
147 super(Editor
, self
).__init
__(parent
)
149 self
.widget_version
= 1
151 self
.context
= context
152 self
.filename
= filename
153 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
154 self
.cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
156 self
.diff
= diff
.DiffWidget(context
, self
)
157 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
158 self
.filewidget
= filelist
.FileWidget(context
, self
)
159 self
.setFocusProxy(self
.tree
)
161 self
.rebase_button
= qtutils
.create_button(
162 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
163 tooltip
=N_('Accept changes and rebase\n' 'Shortcut: Ctrl+Enter'),
168 self
.extdiff_button
= qtutils
.create_button(
169 text
=N_('Launch Diff Tool'),
170 tooltip
=N_('Launch external diff tool\n' 'Shortcut: Ctrl+D'),
172 self
.extdiff_button
.setEnabled(False)
174 self
.help_button
= qtutils
.create_button(
175 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
178 self
.cancel_button
= qtutils
.create_button(
180 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
184 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
185 top
.setSizes([75, 25])
187 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
188 main_split
.setSizes([25, 75])
190 controls_layout
= qtutils
.hbox(
199 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
200 self
.setLayout(layout
)
202 self
.action_rebase
= qtutils
.add_action(
203 self
, N_('Rebase'), self
.rebase
, hotkeys
.CTRL_RETURN
, hotkeys
.CTRL_ENTER
206 self
.tree
.commits_selected
.connect(self
.commits_selected
)
207 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
208 self
.tree
.commits_selected
.connect(self
.diffwidget
.commits_selected
)
209 self
.tree
.external_diff
.connect(self
.external_diff
)
211 self
.filewidget
.files_selected
.connect(self
.diffwidget
.files_selected
)
213 qtutils
.connect_button(self
.rebase_button
, self
.rebase
)
214 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
215 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
216 qtutils
.connect_button(self
.cancel_button
, self
.cancel
)
219 insns
= core
.read(self
.filename
)
220 self
.parse_sequencer_instructions(insns
)
223 def commits_selected(self
, commits
):
224 self
.extdiff_button
.setEnabled(bool(commits
))
227 def parse_sequencer_instructions(self
, insns
):
229 re_comment_char
= re
.escape(self
.comment_char
)
230 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\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
)
261 self
.tree
.decorate(self
.tree
.items())
263 self
.tree
.select_first()
267 if self
.cancel_action
== 'save':
268 status
= self
.save('')
273 self
.exit
.emit(status
)
276 lines
= [item
.value() for item
in self
.tree
.items()]
277 sequencer_instructions
= '\n'.join(lines
) + '\n'
278 status
= self
.save(sequencer_instructions
)
280 self
.exit
.emit(status
)
282 def save(self
, string
):
283 """Save the instruction sheet"""
285 core
.write(self
.filename
, string
)
287 except (OSError, IOError, ValueError) as e
:
288 msg
, details
= utils
.format_exception(e
)
289 sys
.stderr
.write(msg
+ '\n\n' + details
)
293 def external_diff(self
):
294 items
= self
.tree
.selected_items()
298 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
301 # pylint: disable=too-many-ancestors
302 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
303 commits_selected
= Signal(object)
304 external_diff
= Signal()
305 move_rows
= Signal(object, object)
307 def __init__(self
, context
, comment_char
, parent
):
308 super(RebaseTreeWidget
, self
).__init
__(parent
=parent
)
309 self
.context
= context
310 self
.comment_char
= comment_char
312 self
.setHeaderLabels(
321 self
.header().setStretchLastSection(True)
322 self
.setColumnCount(5)
325 self
.copy_oid_action
= qtutils
.add_action(
326 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
329 self
.external_diff_action
= qtutils
.add_action(
330 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
333 self
.toggle_enabled_action
= qtutils
.add_action(
334 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
337 self
.action_pick
= qtutils
.add_action(
338 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
341 self
.action_reword
= qtutils
.add_action(
344 lambda: self
.set_selected_to(REWORD
),
345 *hotkeys
.REBASE_REWORD
348 self
.action_edit
= qtutils
.add_action(
349 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
352 self
.action_fixup
= qtutils
.add_action(
355 lambda: self
.set_selected_to(FIXUP
),
356 *hotkeys
.REBASE_FIXUP
359 self
.action_squash
= qtutils
.add_action(
362 lambda: self
.set_selected_to(SQUASH
),
363 *hotkeys
.REBASE_SQUASH
366 self
.action_shift_down
= qtutils
.add_action(
367 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
370 self
.action_shift_up
= qtutils
.add_action(
371 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
374 # pylint: disable=no-member
375 self
.itemChanged
.connect(self
.item_changed
)
376 self
.itemSelectionChanged
.connect(self
.selection_changed
)
377 self
.move_rows
.connect(self
.move
)
378 self
.items_moved
.connect(self
.decorate
)
380 def add_item(self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
=''):
381 comment_char
= self
.comment_char
382 item
= RebaseTreeWidgetItem(
389 comment_char
=comment_char
,
391 self
.invisibleRootItem().addChild(item
)
393 def decorate(self
, items
):
398 self
.resizeColumnToContents(0)
399 self
.resizeColumnToContents(1)
400 self
.resizeColumnToContents(2)
401 self
.resizeColumnToContents(3)
402 self
.resizeColumnToContents(4)
405 def item_changed(self
, item
, column
):
406 if column
== item
.ENABLED_COLUMN
:
410 invalid_first_choice
= set([FIXUP
, SQUASH
])
411 for item
in self
.items():
412 if item
.is_enabled() and item
.is_commit():
413 if item
.command
in invalid_first_choice
:
414 item
.reset_command(PICK
)
417 def set_selected_to(self
, command
):
418 for i
in self
.selected_items():
419 i
.reset_command(command
)
422 def set_command(self
, item
, command
):
423 item
.reset_command(command
)
427 item
= self
.selected_item()
430 clipboard
= item
.oid
or item
.cmdexec
431 qtutils
.set_clipboard(clipboard
)
433 def selection_changed(self
):
434 item
= self
.selected_item()
435 if item
is None or not item
.is_commit():
437 context
= self
.context
439 params
= dag
.DAG(oid
, 2)
440 repo
= dag
.RepoReader(context
, params
)
445 commits
= commits
[-1:]
446 self
.commits_selected
.emit(commits
)
448 def toggle_enabled(self
):
449 item
= self
.selected_item()
452 item
.toggle_enabled()
454 def select_first(self
):
458 idx
= self
.model().index(0, 0)
460 self
.setCurrentIndex(idx
)
462 def shift_down(self
):
463 item
= self
.selected_item()
467 idx
= items
.index(item
)
468 if idx
< len(items
) - 1:
469 self
.move_rows
.emit([idx
], idx
+ 1)
472 item
= self
.selected_item()
476 idx
= items
.index(item
)
478 self
.move_rows
.emit([idx
], idx
- 1)
480 def move(self
, src_idxs
, dst_idx
):
483 for idx
in reversed(sorted(src_idxs
)):
484 item
= items
[idx
].copy()
485 self
.invisibleRootItem().takeChild(idx
)
486 new_items
.insert(0, item
)
489 self
.invisibleRootItem().insertChildren(dst_idx
, new_items
)
490 self
.setCurrentItem(new_items
[0])
491 # If we've moved to the top then we need to re-decorate all items.
492 # Otherwise, we can decorate just the new items.
494 self
.decorate(self
.items())
496 self
.decorate(new_items
)
501 def dropEvent(self
, event
):
502 super(RebaseTreeWidget
, self
).dropEvent(event
)
505 def contextMenuEvent(self
, event
):
506 menu
= qtutils
.create_menu(N_('Actions'), self
)
507 menu
.addAction(self
.action_pick
)
508 menu
.addAction(self
.action_reword
)
509 menu
.addAction(self
.action_edit
)
510 menu
.addAction(self
.action_fixup
)
511 menu
.addAction(self
.action_squash
)
513 menu
.addAction(self
.toggle_enabled_action
)
515 menu
.addAction(self
.copy_oid_action
)
516 menu
.addAction(self
.external_diff_action
)
517 menu
.exec_(self
.mapToGlobal(event
.pos()))
520 class ComboBox(QtWidgets
.QComboBox
):
524 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
541 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
543 self
.command
= command
546 self
.summary
= summary
547 self
.cmdexec
= cmdexec
548 self
.comment_char
= comment_char
550 # if core.abbrev is set to a higher value then we will notice by
551 # simply tracking the longest oid we've seen
552 oid_len
= self
.__class
__.OID_LENGTH
553 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
555 self
.setText(0, '%02d' % idx
)
556 self
.set_enabled(enabled
)
561 self
.setText(4, cmdexec
)
564 self
.setText(4, summary
)
566 flags
= self
.flags() | Qt
.ItemIsUserCheckable
567 flags
= flags | Qt
.ItemIsDragEnabled
568 flags
= flags
& ~Qt
.ItemIsDropEnabled
571 def __eq__(self
, other
):
578 return self
.__class
__(
583 summary
=self
.summary
,
584 cmdexec
=self
.cmdexec
,
587 def decorate(self
, parent
):
593 idx
= COMMAND_IDX
[self
.command
]
594 combo
= self
.combo
= ComboBox()
595 combo
.setEditable(False)
596 combo
.addItems(items
)
597 combo
.setCurrentIndex(idx
)
598 combo
.setEnabled(self
.is_commit())
600 signal
= combo
.currentIndexChanged
601 # pylint: disable=no-member
602 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
603 combo
.validate
.connect(parent
.validate
)
605 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
608 return self
.command
== EXEC
611 return bool(self
.command
!= EXEC
and self
.oid
and self
.summary
)
614 """Return the serialized representation of an item"""
615 if self
.is_enabled():
618 comment
= self
.comment_char
+ ' '
620 return '%s%s %s' % (comment
, self
.command
, self
.cmdexec
)
621 return '%s%s %s %s' % (comment
, self
.command
, self
.oid
, self
.summary
)
623 def is_enabled(self
):
624 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
626 def set_enabled(self
, enabled
):
627 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
629 def toggle_enabled(self
):
630 self
.set_enabled(not self
.is_enabled())
632 def set_command(self
, command
):
633 """Set the item to a different command, no-op for exec items"""
636 self
.command
= command
639 """Update the view to match the updated state"""
641 command
= self
.command
642 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
644 def reset_command(self
, command
):
645 """Set and refresh the item in one shot"""
646 self
.set_command(command
)
649 def set_command_and_validate(self
, combo
):
650 command
= COMMANDS
[combo
.currentIndex()]
651 self
.set_command(command
)
652 self
.combo
.validate
.emit()
655 def show_help(context
):
661 reword = use commit, but edit the commit message
662 edit = use commit, but stop for amending
663 squash = use commit, but meld into previous commit
664 fixup = like "squash", but discard this commit's log message
665 exec = run command (the rest of the line) using shell
667 These lines can be re-ordered; they are executed from top to bottom.
669 If you disable a line here THAT COMMIT WILL BE LOST.
671 However, if you disable everything, the rebase will be aborted.
686 spacebar = toggle enabled
688 ctrl+enter = accept changes and rebase
689 ctrl+q = cancel and abort the rebase
690 ctrl+d = launch difftool
693 title
= N_('Help - git-cola-sequence-editor')
694 return text
.text_dialog(context
, help_text
, title
)