diff: make the context menu more consistent when unstaging
[git-cola.git] / cola / sequenceeditor.py
blob155ec1b04081ac54e0fc257d6b9849dd50341193
1 import sys
2 import re
3 from argparse import ArgumentParser
4 from functools import partial, reduce
6 from cola import app # prints a message if Qt cannot be found
7 from qtpy import QtCore
8 from qtpy import QtGui
9 from qtpy import QtWidgets
10 from qtpy.QtCore import Qt
11 from qtpy.QtCore import Signal
13 # pylint: disable=ungrouped-imports
14 from cola import core
15 from cola import difftool
16 from cola import hotkeys
17 from cola import icons
18 from cola import qtutils
19 from cola import utils
20 from cola.i18n import N_
21 from cola.models import dag
22 from cola.models import prefs
23 from cola.widgets import defs
24 from cola.widgets import filelist
25 from cola.widgets import diff
26 from cola.widgets import standard
27 from cola.widgets import text
30 PICK = 'pick'
31 REWORD = 'reword'
32 EDIT = 'edit'
33 FIXUP = 'fixup'
34 SQUASH = 'squash'
35 UPDATE_REF = 'update-ref'
36 EXEC = 'exec'
37 COMMANDS = (
38 PICK,
39 REWORD,
40 EDIT,
41 FIXUP,
42 SQUASH,
44 COMMAND_IDX = {cmd_: idx_ for idx_, cmd_ in enumerate(COMMANDS)}
45 ABBREV = {
46 'p': PICK,
47 'r': REWORD,
48 'e': EDIT,
49 'f': FIXUP,
50 's': SQUASH,
51 'x': EXEC,
52 'u': UPDATE_REF,
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().__init__(parent)
96 self.context = context
97 self.status = 1
98 self.editor = None
99 default_title = '%s - git cola sequence 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 if utils.is_darwin():
118 width, height = qtutils.desktop_size()
119 self.resize(width, height)
120 else:
121 self.showMaximized()
123 def set_editor(self, editor):
124 self.editor = editor
125 self.setCentralWidget(editor)
126 editor.exit.connect(self.exit)
127 editor.setFocus()
129 def start(self, _context, _view):
130 self.editor.start()
132 def exit(self, status):
133 self.status = status
134 self.close()
137 class Editor(QtWidgets.QWidget):
138 exit = Signal(int)
140 def __init__(self, context, filename, parent=None):
141 super().__init__(parent)
143 self.widget_version = 1
144 self.status = 1
145 self.context = context
146 self.filename = filename
147 self.comment_char = comment_char = prefs.comment_char(context)
148 self.cancel_action = core.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
150 self.diff = diff.DiffWidget(context, self)
151 self.tree = RebaseTreeWidget(context, comment_char, self)
152 self.filewidget = filelist.FileWidget(context, self)
153 self.setFocusProxy(self.tree)
155 self.rebase_button = qtutils.create_button(
156 text=core.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
157 tooltip=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
158 icon=icons.ok(),
159 default=True,
162 self.extdiff_button = qtutils.create_button(
163 text=N_('Launch Diff Tool'),
164 tooltip=N_('Launch external diff tool\nShortcut: Ctrl+D'),
166 self.extdiff_button.setEnabled(False)
168 self.help_button = qtutils.create_button(
169 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'), icon=icons.question()
172 self.cancel_button = qtutils.create_button(
173 text=N_('Cancel'),
174 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
175 icon=icons.close(),
178 top = qtutils.splitter(Qt.Horizontal, self.tree, self.filewidget)
179 top.setSizes([75, 25])
181 main_split = qtutils.splitter(Qt.Vertical, top, self.diff)
182 main_split.setSizes([25, 75])
184 controls_layout = qtutils.hbox(
185 defs.no_margin,
186 defs.button_spacing,
187 self.cancel_button,
188 qtutils.STRETCH,
189 self.help_button,
190 self.extdiff_button,
191 self.rebase_button,
193 layout = qtutils.vbox(defs.no_margin, defs.spacing, main_split, controls_layout)
194 self.setLayout(layout)
196 self.action_rebase = qtutils.add_action(
197 self, N_('Rebase'), self.rebase, hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER
200 self.tree.commits_selected.connect(self.commits_selected)
201 self.tree.commits_selected.connect(self.filewidget.commits_selected)
202 self.tree.commits_selected.connect(self.diff.commits_selected)
203 self.tree.external_diff.connect(self.external_diff)
205 self.filewidget.files_selected.connect(self.diff.files_selected)
207 qtutils.connect_button(self.rebase_button, self.rebase)
208 qtutils.connect_button(self.extdiff_button, self.external_diff)
209 qtutils.connect_button(self.help_button, partial(show_help, context))
210 qtutils.connect_button(self.cancel_button, self.cancel)
212 def start(self):
213 insns = core.read(self.filename)
214 self.parse_sequencer_instructions(insns)
216 # signal callbacks
217 def commits_selected(self, commits):
218 self.extdiff_button.setEnabled(bool(commits))
220 # helpers
221 def parse_sequencer_instructions(self, insns):
222 idx = 1
223 re_comment_char = re.escape(self.comment_char)
224 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char)
225 update_ref_rgx = re.compile(
226 r'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
228 # The upper bound of 40 below must match git.OID_LENGTH.
229 # We'll have to update this to the new hash length when that happens.
230 pick_rgx = re.compile(
232 r'^\s*(%s)?\s*'
233 + r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
234 + r'\s+([0-9a-f]{7,40})'
235 + r'\s+(.+)$'
237 % re_comment_char
239 for line in insns.splitlines():
240 match = pick_rgx.match(line)
241 if match:
242 enabled = match.group(1) is None
243 command = unabbrev(match.group(2))
244 oid = match.group(3)
245 summary = match.group(4)
246 self.tree.add_item(idx, enabled, command, oid=oid, summary=summary)
247 idx += 1
248 continue
249 match = exec_rgx.match(line)
250 if match:
251 enabled = match.group(1) is None
252 command = unabbrev(match.group(2))
253 cmdexec = match.group(3)
254 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
255 idx += 1
256 continue
257 match = update_ref_rgx.match(line)
258 if match:
259 enabled = match.group(1) is None
260 command = unabbrev(match.group(2))
261 branch = match.group(3)
262 self.tree.add_item(idx, enabled, command, branch=branch)
263 idx += 1
264 continue
266 self.tree.decorate(self.tree.items())
267 self.tree.refit()
268 self.tree.select_first()
270 # actions
271 def cancel(self):
272 if self.cancel_action == 'save':
273 status = self.save('')
274 else:
275 status = 1
277 self.status = status
278 self.exit.emit(status)
280 def rebase(self):
281 lines = [item.value() for item in self.tree.items()]
282 sequencer_instructions = '\n'.join(lines) + '\n'
283 status = self.save(sequencer_instructions)
284 self.status = status
285 self.exit.emit(status)
287 def save(self, string):
288 """Save the instruction sheet"""
289 try:
290 core.write(self.filename, string)
291 status = 0
292 except (OSError, ValueError) as exc:
293 msg, details = utils.format_exception(exc)
294 sys.stderr.write(msg + '\n\n' + details)
295 status = 128
296 return status
298 def external_diff(self):
299 items = self.tree.selected_items()
300 if not items:
301 return
302 item = items[0]
303 difftool.diff_expression(self.context, self, item.oid + '^!', hide_expr=True)
306 # pylint: disable=too-many-ancestors
307 class RebaseTreeWidget(standard.DraggableTreeWidget):
308 commits_selected = Signal(object)
309 external_diff = Signal()
310 move_rows = Signal(object, object)
312 def __init__(self, context, comment_char, parent):
313 super().__init__(parent=parent)
314 self.context = context
315 self.comment_char = comment_char
316 # header
317 self.setHeaderLabels([
318 N_('#'),
319 N_('Enabled'),
320 N_('Command'),
321 N_('SHA-1'),
322 N_('Summary'),
324 self.header().setStretchLastSection(True)
325 self.setColumnCount(5)
326 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
328 # actions
329 self.copy_oid_action = qtutils.add_action(
330 self, N_('Copy SHA-1'), self.copy_oid, QtGui.QKeySequence.Copy
333 self.external_diff_action = qtutils.add_action(
334 self, N_('Launch Diff Tool'), self.external_diff.emit, hotkeys.DIFF
337 self.toggle_enabled_action = qtutils.add_action(
338 self, N_('Toggle Enabled'), self.toggle_enabled, hotkeys.PRIMARY_ACTION
341 self.action_pick = qtutils.add_action(
342 self, N_('Pick'), lambda: self.set_selected_to(PICK), *hotkeys.REBASE_PICK
345 self.action_reword = qtutils.add_action(
346 self,
347 N_('Reword'),
348 lambda: self.set_selected_to(REWORD),
349 *hotkeys.REBASE_REWORD,
352 self.action_edit = qtutils.add_action(
353 self, N_('Edit'), lambda: self.set_selected_to(EDIT), *hotkeys.REBASE_EDIT
356 self.action_fixup = qtutils.add_action(
357 self,
358 N_('Fixup'),
359 lambda: self.set_selected_to(FIXUP),
360 *hotkeys.REBASE_FIXUP,
363 self.action_squash = qtutils.add_action(
364 self,
365 N_('Squash'),
366 lambda: self.set_selected_to(SQUASH),
367 *hotkeys.REBASE_SQUASH,
370 self.action_shift_down = qtutils.add_action(
371 self, N_('Shift Down'), self.shift_down, hotkeys.MOVE_DOWN_TERTIARY
374 self.action_shift_up = qtutils.add_action(
375 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY
378 # pylint: disable=no-member
379 self.itemChanged.connect(self.item_changed)
380 self.itemSelectionChanged.connect(self.selection_changed)
381 self.move_rows.connect(self.move)
382 self.items_moved.connect(self.decorate)
384 def add_item(
385 self, idx, enabled, command, oid='', summary='', cmdexec='', branch=''
387 comment_char = self.comment_char
388 item = RebaseTreeWidgetItem(
389 idx,
390 enabled,
391 command,
392 oid=oid,
393 summary=summary,
394 cmdexec=cmdexec,
395 branch=branch,
396 comment_char=comment_char,
398 self.invisibleRootItem().addChild(item)
400 def decorate(self, items):
401 for item in items:
402 item.decorate(self)
404 def refit(self):
405 self.resizeColumnToContents(0)
406 self.resizeColumnToContents(1)
407 self.resizeColumnToContents(2)
408 self.resizeColumnToContents(3)
409 self.resizeColumnToContents(4)
411 # actions
412 def item_changed(self, item, column):
413 if column == item.ENABLED_COLUMN:
414 self.validate()
416 def validate(self):
417 invalid_first_choice = {FIXUP, SQUASH}
418 for item in self.items():
419 if item.is_enabled() and item.is_commit():
420 if item.command in invalid_first_choice:
421 item.reset_command(PICK)
422 break
424 def set_selected_to(self, command):
425 for i in self.selected_items():
426 i.reset_command(command)
427 self.validate()
429 def set_command(self, item, command):
430 item.reset_command(command)
431 self.validate()
433 def copy_oid(self):
434 item = self.selected_item()
435 if item is None:
436 return
437 clipboard = item.oid or item.cmdexec
438 qtutils.set_clipboard(clipboard)
440 def selection_changed(self):
441 item = self.selected_item()
442 if item is None or not item.is_commit():
443 return
444 context = self.context
445 oid = item.oid
446 params = dag.DAG(oid, 2)
447 repo = dag.RepoReader(context, params)
448 commits = []
449 for commit in repo.get():
450 commits.append(commit)
451 if commits:
452 commits = commits[-1:]
453 self.commits_selected.emit(commits)
455 def toggle_enabled(self):
456 items = self.selected_items()
457 logic_or = reduce(lambda res, item: res or item.is_enabled(), items, False)
458 for item in items:
459 item.set_enabled(not logic_or)
461 def select_first(self):
462 items = self.items()
463 if not items:
464 return
465 idx = self.model().index(0, 0)
466 if idx.isValid():
467 self.setCurrentIndex(idx)
469 def shift_down(self):
470 sel_items = self.selected_items()
471 all_items = self.items()
472 sel_idx = sorted([all_items.index(item) for item in sel_items])
473 if not sel_idx:
474 return
475 idx = sel_idx[0] + 1
476 if not (
477 idx > len(all_items) - len(sel_items)
478 or all_items[sel_idx[-1]] is all_items[-1]
480 self.move_rows.emit(sel_idx, idx)
482 def shift_up(self):
483 sel_items = self.selected_items()
484 all_items = self.items()
485 sel_idx = sorted([all_items.index(item) for item in sel_items])
486 if not sel_idx:
487 return
488 idx = sel_idx[0] - 1
489 if idx >= 0:
490 self.move_rows.emit(sel_idx, idx)
492 def move(self, src_idxs, dst_idx):
493 moved_items = []
494 src_base = sorted(src_idxs)[0]
495 for idx in reversed(sorted(src_idxs)):
496 item = self.invisibleRootItem().takeChild(idx)
497 moved_items.insert(0, [dst_idx + (idx - src_base), item])
499 for item in moved_items:
500 self.invisibleRootItem().insertChild(item[0], item[1])
501 self.setCurrentItem(item[1])
503 if moved_items:
504 moved_items = [item[1] for item in moved_items]
505 # If we've moved to the top then we need to re-decorate all items.
506 # Otherwise, we can decorate just the new items.
507 if dst_idx == 0:
508 self.decorate(self.items())
509 else:
510 self.decorate(moved_items)
512 for item in moved_items:
513 item.setSelected(True)
514 self.validate()
516 # Qt events
518 def dropEvent(self, event):
519 super().dropEvent(event)
520 self.validate()
522 def contextMenuEvent(self, event):
523 items = self.selected_items()
524 menu = qtutils.create_menu(N_('Actions'), self)
525 menu.addAction(self.action_pick)
526 menu.addAction(self.action_reword)
527 menu.addAction(self.action_edit)
528 menu.addAction(self.action_fixup)
529 menu.addAction(self.action_squash)
530 menu.addSeparator()
531 menu.addAction(self.toggle_enabled_action)
532 menu.addSeparator()
533 menu.addAction(self.copy_oid_action)
534 if len(items) > 1:
535 self.copy_oid_action.setDisabled(True)
536 menu.addAction(self.external_diff_action)
537 if len(items) > 1:
538 self.external_diff_action.setDisabled(True)
539 menu.exec_(self.mapToGlobal(event.pos()))
542 class ComboBox(QtWidgets.QComboBox):
543 validate = Signal()
546 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
547 ENABLED_COLUMN = 1
548 COMMAND_COLUMN = 2
549 OID_LENGTH = 7
551 def __init__(
552 self,
553 idx,
554 enabled,
555 command,
556 oid='',
557 summary='',
558 cmdexec='',
559 branch='',
560 comment_char='#',
561 parent=None,
563 QtWidgets.QTreeWidgetItem.__init__(self, parent)
564 self.combo = None
565 self.command = command
566 self.idx = idx
567 self.oid = oid
568 self.summary = summary
569 self.cmdexec = cmdexec
570 self.branch = branch
571 self.comment_char = comment_char
573 # if core.abbrev is set to a higher value then we will notice by
574 # simply tracking the longest oid we've seen
575 oid_len = self.__class__.OID_LENGTH
576 self.__class__.OID_LENGTH = max(len(oid), oid_len)
578 self.setText(0, '%02d' % idx)
579 self.set_enabled(enabled)
580 # checkbox on 1
581 # combo box on 2
582 if self.is_exec():
583 self.setText(3, '')
584 self.setText(4, cmdexec)
585 elif self.is_update_ref():
586 self.setText(3, '')
587 self.setText(4, branch)
588 else:
589 self.setText(3, oid)
590 self.setText(4, summary)
592 flags = self.flags() | Qt.ItemIsUserCheckable
593 flags = flags | Qt.ItemIsDragEnabled
594 flags = flags & ~Qt.ItemIsDropEnabled
595 self.setFlags(flags)
597 def __eq__(self, other):
598 return self is other
600 def __hash__(self):
601 return self.oid
603 def copy(self):
604 return self.__class__(
605 self.idx,
606 self.is_enabled(),
607 self.command,
608 oid=self.oid,
609 summary=self.summary,
610 cmdexec=self.cmdexec,
611 branch=self.branch,
612 comment_char=self.comment_char,
615 def decorate(self, parent):
616 if self.is_exec():
617 items = [EXEC]
618 idx = 0
619 elif self.is_update_ref():
620 items = [UPDATE_REF]
621 idx = 0
622 else:
623 items = COMMANDS
624 idx = COMMAND_IDX[self.command]
625 combo = self.combo = ComboBox()
626 combo.setEditable(False)
627 combo.addItems(items)
628 combo.setCurrentIndex(idx)
629 combo.setEnabled(self.is_commit())
631 signal = combo.currentIndexChanged
632 # pylint: disable=no-member
633 signal.connect(lambda x: self.set_command_and_validate(combo))
634 combo.validate.connect(parent.validate)
636 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
638 def is_exec(self):
639 return self.command == EXEC
641 def is_update_ref(self):
642 return self.command == UPDATE_REF
644 def is_commit(self):
645 return bool(
646 not (self.is_exec() or self.is_update_ref()) and self.oid and self.summary
649 def value(self):
650 """Return the serialized representation of an item"""
651 if self.is_enabled():
652 comment = ''
653 else:
654 comment = self.comment_char + ' '
655 if self.is_exec():
656 return f'{comment}{self.command} {self.cmdexec}'
657 if self.is_update_ref():
658 return f'{comment}{self.command} {self.branch}'
659 return f'{comment}{self.command} {self.oid} {self.summary}'
661 def is_enabled(self):
662 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
664 def set_enabled(self, enabled):
665 self.setCheckState(self.ENABLED_COLUMN, enabled and Qt.Checked or Qt.Unchecked)
667 def toggle_enabled(self):
668 self.set_enabled(not self.is_enabled())
670 def set_command(self, command):
671 """Set the item to a different command, no-op for exec items"""
672 if self.is_exec():
673 return
674 self.command = command
676 def refresh(self):
677 """Update the view to match the updated state"""
678 if self.is_commit():
679 command = self.command
680 self.combo.setCurrentIndex(COMMAND_IDX[command])
682 def reset_command(self, command):
683 """Set and refresh the item in one shot"""
684 self.set_command(command)
685 self.refresh()
687 def set_command_and_validate(self, combo):
688 command = COMMANDS[combo.currentIndex()]
689 self.set_command(command)
690 self.combo.validate.emit()
693 def show_help(context):
694 help_text = N_(
696 Commands
697 --------
698 pick = use commit
699 reword = use commit, but edit the commit message
700 edit = use commit, but stop for amending
701 squash = use commit, but meld into previous commit
702 fixup = like "squash", but discard this commit's log message
703 exec = run command (the rest of the line) using shell
704 update-ref = update branches that point to commits
706 These lines can be re-ordered; they are executed from top to bottom.
708 If you disable a line here THAT COMMIT WILL BE LOST.
710 However, if you disable everything, the rebase will be aborted.
712 Keyboard Shortcuts
713 ------------------
714 ? = show help
715 j = move down
716 k = move up
717 J = shift row down
718 K = shift row up
720 1, p = pick
721 2, r = reword
722 3, e = edit
723 4, f = fixup
724 5, s = squash
725 spacebar = toggle enabled
727 ctrl+enter = accept changes and rebase
728 ctrl+q = cancel and abort the rebase
729 ctrl+d = launch difftool
732 title = N_('Help - git-cola-sequence-editor')
733 return text.text_dialog(context, help_text, title)