doc: add Alexander to the credits
[git-cola.git] / cola / sequenceeditor.py
blob20699ef9bde52a490d562217bcbf2e3b042daabb
1 # flake8: noqa
2 from __future__ import absolute_import, division, print_function, 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 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
32 PICK = 'pick'
33 REWORD = 'reword'
34 EDIT = 'edit'
35 FIXUP = 'fixup'
36 SQUASH = 'squash'
37 EXEC = 'exec'
38 COMMANDS = (
39 PICK,
40 REWORD,
41 EDIT,
42 FIXUP,
43 SQUASH,
45 COMMAND_IDX = dict([(cmd_, idx_) for idx_, cmd_ in enumerate(COMMANDS)])
46 ABBREV = {
47 'p': PICK,
48 'r': REWORD,
49 'e': EDIT,
50 'f': FIXUP,
51 's': SQUASH,
52 'x': EXEC,
56 def main():
57 """Start a git-cola-sequence-editor session"""
58 args = parse_args()
59 context = app.application_init(args)
60 view = new_window(context, args.filename)
61 app.application_run(context, view, start=view.start, stop=stop)
62 return view.status
65 def winmain():
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()
75 def parse_args():
76 parser = ArgumentParser()
77 parser.add_argument(
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)
88 return window
91 def unabbrev(cmd):
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
102 self.status = 1
103 self.editor = None
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())
126 else:
127 self.showMaximized()
129 def set_editor(self, editor):
130 self.editor = editor
131 self.setCentralWidget(editor)
132 editor.exit.connect(self.exit)
133 editor.setFocus()
135 def start(self, _context, _view):
136 self.editor.start()
138 def exit(self, status):
139 self.status = status
140 self.close()
143 class Editor(QtWidgets.QWidget):
144 exit = Signal(int)
146 def __init__(self, context, filename, parent=None):
147 super(Editor, self).__init__(parent)
149 self.widget_version = 1
150 self.status = 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'),
164 icon=icons.ok(),
165 default=True,
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(
179 text=N_('Cancel'),
180 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
181 icon=icons.close(),
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(
191 defs.no_margin,
192 defs.button_spacing,
193 self.cancel_button,
194 qtutils.STRETCH,
195 self.help_button,
196 self.extdiff_button,
197 self.rebase_button,
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.diff.commits_selected)
209 self.tree.external_diff.connect(self.external_diff)
211 self.filewidget.files_selected.connect(self.diff.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)
218 def start(self):
219 insns = core.read(self.filename)
220 self.parse_sequencer_instructions(insns)
222 # signal callbacks
223 def commits_selected(self, commits):
224 self.extdiff_button.setEnabled(bool(commits))
226 # helpers
227 def parse_sequencer_instructions(self, insns):
228 idx = 1
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(
235 r'^\s*(%s)?\s*'
236 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
237 r'\s+([0-9a-f]{7,40})'
238 r'\s+(.+)$'
240 % re_comment_char
242 for line in insns.splitlines():
243 match = pick_rgx.match(line)
244 if match:
245 enabled = match.group(1) is None
246 command = unabbrev(match.group(2))
247 oid = match.group(3)
248 summary = match.group(4)
249 self.tree.add_item(idx, enabled, command, oid=oid, summary=summary)
250 idx += 1
251 continue
252 match = exec_rgx.match(line)
253 if match:
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)
258 idx += 1
259 continue
261 self.tree.decorate(self.tree.items())
262 self.tree.refit()
263 self.tree.select_first()
265 # actions
266 def cancel(self):
267 if self.cancel_action == 'save':
268 status = self.save('')
269 else:
270 status = 1
272 self.status = status
273 self.exit.emit(status)
275 def rebase(self):
276 lines = [item.value() for item in self.tree.items()]
277 sequencer_instructions = '\n'.join(lines) + '\n'
278 status = self.save(sequencer_instructions)
279 self.status = status
280 self.exit.emit(status)
282 def save(self, string):
283 """Save the instruction sheet"""
284 try:
285 core.write(self.filename, string)
286 status = 0
287 except (OSError, IOError, ValueError) as e:
288 msg, details = utils.format_exception(e)
289 sys.stderr.write(msg + '\n\n' + details)
290 status = 128
291 return status
293 def external_diff(self):
294 items = self.tree.selected_items()
295 if not items:
296 return
297 item = items[0]
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
311 # header
312 self.setHeaderLabels(
314 N_('#'),
315 N_('Enabled'),
316 N_('Command'),
317 N_('SHA-1'),
318 N_('Summary'),
321 self.header().setStretchLastSection(True)
322 self.setColumnCount(5)
324 # actions
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(
342 self,
343 N_('Reword'),
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(
353 self,
354 N_('Fixup'),
355 lambda: self.set_selected_to(FIXUP),
356 *hotkeys.REBASE_FIXUP
359 self.action_squash = qtutils.add_action(
360 self,
361 N_('Squash'),
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(
383 idx,
384 enabled,
385 command,
386 oid=oid,
387 summary=summary,
388 cmdexec=cmdexec,
389 comment_char=comment_char,
391 self.invisibleRootItem().addChild(item)
393 def decorate(self, items):
394 for item in items:
395 item.decorate(self)
397 def refit(self):
398 self.resizeColumnToContents(0)
399 self.resizeColumnToContents(1)
400 self.resizeColumnToContents(2)
401 self.resizeColumnToContents(3)
402 self.resizeColumnToContents(4)
404 # actions
405 def item_changed(self, item, column):
406 if column == item.ENABLED_COLUMN:
407 self.validate()
409 def validate(self):
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)
415 break
417 def set_selected_to(self, command):
418 for i in self.selected_items():
419 i.reset_command(command)
420 self.validate()
422 def set_command(self, item, command):
423 item.reset_command(command)
424 self.validate()
426 def copy_oid(self):
427 item = self.selected_item()
428 if item is None:
429 return
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():
436 return
437 context = self.context
438 oid = item.oid
439 params = dag.DAG(oid, 2)
440 repo = dag.RepoReader(context, params)
441 commits = []
442 for c in repo.get():
443 commits.append(c)
444 if commits:
445 commits = commits[-1:]
446 self.commits_selected.emit(commits)
448 def toggle_enabled(self):
449 item = self.selected_item()
450 if item is None:
451 return
452 item.toggle_enabled()
454 def select_first(self):
455 items = self.items()
456 if not items:
457 return
458 idx = self.model().index(0, 0)
459 if idx.isValid():
460 self.setCurrentIndex(idx)
462 def shift_down(self):
463 item = self.selected_item()
464 if item is None:
465 return
466 items = self.items()
467 idx = items.index(item)
468 if idx < len(items) - 1:
469 self.move_rows.emit([idx], idx + 1)
471 def shift_up(self):
472 item = self.selected_item()
473 if item is None:
474 return
475 items = self.items()
476 idx = items.index(item)
477 if idx > 0:
478 self.move_rows.emit([idx], idx - 1)
480 def move(self, src_idxs, dst_idx):
481 new_items = []
482 items = self.items()
483 for idx in reversed(sorted(src_idxs)):
484 item = items[idx].copy()
485 self.invisibleRootItem().takeChild(idx)
486 new_items.insert(0, item)
488 if new_items:
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.
493 if dst_idx == 0:
494 self.decorate(self.items())
495 else:
496 self.decorate(new_items)
497 self.validate()
499 # Qt events
501 def dropEvent(self, event):
502 super(RebaseTreeWidget, self).dropEvent(event)
503 self.validate()
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)
512 menu.addSeparator()
513 menu.addAction(self.toggle_enabled_action)
514 menu.addSeparator()
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):
521 validate = Signal()
524 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
526 ENABLED_COLUMN = 1
527 COMMAND_COLUMN = 2
528 OID_LENGTH = 7
530 def __init__(
531 self,
532 idx,
533 enabled,
534 command,
535 oid='',
536 summary='',
537 cmdexec='',
538 comment_char='#',
539 parent=None,
541 QtWidgets.QTreeWidgetItem.__init__(self, parent)
542 self.combo = None
543 self.command = command
544 self.idx = idx
545 self.oid = oid
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)
557 # checkbox on 1
558 # combo box on 2
559 if self.is_exec():
560 self.setText(3, '')
561 self.setText(4, cmdexec)
562 else:
563 self.setText(3, oid)
564 self.setText(4, summary)
566 flags = self.flags() | Qt.ItemIsUserCheckable
567 flags = flags | Qt.ItemIsDragEnabled
568 flags = flags & ~Qt.ItemIsDropEnabled
569 self.setFlags(flags)
571 def __eq__(self, other):
572 return self is other
574 def __hash__(self):
575 return self.oid
577 def copy(self):
578 return self.__class__(
579 self.idx,
580 self.is_enabled(),
581 self.command,
582 oid=self.oid,
583 summary=self.summary,
584 cmdexec=self.cmdexec,
587 def decorate(self, parent):
588 if self.is_exec():
589 items = [EXEC]
590 idx = 0
591 else:
592 items = COMMANDS
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)
607 def is_exec(self):
608 return self.command == EXEC
610 def is_commit(self):
611 return bool(self.command != EXEC and self.oid and self.summary)
613 def value(self):
614 """Return the serialized representation of an item"""
615 if self.is_enabled():
616 comment = ''
617 else:
618 comment = self.comment_char + ' '
619 if self.is_exec():
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"""
634 if self.is_exec():
635 return
636 self.command = command
638 def refresh(self):
639 """Update the view to match the updated state"""
640 if self.is_commit():
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)
647 self.refresh()
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):
656 help_text = N_(
658 Commands
659 --------
660 pick = use commit
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.
673 Keyboard Shortcuts
674 ------------------
675 ? = show help
676 j = move down
677 k = move up
678 J = shift row down
679 K = shift row up
681 1, p = pick
682 2, r = reword
683 3, e = edit
684 4, f = fixup
685 5, s = squash
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)