git-cola-sequence-editor: move the implementation to a module
[git-cola.git] / cola / sequenceeditor.py
blob39955698c2a202334c2799aa7b3aedfb947596f9
1 # flake8: noqa
2 from __future__ import absolute_import, division, unicode_literals
3 import sys
4 import re
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
16 from cola import core
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
33 PICK = 'pick'
34 REWORD = 'reword'
35 EDIT = 'edit'
36 FIXUP = 'fixup'
37 SQUASH = 'squash'
38 EXEC = 'exec'
39 COMMANDS = (
40 PICK,
41 REWORD,
42 EDIT,
43 FIXUP,
44 SQUASH,
46 COMMAND_IDX = dict([(cmd_, idx_) for idx_, cmd_ in enumerate(COMMANDS)])
47 ABBREV = {
48 'p': PICK,
49 'r': REWORD,
50 'e': EDIT,
51 'f': FIXUP,
52 's': SQUASH,
53 'x': EXEC,
57 def main():
58 """Start a git-cola-sequence-editor session"""
59 args = parse_args()
60 context = app.application_init(args)
61 view = new_window(context, args.filename)
62 app.application_run(context, view, start=view.start, stop=stop)
63 return view.status
66 def winmain():
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()
76 def parse_args():
77 parser = ArgumentParser()
78 parser.add_argument(
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)
89 return window
92 def unabbrev(cmd):
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
103 self.status = 1
104 self.editor = None
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())
127 else:
128 self.showMaximized()
130 def set_editor(self, editor):
131 self.editor = editor
132 self.setCentralWidget(editor)
133 editor.exit.connect(self.exit)
134 editor.setFocus()
136 def start(self, _context, _view):
137 self.editor.start()
139 def exit(self, status):
140 self.status = status
141 self.close()
144 class Editor(QtWidgets.QWidget):
145 exit = Signal(int)
147 def __init__(self, context, filename, parent=None):
148 super(Editor, self).__init__(parent)
150 self.widget_version = 1
151 self.status = 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'),
166 icon=icons.ok(),
167 default=True,
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(
181 text=N_('Cancel'),
182 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
183 icon=icons.close(),
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(
193 defs.no_margin,
194 defs.button_spacing,
195 self.cancel_button,
196 qtutils.STRETCH,
197 self.help_button,
198 self.extdiff_button,
199 self.rebase_button,
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)
216 def start(self):
217 insns = core.read(self.filename)
218 self.parse_sequencer_instructions(insns)
220 # notifier callbacks
221 def commits_selected(self, commits):
222 self.extdiff_button.setEnabled(bool(commits))
224 # helpers
225 def parse_sequencer_instructions(self, insns):
226 idx = 1
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(
233 r'^\s*(%s)?\s*'
234 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
235 r'\s+([0-9a-f]{7,40})'
236 r'\s+(.+)$'
238 % re_comment_char
240 for line in insns.splitlines():
241 match = pick_rgx.match(line)
242 if match:
243 enabled = match.group(1) is None
244 command = unabbrev(match.group(2))
245 oid = match.group(3)
246 summary = match.group(4)
247 self.tree.add_item(idx, enabled, command, oid=oid, summary=summary)
248 idx += 1
249 continue
250 match = exec_rgx.match(line)
251 if match:
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)
256 idx += 1
257 continue
259 self.tree.decorate(self.tree.items())
260 self.tree.refit()
261 self.tree.select_first()
263 # actions
264 def cancel(self):
265 if self.cancel_action == 'save':
266 status = self.save('')
267 else:
268 status = 1
270 self.status = status
271 self.exit.emit(status)
273 def rebase(self):
274 lines = [item.value() for item in self.tree.items()]
275 sequencer_instructions = '\n'.join(lines) + '\n'
276 status = self.save(sequencer_instructions)
277 self.status = status
278 self.exit.emit(status)
280 def save(self, string):
281 """Save the instruction sheet"""
282 try:
283 core.write(self.filename, string)
284 status = 0
285 except (OSError, IOError, ValueError) as e:
286 msg, details = utils.format_exception(e)
287 sys.stderr.write(msg + '\n\n' + details)
288 status = 128
289 return status
291 def external_diff(self):
292 items = self.tree.selected_items()
293 if not items:
294 return
295 item = items[0]
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
309 # header
310 self.setHeaderLabels(
312 N_('#'),
313 N_('Enabled'),
314 N_('Command'),
315 N_('SHA-1'),
316 N_('Summary'),
319 self.header().setStretchLastSection(True)
320 self.setColumnCount(5)
322 # actions
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(
340 self,
341 N_('Reword'),
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(
351 self,
352 N_('Fixup'),
353 lambda: self.set_selected_to(FIXUP),
354 *hotkeys.REBASE_FIXUP
357 self.action_squash = qtutils.add_action(
358 self,
359 N_('Squash'),
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(
381 idx,
382 enabled,
383 command,
384 oid=oid,
385 summary=summary,
386 cmdexec=cmdexec,
387 comment_char=comment_char,
389 self.invisibleRootItem().addChild(item)
391 def decorate(self, items):
392 for item in items:
393 item.decorate(self)
395 def refit(self):
396 self.resizeColumnToContents(0)
397 self.resizeColumnToContents(1)
398 self.resizeColumnToContents(2)
399 self.resizeColumnToContents(3)
400 self.resizeColumnToContents(4)
402 # actions
403 def item_changed(self, item, column):
404 if column == item.ENABLED_COLUMN:
405 self.validate()
407 def validate(self):
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)
413 break
415 def set_selected_to(self, command):
416 for i in self.selected_items():
417 i.reset_command(command)
418 self.validate()
420 def set_command(self, item, command):
421 item.reset_command(command)
422 self.validate()
424 def copy_oid(self):
425 item = self.selected_item()
426 if item is None:
427 return
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():
434 return
435 context = self.context
436 oid = item.oid
437 params = dag.DAG(oid, 2)
438 repo = dag.RepoReader(context, params)
439 commits = []
440 for c in repo.get():
441 commits.append(c)
442 if commits:
443 commits = commits[-1:]
444 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
446 def toggle_enabled(self):
447 item = self.selected_item()
448 if item is None:
449 return
450 item.toggle_enabled()
452 def select_first(self):
453 items = self.items()
454 if not items:
455 return
456 idx = self.model().index(0, 0)
457 if idx.isValid():
458 self.setCurrentIndex(idx)
460 def shift_down(self):
461 item = self.selected_item()
462 if item is None:
463 return
464 items = self.items()
465 idx = items.index(item)
466 if idx < len(items) - 1:
467 self.move_rows.emit([idx], idx + 1)
469 def shift_up(self):
470 item = self.selected_item()
471 if item is None:
472 return
473 items = self.items()
474 idx = items.index(item)
475 if idx > 0:
476 self.move_rows.emit([idx], idx - 1)
478 def move(self, src_idxs, dst_idx):
479 new_items = []
480 items = self.items()
481 for idx in reversed(sorted(src_idxs)):
482 item = items[idx].copy()
483 self.invisibleRootItem().takeChild(idx)
484 new_items.insert(0, item)
486 if new_items:
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.
491 if dst_idx == 0:
492 self.decorate(self.items())
493 else:
494 self.decorate(new_items)
495 self.validate()
497 # Qt events
499 def dropEvent(self, event):
500 super(RebaseTreeWidget, self).dropEvent(event)
501 self.validate()
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)
510 menu.addSeparator()
511 menu.addAction(self.toggle_enabled_action)
512 menu.addSeparator()
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):
519 validate = Signal()
522 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
524 ENABLED_COLUMN = 1
525 COMMAND_COLUMN = 2
526 OID_LENGTH = 7
528 def __init__(
529 self,
530 idx,
531 enabled,
532 command,
533 oid='',
534 summary='',
535 cmdexec='',
536 comment_char='#',
537 parent=None,
539 QtWidgets.QTreeWidgetItem.__init__(self, parent)
540 self.combo = None
541 self.command = command
542 self.idx = idx
543 self.oid = oid
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)
555 # checkbox on 1
556 # combo box on 2
557 if self.is_exec():
558 self.setText(3, '')
559 self.setText(4, cmdexec)
560 else:
561 self.setText(3, oid)
562 self.setText(4, summary)
564 flags = self.flags() | Qt.ItemIsUserCheckable
565 flags = flags | Qt.ItemIsDragEnabled
566 flags = flags & ~Qt.ItemIsDropEnabled
567 self.setFlags(flags)
569 def __eq__(self, other):
570 return self is other
572 def __hash__(self):
573 return self.oid
575 def copy(self):
576 return self.__class__(
577 self.idx,
578 self.is_enabled(),
579 self.command,
580 oid=self.oid,
581 summary=self.summary,
582 cmdexec=self.cmdexec,
585 def decorate(self, parent):
586 if self.is_exec():
587 items = [EXEC]
588 idx = 0
589 else:
590 items = COMMANDS
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)
605 def is_exec(self):
606 return self.command == EXEC
608 def is_commit(self):
609 return bool(self.command != EXEC and self.oid and self.summary)
611 def value(self):
612 """Return the serialized representation of an item"""
613 if self.is_enabled():
614 comment = ''
615 else:
616 comment = self.comment_char + ' '
617 if self.is_exec():
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"""
632 if self.is_exec():
633 return
634 self.command = command
636 def refresh(self):
637 """Update the view to match the updated state"""
638 if self.is_commit():
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)
645 self.refresh()
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):
654 help_text = N_(
656 Commands
657 --------
658 pick = use commit
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.
671 Keyboard Shortcuts
672 ------------------
673 ? = show help
674 j = move down
675 k = move up
676 J = shift row down
677 K = shift row up
679 1, p = pick
680 2, r = reword
681 3, e = edit
682 4, f = fixup
683 5, s = squash
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)