browse: display errors when saving blobs
[git-cola.git] / cola / sequenceeditor.py
bloba151a69bdb71bc359116a5bae1ec3626b88c9f9c
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 UPDATE_REF = 'update-ref'
38 EXEC = 'exec'
39 COMMANDS = (
40 PICK,
41 REWORD,
42 EDIT,
43 FIXUP,
44 SQUASH,
46 COMMAND_IDX = {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,
54 'u': UPDATE_REF,
58 def main():
59 """Start a git-cola-sequence-editor session"""
60 args = parse_args()
61 context = app.application_init(args)
62 view = new_window(context, args.filename)
63 app.application_run(context, view, start=view.start, stop=stop)
64 return view.status
67 def stop(_context, _view):
68 """All done, cleanup"""
69 QtCore.QThreadPool.globalInstance().waitForDone()
72 def parse_args():
73 parser = ArgumentParser()
74 parser.add_argument(
75 'filename', metavar='<filename>', help='git-rebase-todo file to edit'
77 app.add_common_arguments(parser)
78 return parser.parse_args()
81 def new_window(context, filename):
82 window = MainWindow(context)
83 editor = Editor(context, filename, parent=window)
84 window.set_editor(editor)
85 return window
88 def unabbrev(cmd):
89 """Expand shorthand commands into their full name"""
90 return ABBREV.get(cmd, cmd)
93 class MainWindow(standard.MainWindow):
94 """The main git-cola application window"""
96 def __init__(self, context, parent=None):
97 super(MainWindow, self).__init__(parent)
98 self.context = context
99 self.status = 1
100 self.editor = None
101 default_title = '%s - git cola sequence editor' % core.getcwd()
102 title = core.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title)
103 self.setWindowTitle(title)
105 self.show_help_action = qtutils.add_action(
106 self, N_('Show Help'), partial(show_help, context), hotkeys.QUESTION
109 self.menubar = QtWidgets.QMenuBar(self)
110 self.help_menu = self.menubar.addMenu(N_('Help'))
111 self.help_menu.addAction(self.show_help_action)
112 self.setMenuBar(self.menubar)
114 qtutils.add_close_action(self)
115 self.init_state(context.settings, self.init_window_size)
117 def init_window_size(self):
118 """Set the window size on the first initial view"""
119 context = self.context
120 if utils.is_darwin():
121 desktop = context.app.desktop()
122 self.resize(desktop.width(), desktop.height())
123 else:
124 self.showMaximized()
126 def set_editor(self, editor):
127 self.editor = editor
128 self.setCentralWidget(editor)
129 editor.exit.connect(self.exit)
130 editor.setFocus()
132 def start(self, _context, _view):
133 self.editor.start()
135 def exit(self, status):
136 self.status = status
137 self.close()
140 class Editor(QtWidgets.QWidget):
141 exit = Signal(int)
143 def __init__(self, context, filename, parent=None):
144 super(Editor, self).__init__(parent)
146 self.widget_version = 1
147 self.status = 1
148 self.context = context
149 self.filename = filename
150 self.comment_char = comment_char = prefs.comment_char(context)
151 self.cancel_action = core.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
153 self.diff = diff.DiffWidget(context, self)
154 self.tree = RebaseTreeWidget(context, comment_char, self)
155 self.filewidget = filelist.FileWidget(context, self)
156 self.setFocusProxy(self.tree)
158 self.rebase_button = qtutils.create_button(
159 text=core.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
160 tooltip=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
161 icon=icons.ok(),
162 default=True,
165 self.extdiff_button = qtutils.create_button(
166 text=N_('Launch Diff Tool'),
167 tooltip=N_('Launch external diff tool\nShortcut: Ctrl+D'),
169 self.extdiff_button.setEnabled(False)
171 self.help_button = qtutils.create_button(
172 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'), icon=icons.question()
175 self.cancel_button = qtutils.create_button(
176 text=N_('Cancel'),
177 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
178 icon=icons.close(),
181 top = qtutils.splitter(Qt.Horizontal, self.tree, self.filewidget)
182 top.setSizes([75, 25])
184 main_split = qtutils.splitter(Qt.Vertical, top, self.diff)
185 main_split.setSizes([25, 75])
187 controls_layout = qtutils.hbox(
188 defs.no_margin,
189 defs.button_spacing,
190 self.cancel_button,
191 qtutils.STRETCH,
192 self.help_button,
193 self.extdiff_button,
194 self.rebase_button,
196 layout = qtutils.vbox(defs.no_margin, defs.spacing, main_split, controls_layout)
197 self.setLayout(layout)
199 self.action_rebase = qtutils.add_action(
200 self, N_('Rebase'), self.rebase, hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER
203 self.tree.commits_selected.connect(self.commits_selected)
204 self.tree.commits_selected.connect(self.filewidget.commits_selected)
205 self.tree.commits_selected.connect(self.diff.commits_selected)
206 self.tree.external_diff.connect(self.external_diff)
208 self.filewidget.files_selected.connect(self.diff.files_selected)
210 qtutils.connect_button(self.rebase_button, self.rebase)
211 qtutils.connect_button(self.extdiff_button, self.external_diff)
212 qtutils.connect_button(self.help_button, partial(show_help, context))
213 qtutils.connect_button(self.cancel_button, self.cancel)
215 def start(self):
216 insns = core.read(self.filename)
217 self.parse_sequencer_instructions(insns)
219 # signal callbacks
220 def commits_selected(self, commits):
221 self.extdiff_button.setEnabled(bool(commits))
223 # helpers
224 def parse_sequencer_instructions(self, insns):
225 idx = 1
226 re_comment_char = re.escape(self.comment_char)
227 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char)
228 update_ref_rgx = re.compile(
229 r'^\s*(%s)?\s*(u|update-ref)\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
260 match = update_ref_rgx.match(line)
261 if match:
262 enabled = match.group(1) is None
263 command = unabbrev(match.group(2))
264 branch = match.group(3)
265 self.tree.add_item(idx, enabled, command, branch=branch)
266 idx += 1
267 continue
269 self.tree.decorate(self.tree.items())
270 self.tree.refit()
271 self.tree.select_first()
273 # actions
274 def cancel(self):
275 if self.cancel_action == 'save':
276 status = self.save('')
277 else:
278 status = 1
280 self.status = status
281 self.exit.emit(status)
283 def rebase(self):
284 lines = [item.value() for item in self.tree.items()]
285 sequencer_instructions = '\n'.join(lines) + '\n'
286 status = self.save(sequencer_instructions)
287 self.status = status
288 self.exit.emit(status)
290 def save(self, string):
291 """Save the instruction sheet"""
292 try:
293 core.write(self.filename, string)
294 status = 0
295 except (OSError, IOError, ValueError) as e:
296 msg, details = utils.format_exception(e)
297 sys.stderr.write(msg + '\n\n' + details)
298 status = 128
299 return status
301 def external_diff(self):
302 items = self.tree.selected_items()
303 if not items:
304 return
305 item = items[0]
306 difftool.diff_expression(self.context, self, item.oid + '^!', hide_expr=True)
309 # pylint: disable=too-many-ancestors
310 class RebaseTreeWidget(standard.DraggableTreeWidget):
311 commits_selected = Signal(object)
312 external_diff = Signal()
313 move_rows = Signal(object, object)
315 def __init__(self, context, comment_char, parent):
316 super(RebaseTreeWidget, self).__init__(parent=parent)
317 self.context = context
318 self.comment_char = comment_char
319 # header
320 self.setHeaderLabels(
322 N_('#'),
323 N_('Enabled'),
324 N_('Command'),
325 N_('SHA-1'),
326 N_('Summary'),
329 self.header().setStretchLastSection(True)
330 self.setColumnCount(5)
331 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
333 # actions
334 self.copy_oid_action = qtutils.add_action(
335 self, N_('Copy SHA-1'), self.copy_oid, QtGui.QKeySequence.Copy
338 self.external_diff_action = qtutils.add_action(
339 self, N_('Launch Diff Tool'), self.external_diff.emit, hotkeys.DIFF
342 self.toggle_enabled_action = qtutils.add_action(
343 self, N_('Toggle Enabled'), self.toggle_enabled, hotkeys.PRIMARY_ACTION
346 self.action_pick = qtutils.add_action(
347 self, N_('Pick'), lambda: self.set_selected_to(PICK), *hotkeys.REBASE_PICK
350 self.action_reword = qtutils.add_action(
351 self,
352 N_('Reword'),
353 lambda: self.set_selected_to(REWORD),
354 *hotkeys.REBASE_REWORD
357 self.action_edit = qtutils.add_action(
358 self, N_('Edit'), lambda: self.set_selected_to(EDIT), *hotkeys.REBASE_EDIT
361 self.action_fixup = qtutils.add_action(
362 self,
363 N_('Fixup'),
364 lambda: self.set_selected_to(FIXUP),
365 *hotkeys.REBASE_FIXUP
368 self.action_squash = qtutils.add_action(
369 self,
370 N_('Squash'),
371 lambda: self.set_selected_to(SQUASH),
372 *hotkeys.REBASE_SQUASH
375 self.action_shift_down = qtutils.add_action(
376 self, N_('Shift Down'), self.shift_down, hotkeys.MOVE_DOWN_TERTIARY
379 self.action_shift_up = qtutils.add_action(
380 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY
383 # pylint: disable=no-member
384 self.itemChanged.connect(self.item_changed)
385 self.itemSelectionChanged.connect(self.selection_changed)
386 self.move_rows.connect(self.move)
387 self.items_moved.connect(self.decorate)
389 def add_item(
390 self, idx, enabled, command, oid='', summary='', cmdexec='', branch=''
392 comment_char = self.comment_char
393 item = RebaseTreeWidgetItem(
394 idx,
395 enabled,
396 command,
397 oid=oid,
398 summary=summary,
399 cmdexec=cmdexec,
400 branch=branch,
401 comment_char=comment_char,
403 self.invisibleRootItem().addChild(item)
405 def decorate(self, items):
406 for item in items:
407 item.decorate(self)
409 def refit(self):
410 self.resizeColumnToContents(0)
411 self.resizeColumnToContents(1)
412 self.resizeColumnToContents(2)
413 self.resizeColumnToContents(3)
414 self.resizeColumnToContents(4)
416 # actions
417 def item_changed(self, item, column):
418 if column == item.ENABLED_COLUMN:
419 self.validate()
421 def validate(self):
422 invalid_first_choice = set([FIXUP, SQUASH])
423 for item in self.items():
424 if item.is_enabled() and item.is_commit():
425 if item.command in invalid_first_choice:
426 item.reset_command(PICK)
427 break
429 def set_selected_to(self, command):
430 for i in self.selected_items():
431 i.reset_command(command)
432 self.validate()
434 def set_command(self, item, command):
435 item.reset_command(command)
436 self.validate()
438 def copy_oid(self):
439 item = self.selected_item()
440 if item is None:
441 return
442 clipboard = item.oid or item.cmdexec
443 qtutils.set_clipboard(clipboard)
445 def selection_changed(self):
446 item = self.selected_item()
447 if item is None or not item.is_commit():
448 return
449 context = self.context
450 oid = item.oid
451 params = dag.DAG(oid, 2)
452 repo = dag.RepoReader(context, params)
453 commits = []
454 for c in repo.get():
455 commits.append(c)
456 if commits:
457 commits = commits[-1:]
458 self.commits_selected.emit(commits)
460 def toggle_enabled(self):
461 items = self.selected_items()
462 logic_or = reduce(lambda res, item: res or item.is_enabled(), items, False)
463 for item in items:
464 item.set_enabled(not logic_or)
466 def select_first(self):
467 items = self.items()
468 if not items:
469 return
470 idx = self.model().index(0, 0)
471 if idx.isValid():
472 self.setCurrentIndex(idx)
474 def shift_down(self):
475 sel_items = self.selected_items()
476 all_items = self.items()
477 sel_idx = sorted([all_items.index(item) for item in sel_items])
478 if not sel_idx:
479 return
480 idx = sel_idx[0] + 1
481 if not (
482 idx > len(all_items) - len(sel_items)
483 or all_items[sel_idx[-1]] is all_items[-1]
485 self.move_rows.emit(sel_idx, idx)
487 def shift_up(self):
488 sel_items = self.selected_items()
489 all_items = self.items()
490 sel_idx = sorted([all_items.index(item) for item in sel_items])
491 if not sel_idx:
492 return
493 idx = sel_idx[0] - 1
494 if idx >= 0:
495 self.move_rows.emit(sel_idx, idx)
497 def move(self, src_idxs, dst_idx):
498 moved_items = []
499 src_base = sorted(src_idxs)[0]
500 for idx in reversed(sorted(src_idxs)):
501 item = self.invisibleRootItem().takeChild(idx)
502 moved_items.insert(0, [dst_idx + (idx - src_base), item])
504 for item in moved_items:
505 self.invisibleRootItem().insertChild(item[0], item[1])
506 self.setCurrentItem(item[1])
508 if moved_items:
509 moved_items = [item[1] for item in moved_items]
510 # If we've moved to the top then we need to re-decorate all items.
511 # Otherwise, we can decorate just the new items.
512 if dst_idx == 0:
513 self.decorate(self.items())
514 else:
515 self.decorate(moved_items)
517 for item in moved_items:
518 item.setSelected(True)
519 self.validate()
521 # Qt events
523 def dropEvent(self, event):
524 super(RebaseTreeWidget, self).dropEvent(event)
525 self.validate()
527 def contextMenuEvent(self, event):
528 items = self.selected_items()
529 menu = qtutils.create_menu(N_('Actions'), self)
530 menu.addAction(self.action_pick)
531 menu.addAction(self.action_reword)
532 menu.addAction(self.action_edit)
533 menu.addAction(self.action_fixup)
534 menu.addAction(self.action_squash)
535 menu.addSeparator()
536 menu.addAction(self.toggle_enabled_action)
537 menu.addSeparator()
538 menu.addAction(self.copy_oid_action)
539 if len(items) > 1:
540 self.copy_oid_action.setDisabled(True)
541 menu.addAction(self.external_diff_action)
542 if len(items) > 1:
543 self.external_diff_action.setDisabled(True)
544 menu.exec_(self.mapToGlobal(event.pos()))
547 class ComboBox(QtWidgets.QComboBox):
548 validate = Signal()
551 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
553 ENABLED_COLUMN = 1
554 COMMAND_COLUMN = 2
555 OID_LENGTH = 7
557 def __init__(
558 self,
559 idx,
560 enabled,
561 command,
562 oid='',
563 summary='',
564 cmdexec='',
565 branch='',
566 comment_char='#',
567 parent=None,
569 QtWidgets.QTreeWidgetItem.__init__(self, parent)
570 self.combo = None
571 self.command = command
572 self.idx = idx
573 self.oid = oid
574 self.summary = summary
575 self.cmdexec = cmdexec
576 self.branch = branch
577 self.comment_char = comment_char
579 # if core.abbrev is set to a higher value then we will notice by
580 # simply tracking the longest oid we've seen
581 oid_len = self.__class__.OID_LENGTH
582 self.__class__.OID_LENGTH = max(len(oid), oid_len)
584 self.setText(0, '%02d' % idx)
585 self.set_enabled(enabled)
586 # checkbox on 1
587 # combo box on 2
588 if self.is_exec():
589 self.setText(3, '')
590 self.setText(4, cmdexec)
591 elif self.is_update_ref():
592 self.setText(3, '')
593 self.setText(4, branch)
594 else:
595 self.setText(3, oid)
596 self.setText(4, summary)
598 flags = self.flags() | Qt.ItemIsUserCheckable
599 flags = flags | Qt.ItemIsDragEnabled
600 flags = flags & ~Qt.ItemIsDropEnabled
601 self.setFlags(flags)
603 def __eq__(self, other):
604 return self is other
606 def __hash__(self):
607 return self.oid
609 def copy(self):
610 return self.__class__(
611 self.idx,
612 self.is_enabled(),
613 self.command,
614 oid=self.oid,
615 summary=self.summary,
616 cmdexec=self.cmdexec,
617 branch=self.branch,
618 comment_char=self.comment_char,
621 def decorate(self, parent):
622 if self.is_exec():
623 items = [EXEC]
624 idx = 0
625 elif self.is_update_ref():
626 items = [UPDATE_REF]
627 idx = 0
628 else:
629 items = COMMANDS
630 idx = COMMAND_IDX[self.command]
631 combo = self.combo = ComboBox()
632 combo.setEditable(False)
633 combo.addItems(items)
634 combo.setCurrentIndex(idx)
635 combo.setEnabled(self.is_commit())
637 signal = combo.currentIndexChanged
638 # pylint: disable=no-member
639 signal.connect(lambda x: self.set_command_and_validate(combo))
640 combo.validate.connect(parent.validate)
642 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
644 def is_exec(self):
645 return self.command == EXEC
647 def is_update_ref(self):
648 return self.command == UPDATE_REF
650 def is_commit(self):
651 return bool(
652 not (self.is_exec() or self.is_update_ref()) and self.oid and self.summary
655 def value(self):
656 """Return the serialized representation of an item"""
657 if self.is_enabled():
658 comment = ''
659 else:
660 comment = self.comment_char + ' '
661 if self.is_exec():
662 return '%s%s %s' % (comment, self.command, self.cmdexec)
663 if self.is_update_ref():
664 return '%s%s %s' % (comment, self.command, self.branch)
665 return '%s%s %s %s' % (comment, self.command, self.oid, self.summary)
667 def is_enabled(self):
668 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
670 def set_enabled(self, enabled):
671 self.setCheckState(self.ENABLED_COLUMN, enabled and Qt.Checked or Qt.Unchecked)
673 def toggle_enabled(self):
674 self.set_enabled(not self.is_enabled())
676 def set_command(self, command):
677 """Set the item to a different command, no-op for exec items"""
678 if self.is_exec():
679 return
680 self.command = command
682 def refresh(self):
683 """Update the view to match the updated state"""
684 if self.is_commit():
685 command = self.command
686 self.combo.setCurrentIndex(COMMAND_IDX[command])
688 def reset_command(self, command):
689 """Set and refresh the item in one shot"""
690 self.set_command(command)
691 self.refresh()
693 def set_command_and_validate(self, combo):
694 command = COMMANDS[combo.currentIndex()]
695 self.set_command(command)
696 self.combo.validate.emit()
699 def show_help(context):
700 help_text = N_(
702 Commands
703 --------
704 pick = use commit
705 reword = use commit, but edit the commit message
706 edit = use commit, but stop for amending
707 squash = use commit, but meld into previous commit
708 fixup = like "squash", but discard this commit's log message
709 exec = run command (the rest of the line) using shell
710 update-ref = update branches that point to commits
712 These lines can be re-ordered; they are executed from top to bottom.
714 If you disable a line here THAT COMMIT WILL BE LOST.
716 However, if you disable everything, the rebase will be aborted.
718 Keyboard Shortcuts
719 ------------------
720 ? = show help
721 j = move down
722 k = move up
723 J = shift row down
724 K = shift row up
726 1, p = pick
727 2, r = reword
728 3, e = edit
729 4, f = fixup
730 5, s = squash
731 spacebar = toggle enabled
733 ctrl+enter = accept changes and rebase
734 ctrl+q = cancel and abort the rebase
735 ctrl+d = launch difftool
738 title = N_('Help - git-cola-sequence-editor')
739 return text.text_dialog(context, help_text, title)