prefs: return the resolved editor variable
[git-cola.git] / cola / sequenceeditor.py
blob75ba372fab36220b65f7c245046d45275e695c5b
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, reduce
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 stop(_context, _view):
66 """All done, cleanup"""
67 QtCore.QThreadPool.globalInstance().waitForDone()
70 def parse_args():
71 parser = ArgumentParser()
72 parser.add_argument(
73 'filename', metavar='<filename>', help='git-rebase-todo file to edit'
75 app.add_common_arguments(parser)
76 return parser.parse_args()
79 def new_window(context, filename):
80 window = MainWindow(context)
81 editor = Editor(context, filename, parent=window)
82 window.set_editor(editor)
83 return window
86 def unabbrev(cmd):
87 """Expand shorthand commands into their full name"""
88 return ABBREV.get(cmd, cmd)
91 class MainWindow(standard.MainWindow):
92 """The main git-cola application window"""
94 def __init__(self, context, parent=None):
95 super(MainWindow, self).__init__(parent)
96 self.context = context
97 self.status = 1
98 self.editor = None
99 default_title = '%s - git cola seqeuence editor' % core.getcwd()
100 title = core.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title)
101 self.setWindowTitle(title)
103 self.show_help_action = qtutils.add_action(
104 self, N_('Show Help'), partial(show_help, context), hotkeys.QUESTION
107 self.menubar = QtWidgets.QMenuBar(self)
108 self.help_menu = self.menubar.addMenu(N_('Help'))
109 self.help_menu.addAction(self.show_help_action)
110 self.setMenuBar(self.menubar)
112 qtutils.add_close_action(self)
113 self.init_state(context.settings, self.init_window_size)
115 def init_window_size(self):
116 """Set the window size on the first initial view"""
117 context = self.context
118 if utils.is_darwin():
119 desktop = context.app.desktop()
120 self.resize(desktop.width(), desktop.height())
121 else:
122 self.showMaximized()
124 def set_editor(self, editor):
125 self.editor = editor
126 self.setCentralWidget(editor)
127 editor.exit.connect(self.exit)
128 editor.setFocus()
130 def start(self, _context, _view):
131 self.editor.start()
133 def exit(self, status):
134 self.status = status
135 self.close()
138 class Editor(QtWidgets.QWidget):
139 exit = Signal(int)
141 def __init__(self, context, filename, parent=None):
142 super(Editor, self).__init__(parent)
144 self.widget_version = 1
145 self.status = 1
146 self.context = context
147 self.filename = filename
148 self.comment_char = comment_char = prefs.comment_char(context)
149 self.cancel_action = core.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
151 self.diff = diff.DiffWidget(context, self)
152 self.tree = RebaseTreeWidget(context, comment_char, self)
153 self.filewidget = filelist.FileWidget(context, self)
154 self.setFocusProxy(self.tree)
156 self.rebase_button = qtutils.create_button(
157 text=core.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
158 tooltip=N_('Accept changes and rebase\n' 'Shortcut: Ctrl+Enter'),
159 icon=icons.ok(),
160 default=True,
163 self.extdiff_button = qtutils.create_button(
164 text=N_('Launch Diff Tool'),
165 tooltip=N_('Launch external diff tool\n' 'Shortcut: Ctrl+D'),
167 self.extdiff_button.setEnabled(False)
169 self.help_button = qtutils.create_button(
170 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'), icon=icons.question()
173 self.cancel_button = qtutils.create_button(
174 text=N_('Cancel'),
175 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
176 icon=icons.close(),
179 top = qtutils.splitter(Qt.Horizontal, self.tree, self.filewidget)
180 top.setSizes([75, 25])
182 main_split = qtutils.splitter(Qt.Vertical, top, self.diff)
183 main_split.setSizes([25, 75])
185 controls_layout = qtutils.hbox(
186 defs.no_margin,
187 defs.button_spacing,
188 self.cancel_button,
189 qtutils.STRETCH,
190 self.help_button,
191 self.extdiff_button,
192 self.rebase_button,
194 layout = qtutils.vbox(defs.no_margin, defs.spacing, main_split, controls_layout)
195 self.setLayout(layout)
197 self.action_rebase = qtutils.add_action(
198 self, N_('Rebase'), self.rebase, hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER
201 self.tree.commits_selected.connect(self.commits_selected)
202 self.tree.commits_selected.connect(self.filewidget.commits_selected)
203 self.tree.commits_selected.connect(self.diff.commits_selected)
204 self.tree.external_diff.connect(self.external_diff)
206 self.filewidget.files_selected.connect(self.diff.files_selected)
208 qtutils.connect_button(self.rebase_button, self.rebase)
209 qtutils.connect_button(self.extdiff_button, self.external_diff)
210 qtutils.connect_button(self.help_button, partial(show_help, context))
211 qtutils.connect_button(self.cancel_button, self.cancel)
213 def start(self):
214 insns = core.read(self.filename)
215 self.parse_sequencer_instructions(insns)
217 # signal callbacks
218 def commits_selected(self, commits):
219 self.extdiff_button.setEnabled(bool(commits))
221 # helpers
222 def parse_sequencer_instructions(self, insns):
223 idx = 1
224 re_comment_char = re.escape(self.comment_char)
225 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char)
226 # The upper bound of 40 below must match git.OID_LENGTH.
227 # We'll have to update this to the new hash length when that happens.
228 pick_rgx = re.compile(
230 r'^\s*(%s)?\s*'
231 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
232 r'\s+([0-9a-f]{7,40})'
233 r'\s+(.+)$'
235 % re_comment_char
237 for line in insns.splitlines():
238 match = pick_rgx.match(line)
239 if match:
240 enabled = match.group(1) is None
241 command = unabbrev(match.group(2))
242 oid = match.group(3)
243 summary = match.group(4)
244 self.tree.add_item(idx, enabled, command, oid=oid, summary=summary)
245 idx += 1
246 continue
247 match = exec_rgx.match(line)
248 if match:
249 enabled = match.group(1) is None
250 command = unabbrev(match.group(2))
251 cmdexec = match.group(3)
252 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
253 idx += 1
254 continue
256 self.tree.decorate(self.tree.items())
257 self.tree.refit()
258 self.tree.select_first()
260 # actions
261 def cancel(self):
262 if self.cancel_action == 'save':
263 status = self.save('')
264 else:
265 status = 1
267 self.status = status
268 self.exit.emit(status)
270 def rebase(self):
271 lines = [item.value() for item in self.tree.items()]
272 sequencer_instructions = '\n'.join(lines) + '\n'
273 status = self.save(sequencer_instructions)
274 self.status = status
275 self.exit.emit(status)
277 def save(self, string):
278 """Save the instruction sheet"""
279 try:
280 core.write(self.filename, string)
281 status = 0
282 except (OSError, IOError, ValueError) as e:
283 msg, details = utils.format_exception(e)
284 sys.stderr.write(msg + '\n\n' + details)
285 status = 128
286 return status
288 def external_diff(self):
289 items = self.tree.selected_items()
290 if not items:
291 return
292 item = items[0]
293 difftool.diff_expression(self.context, self, item.oid + '^!', hide_expr=True)
296 # pylint: disable=too-many-ancestors
297 class RebaseTreeWidget(standard.DraggableTreeWidget):
298 commits_selected = Signal(object)
299 external_diff = Signal()
300 move_rows = Signal(object, object)
302 def __init__(self, context, comment_char, parent):
303 super(RebaseTreeWidget, self).__init__(parent=parent)
304 self.context = context
305 self.comment_char = comment_char
306 # header
307 self.setHeaderLabels(
309 N_('#'),
310 N_('Enabled'),
311 N_('Command'),
312 N_('SHA-1'),
313 N_('Summary'),
316 self.header().setStretchLastSection(True)
317 self.setColumnCount(5)
318 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
320 # actions
321 self.copy_oid_action = qtutils.add_action(
322 self, N_('Copy SHA-1'), self.copy_oid, QtGui.QKeySequence.Copy
325 self.external_diff_action = qtutils.add_action(
326 self, N_('Launch Diff Tool'), self.external_diff.emit, hotkeys.DIFF
329 self.toggle_enabled_action = qtutils.add_action(
330 self, N_('Toggle Enabled'), self.toggle_enabled, hotkeys.PRIMARY_ACTION
333 self.action_pick = qtutils.add_action(
334 self, N_('Pick'), lambda: self.set_selected_to(PICK), *hotkeys.REBASE_PICK
337 self.action_reword = qtutils.add_action(
338 self,
339 N_('Reword'),
340 lambda: self.set_selected_to(REWORD),
341 *hotkeys.REBASE_REWORD
344 self.action_edit = qtutils.add_action(
345 self, N_('Edit'), lambda: self.set_selected_to(EDIT), *hotkeys.REBASE_EDIT
348 self.action_fixup = qtutils.add_action(
349 self,
350 N_('Fixup'),
351 lambda: self.set_selected_to(FIXUP),
352 *hotkeys.REBASE_FIXUP
355 self.action_squash = qtutils.add_action(
356 self,
357 N_('Squash'),
358 lambda: self.set_selected_to(SQUASH),
359 *hotkeys.REBASE_SQUASH
362 self.action_shift_down = qtutils.add_action(
363 self, N_('Shift Down'), self.shift_down, hotkeys.MOVE_DOWN_TERTIARY
366 self.action_shift_up = qtutils.add_action(
367 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY
370 # pylint: disable=no-member
371 self.itemChanged.connect(self.item_changed)
372 self.itemSelectionChanged.connect(self.selection_changed)
373 self.move_rows.connect(self.move)
374 self.items_moved.connect(self.decorate)
376 def add_item(self, idx, enabled, command, oid='', summary='', cmdexec=''):
377 comment_char = self.comment_char
378 item = RebaseTreeWidgetItem(
379 idx,
380 enabled,
381 command,
382 oid=oid,
383 summary=summary,
384 cmdexec=cmdexec,
385 comment_char=comment_char,
387 self.invisibleRootItem().addChild(item)
389 def decorate(self, items):
390 for item in items:
391 item.decorate(self)
393 def refit(self):
394 self.resizeColumnToContents(0)
395 self.resizeColumnToContents(1)
396 self.resizeColumnToContents(2)
397 self.resizeColumnToContents(3)
398 self.resizeColumnToContents(4)
400 # actions
401 def item_changed(self, item, column):
402 if column == item.ENABLED_COLUMN:
403 self.validate()
405 def validate(self):
406 invalid_first_choice = set([FIXUP, SQUASH])
407 for item in self.items():
408 if item.is_enabled() and item.is_commit():
409 if item.command in invalid_first_choice:
410 item.reset_command(PICK)
411 break
413 def set_selected_to(self, command):
414 for i in self.selected_items():
415 i.reset_command(command)
416 self.validate()
418 def set_command(self, item, command):
419 item.reset_command(command)
420 self.validate()
422 def copy_oid(self):
423 item = self.selected_item()
424 if item is None:
425 return
426 clipboard = item.oid or item.cmdexec
427 qtutils.set_clipboard(clipboard)
429 def selection_changed(self):
430 item = self.selected_item()
431 if item is None or not item.is_commit():
432 return
433 context = self.context
434 oid = item.oid
435 params = dag.DAG(oid, 2)
436 repo = dag.RepoReader(context, params)
437 commits = []
438 for c in repo.get():
439 commits.append(c)
440 if commits:
441 commits = commits[-1:]
442 self.commits_selected.emit(commits)
444 def toggle_enabled(self):
445 items = self.selected_items()
446 logic_or = reduce(lambda res, item: res or item.is_enabled(), items, False)
447 for item in items:
448 item.set_enabled(not logic_or)
450 def select_first(self):
451 items = self.items()
452 if not items:
453 return
454 idx = self.model().index(0, 0)
455 if idx.isValid():
456 self.setCurrentIndex(idx)
458 def shift_down(self):
459 sel_items = self.selected_items()
460 all_items = self.items()
461 sel_idx = sorted([all_items.index(item) for item in sel_items])
462 if not sel_idx:
463 return
464 idx = sel_idx[0] + 1
465 if not (
466 idx > len(all_items) - len(sel_items)
467 or all_items[sel_idx[-1]] is all_items[-1]
469 self.move_rows.emit(sel_idx, idx)
471 def shift_up(self):
472 sel_items = self.selected_items()
473 all_items = self.items()
474 sel_idx = sorted([all_items.index(item) for item in sel_items])
475 if not sel_idx:
476 return
477 idx = sel_idx[0] - 1
478 if idx >= 0:
479 self.move_rows.emit(sel_idx, idx)
481 def move(self, src_idxs, dst_idx):
482 moved_items = list()
483 src_base = sorted(src_idxs)[0]
484 for idx in reversed(sorted(src_idxs)):
485 item = self.invisibleRootItem().takeChild(idx)
486 moved_items.insert(0, [dst_idx + (idx - src_base), item])
488 for item in moved_items:
489 self.invisibleRootItem().insertChild(item[0], item[1])
490 self.setCurrentItem(item[1])
492 if moved_items:
493 moved_items = [item[1] for item in moved_items]
494 # If we've moved to the top then we need to re-decorate all items.
495 # Otherwise, we can decorate just the new items.
496 if dst_idx == 0:
497 self.decorate(self.items())
498 else:
499 self.decorate(moved_items)
501 for item in moved_items:
502 item.setSelected(True)
503 self.validate()
505 # Qt events
507 def dropEvent(self, event):
508 super(RebaseTreeWidget, self).dropEvent(event)
509 self.validate()
511 def contextMenuEvent(self, event):
512 items = self.selected_items()
513 menu = qtutils.create_menu(N_('Actions'), self)
514 menu.addAction(self.action_pick)
515 menu.addAction(self.action_reword)
516 menu.addAction(self.action_edit)
517 menu.addAction(self.action_fixup)
518 menu.addAction(self.action_squash)
519 menu.addSeparator()
520 menu.addAction(self.toggle_enabled_action)
521 menu.addSeparator()
522 menu.addAction(self.copy_oid_action)
523 if len(items) > 1:
524 self.copy_oid_action.setDisabled(True)
525 menu.addAction(self.external_diff_action)
526 if len(items) > 1:
527 self.external_diff_action.setDisabled(True)
528 menu.exec_(self.mapToGlobal(event.pos()))
531 class ComboBox(QtWidgets.QComboBox):
532 validate = Signal()
535 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
537 ENABLED_COLUMN = 1
538 COMMAND_COLUMN = 2
539 OID_LENGTH = 7
541 def __init__(
542 self,
543 idx,
544 enabled,
545 command,
546 oid='',
547 summary='',
548 cmdexec='',
549 comment_char='#',
550 parent=None,
552 QtWidgets.QTreeWidgetItem.__init__(self, parent)
553 self.combo = None
554 self.command = command
555 self.idx = idx
556 self.oid = oid
557 self.summary = summary
558 self.cmdexec = cmdexec
559 self.comment_char = comment_char
561 # if core.abbrev is set to a higher value then we will notice by
562 # simply tracking the longest oid we've seen
563 oid_len = self.__class__.OID_LENGTH
564 self.__class__.OID_LENGTH = max(len(oid), oid_len)
566 self.setText(0, '%02d' % idx)
567 self.set_enabled(enabled)
568 # checkbox on 1
569 # combo box on 2
570 if self.is_exec():
571 self.setText(3, '')
572 self.setText(4, cmdexec)
573 else:
574 self.setText(3, oid)
575 self.setText(4, summary)
577 flags = self.flags() | Qt.ItemIsUserCheckable
578 flags = flags | Qt.ItemIsDragEnabled
579 flags = flags & ~Qt.ItemIsDropEnabled
580 self.setFlags(flags)
582 def __eq__(self, other):
583 return self is other
585 def __hash__(self):
586 return self.oid
588 def copy(self):
589 return self.__class__(
590 self.idx,
591 self.is_enabled(),
592 self.command,
593 oid=self.oid,
594 summary=self.summary,
595 cmdexec=self.cmdexec,
598 def decorate(self, parent):
599 if self.is_exec():
600 items = [EXEC]
601 idx = 0
602 else:
603 items = COMMANDS
604 idx = COMMAND_IDX[self.command]
605 combo = self.combo = ComboBox()
606 combo.setEditable(False)
607 combo.addItems(items)
608 combo.setCurrentIndex(idx)
609 combo.setEnabled(self.is_commit())
611 signal = combo.currentIndexChanged
612 # pylint: disable=no-member
613 signal.connect(lambda x: self.set_command_and_validate(combo))
614 combo.validate.connect(parent.validate)
616 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
618 def is_exec(self):
619 return self.command == EXEC
621 def is_commit(self):
622 return bool(self.command != EXEC and self.oid and self.summary)
624 def value(self):
625 """Return the serialized representation of an item"""
626 if self.is_enabled():
627 comment = ''
628 else:
629 comment = self.comment_char + ' '
630 if self.is_exec():
631 return '%s%s %s' % (comment, self.command, self.cmdexec)
632 return '%s%s %s %s' % (comment, self.command, self.oid, self.summary)
634 def is_enabled(self):
635 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
637 def set_enabled(self, enabled):
638 self.setCheckState(self.ENABLED_COLUMN, enabled and Qt.Checked or Qt.Unchecked)
640 def toggle_enabled(self):
641 self.set_enabled(not self.is_enabled())
643 def set_command(self, command):
644 """Set the item to a different command, no-op for exec items"""
645 if self.is_exec():
646 return
647 self.command = command
649 def refresh(self):
650 """Update the view to match the updated state"""
651 if self.is_commit():
652 command = self.command
653 self.combo.setCurrentIndex(COMMAND_IDX[command])
655 def reset_command(self, command):
656 """Set and refresh the item in one shot"""
657 self.set_command(command)
658 self.refresh()
660 def set_command_and_validate(self, combo):
661 command = COMMANDS[combo.currentIndex()]
662 self.set_command(command)
663 self.combo.validate.emit()
666 def show_help(context):
667 help_text = N_(
669 Commands
670 --------
671 pick = use commit
672 reword = use commit, but edit the commit message
673 edit = use commit, but stop for amending
674 squash = use commit, but meld into previous commit
675 fixup = like "squash", but discard this commit's log message
676 exec = run command (the rest of the line) using shell
678 These lines can be re-ordered; they are executed from top to bottom.
680 If you disable a line here THAT COMMIT WILL BE LOST.
682 However, if you disable everything, the rebase will be aborted.
684 Keyboard Shortcuts
685 ------------------
686 ? = show help
687 j = move down
688 k = move up
689 J = shift row down
690 K = shift row up
692 1, p = pick
693 2, r = reword
694 3, e = edit
695 4, f = fixup
696 5, s = squash
697 spacebar = toggle enabled
699 ctrl+enter = accept changes and rebase
700 ctrl+q = cancel and abort the rebase
701 ctrl+d = launch difftool
704 title = N_('Help - git-cola-sequence-editor')
705 return text.text_dialog(context, help_text, title)