3 from argparse
import ArgumentParser
4 from functools
import partial
, reduce
5 from threading
import Thread
7 from cola
import app
# prints a message if Qt cannot be found
8 from qtpy
import QtCore
10 from qtpy
import QtWidgets
11 from qtpy
.QtCore
import Qt
12 from qtpy
.QtCore
import Signal
14 # pylint: disable=ungrouped-imports
16 from cola
import difftool
17 from cola
import gitcmds
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
37 UPDATE_REF
= 'update-ref'
46 COMMAND_IDX
= {cmd_
: idx_
for idx_
, cmd_
in enumerate(COMMANDS
)}
59 """Start a git-cola-sequence-editor session"""
61 context
= app
.application_init(args
)
62 view
= new_window(context
, args
.filename
)
63 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
67 def stop(_context
, _view
):
68 """All done, cleanup"""
69 QtCore
.QThreadPool
.globalInstance().waitForDone()
73 parser
= ArgumentParser()
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
)
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().__init
__(parent
)
98 self
.context
= context
101 # Final user decision at the window close moment.
102 # If user just closed the window, it's considered to be canceled.
103 self
.cancelled
= True
106 default_title
= '%s - git cola sequence editor' % core
.getcwd()
107 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
108 self
.setWindowTitle(title
)
110 self
.show_help_action
= qtutils
.add_action(
111 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
114 self
.menubar
= QtWidgets
.QMenuBar(self
)
115 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
116 self
.help_menu
.addAction(self
.show_help_action
)
117 self
.setMenuBar(self
.menubar
)
119 qtutils
.add_close_action(self
)
120 self
.init_state(context
.settings
, self
.init_window_size
)
122 def init_window_size(self
):
123 """Set the window size on the first initial view"""
124 if utils
.is_darwin():
125 width
, height
= qtutils
.desktop_size()
126 self
.resize(width
, height
)
130 def set_editor(self
, editor
):
132 self
.setCentralWidget(editor
)
133 editor
.cancel
.connect(self
.cancel
)
134 editor
.rebase
.connect(self
.rebase
)
137 def start(self
, _context
, _view
):
141 self
.cancelled
= True
145 self
.cancelled
= False
148 def closeEvent(self
, event
):
149 self
.editor
.stopped()
152 cancel_action
= core
.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
154 if cancel_action
== 'save':
155 status
= self
.editor
.save('')
159 status
= self
.editor
.save()
163 super().closeEvent(event
)
166 class Editor(QtWidgets
.QWidget
):
170 def __init__(self
, context
, filename
, parent
=None):
171 super().__init
__(parent
)
173 self
.widget_version
= 1
174 self
.context
= context
175 self
.filename
= filename
176 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
178 self
.diff
= diff
.DiffWidget(context
, self
)
179 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
180 self
.filewidget
= filelist
.FileWidget(context
, self
, remarks
=True)
181 self
.setFocusProxy(self
.tree
)
183 self
.rebase_button
= qtutils
.create_button(
184 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
185 tooltip
=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
190 self
.extdiff_button
= qtutils
.create_button(
191 text
=N_('Launch Diff Tool'),
192 tooltip
=N_('Launch external diff tool\nShortcut: Ctrl+D'),
194 self
.extdiff_button
.setEnabled(False)
196 self
.help_button
= qtutils
.create_button(
197 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
200 self
.cancel_button
= qtutils
.create_button(
202 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
206 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
207 top
.setSizes([75, 25])
209 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
210 main_split
.setSizes([25, 75])
212 controls_layout
= qtutils
.hbox(
221 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
222 self
.setLayout(layout
)
224 self
.action_rebase
= qtutils
.add_action(
232 self
.tree
.commits_selected
.connect(self
.commits_selected
)
233 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
234 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
235 self
.tree
.external_diff
.connect(self
.external_diff
)
237 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
238 self
.filewidget
.remark_toggled
.connect(self
.remark_toggled_for_files
)
240 # `git` calls are too expensive.
241 # When user toggles a remark of all commits touching selected paths
242 # the GUI freezes for a while on a big enough sequence.
243 # So, a cache is used (commit ID to paths tuple) to avoid freezing
244 # during consequent work.
245 self
.oid_to_paths
= {}
246 # A thread fills this cache in background to reduce the first
248 # This flag stops it.
251 qtutils
.connect_button(self
.rebase_button
, self
.rebase
.emit
)
252 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
253 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
254 qtutils
.connect_button(self
.cancel_button
, self
.cancel
.emit
)
257 insns
= core
.read(self
.filename
)
258 self
.parse_sequencer_instructions(insns
)
260 # Assume that the tree is filled at this point.
261 Thread(target
=self
.poll_touched_paths_main
).start()
267 def commits_selected(self
, commits
):
268 self
.extdiff_button
.setEnabled(bool(commits
))
270 def remark_toggled_for_files(self
, remark
, filenames
):
271 filenames
= set(filenames
)
273 items
= self
.tree
.items()
277 if not item
.is_commit():
280 paths
= self
.paths_touched_by_oid(oid
)
281 if filenames
.intersection(paths
):
282 touching_items
.append(item
)
284 self
.tree
.toggle_remark_of_items(remark
, touching_items
)
286 def external_diff(self
):
287 items
= self
.tree
.selected_items()
291 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
295 def paths_touched_by_oid(self
, oid
):
297 return self
.oid_to_paths
[oid
]
301 paths
= gitcmds
.changed_files(self
.context
, oid
)
302 self
.oid_to_paths
[oid
] = paths
306 def poll_touched_paths_main(self
):
307 for item
in self
.tree
.items():
310 self
.paths_touched_by_oid(item
.oid
)
312 def parse_sequencer_instructions(self
, insns
):
314 re_comment_char
= re
.escape(self
.comment_char
)
315 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
316 update_ref_rgx
= re
.compile(
317 r
'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
319 # The upper bound of 40 below must match git.OID_LENGTH.
320 # We'll have to update this to the new hash length when that happens.
321 pick_rgx
= re
.compile(
324 + r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
325 + r
'\s+([0-9a-f]{7,40})'
330 for line
in insns
.splitlines():
331 match
= pick_rgx
.match(line
)
333 enabled
= match
.group(1) is None
334 command
= unabbrev(match
.group(2))
336 summary
= match
.group(4)
337 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
340 match
= exec_rgx
.match(line
)
342 enabled
= match
.group(1) is None
343 command
= unabbrev(match
.group(2))
344 cmdexec
= match
.group(3)
345 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
348 match
= update_ref_rgx
.match(line
)
350 enabled
= match
.group(1) is None
351 command
= unabbrev(match
.group(2))
352 branch
= match
.group(3)
353 self
.tree
.add_item(idx
, enabled
, command
, branch
=branch
)
357 self
.tree
.decorate(self
.tree
.items())
359 self
.tree
.select_first()
361 def save(self
, string
=None):
362 """Save the instruction sheet"""
365 lines
= [item
.value() for item
in self
.tree
.items()]
366 # sequencer instructions
367 string
= '\n'.join(lines
) + '\n'
370 core
.write(self
.filename
, string
)
372 except (OSError, ValueError) as exc
:
373 msg
, details
= utils
.format_exception(exc
)
374 sys
.stderr
.write(msg
+ '\n\n' + details
)
379 # pylint: disable=too-many-ancestors
380 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
381 commits_selected
= Signal(object)
382 external_diff
= Signal()
383 move_rows
= Signal(object, object)
385 def __init__(self
, context
, comment_char
, parent
):
386 super().__init
__(parent
=parent
)
387 self
.context
= context
388 self
.comment_char
= comment_char
390 self
.setHeaderLabels([
398 self
.header().setStretchLastSection(True)
399 self
.setColumnCount(6)
400 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
403 self
.copy_oid_action
= qtutils
.add_action(
404 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
407 self
.external_diff_action
= qtutils
.add_action(
408 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
411 self
.toggle_enabled_action
= qtutils
.add_action(
412 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
415 self
.action_pick
= qtutils
.add_action(
416 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
419 self
.action_reword
= qtutils
.add_action(
422 lambda: self
.set_selected_to(REWORD
),
423 *hotkeys
.REBASE_REWORD
,
426 self
.action_edit
= qtutils
.add_action(
427 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
430 self
.action_fixup
= qtutils
.add_action(
433 lambda: self
.set_selected_to(FIXUP
),
434 *hotkeys
.REBASE_FIXUP
,
437 self
.action_squash
= qtutils
.add_action(
440 lambda: self
.set_selected_to(SQUASH
),
441 *hotkeys
.REBASE_SQUASH
,
444 self
.action_shift_down
= qtutils
.add_action(
445 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
448 self
.action_shift_up
= qtutils
.add_action(
449 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
452 self
.toggle_remark_actions
= tuple(
456 lambda remark
=r
: self
.toggle_remark(remark
),
457 hotkeys
.hotkey(Qt
.CTRL |
getattr(Qt
, 'Key_' + r
)),
459 for r
in map(str, range(10))
462 # pylint: disable=no-member
463 self
.itemChanged
.connect(self
.item_changed
)
464 self
.itemSelectionChanged
.connect(self
.selection_changed
)
465 self
.move_rows
.connect(self
.move
)
466 self
.items_moved
.connect(self
.decorate
)
469 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
471 comment_char
= self
.comment_char
472 item
= RebaseTreeWidgetItem(
480 comment_char
=comment_char
,
482 self
.invisibleRootItem().addChild(item
)
484 def decorate(self
, items
):
489 self
.resizeColumnToContents(0)
490 self
.resizeColumnToContents(1)
491 self
.resizeColumnToContents(2)
492 self
.resizeColumnToContents(3)
493 self
.resizeColumnToContents(4)
494 self
.resizeColumnToContents(5)
497 def item_changed(self
, item
, column
):
498 if column
== item
.ENABLED_COLUMN
:
502 invalid_first_choice
= {FIXUP
, SQUASH
}
503 for item
in self
.items():
504 if item
.is_enabled() and item
.is_commit():
505 if item
.command
in invalid_first_choice
:
506 item
.reset_command(PICK
)
509 def set_selected_to(self
, command
):
510 for i
in self
.selected_items():
511 i
.reset_command(command
)
514 def set_command(self
, item
, command
):
515 item
.reset_command(command
)
519 item
= self
.selected_item()
522 clipboard
= item
.oid
or item
.cmdexec
523 qtutils
.set_clipboard(clipboard
)
525 def selection_changed(self
):
526 item
= self
.selected_item()
527 if item
is None or not item
.is_commit():
529 context
= self
.context
531 params
= dag
.DAG(oid
, 2)
532 repo
= dag
.RepoReader(context
, params
)
534 for commit
in repo
.get():
535 commits
.append(commit
)
537 commits
= commits
[-1:]
538 self
.commits_selected
.emit(commits
)
540 def toggle_enabled(self
):
541 items
= self
.selected_items()
542 logic_or
= reduce(lambda res
, item
: res
or item
.is_enabled(), items
, False)
544 item
.set_enabled(not logic_or
)
546 def select_first(self
):
550 idx
= self
.model().index(0, 0)
552 self
.setCurrentIndex(idx
)
554 def shift_down(self
):
555 sel_items
= self
.selected_items()
556 all_items
= self
.items()
557 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
562 idx
> len(all_items
) - len(sel_items
)
563 or all_items
[sel_idx
[-1]] is all_items
[-1]
565 self
.move_rows
.emit(sel_idx
, idx
)
568 sel_items
= self
.selected_items()
569 all_items
= self
.items()
570 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
575 self
.move_rows
.emit(sel_idx
, idx
)
577 def toggle_remark(self
, remark
):
578 items
= self
.selected_items()
579 self
.toggle_remark_of_items(remark
, items
)
581 def toggle_remark_of_items(self
, remark
, items
):
582 logic_or
= reduce(lambda res
, item
: res
or remark
in item
.remarks
, items
, False)
585 item
.remove_remark(remark
)
588 item
.add_remark(remark
)
590 def move(self
, src_idxs
, dst_idx
):
592 src_base
= sorted(src_idxs
)[0]
593 for idx
in reversed(sorted(src_idxs
)):
594 item
= self
.invisibleRootItem().takeChild(idx
)
595 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
597 for item
in moved_items
:
598 self
.invisibleRootItem().insertChild(item
[0], item
[1])
599 self
.setCurrentItem(item
[1])
602 moved_items
= [item
[1] for item
in moved_items
]
603 # If we've moved to the top then we need to re-decorate all items.
604 # Otherwise, we can decorate just the new items.
606 self
.decorate(self
.items())
608 self
.decorate(moved_items
)
610 for item
in moved_items
:
611 item
.setSelected(True)
616 def dropEvent(self
, event
):
617 super().dropEvent(event
)
620 def contextMenuEvent(self
, event
):
621 items
= self
.selected_items()
622 menu
= qtutils
.create_menu(N_('Actions'), self
)
623 menu
.addAction(self
.action_pick
)
624 menu
.addAction(self
.action_reword
)
625 menu
.addAction(self
.action_edit
)
626 menu
.addAction(self
.action_fixup
)
627 menu
.addAction(self
.action_squash
)
629 menu
.addAction(self
.toggle_enabled_action
)
631 menu
.addAction(self
.copy_oid_action
)
632 self
.copy_oid_action
.setDisabled(len(items
) > 1)
633 menu
.addAction(self
.external_diff_action
)
634 self
.external_diff_action
.setDisabled(len(items
) > 1)
636 menu_toggle_remark
= menu
.addMenu(N_('Toggle remark'))
637 tuple(map(menu_toggle_remark
.addAction
, self
.toggle_remark_actions
))
638 menu
.exec_(self
.mapToGlobal(event
.pos()))
641 class ComboBox(QtWidgets
.QComboBox
):
645 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
663 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
665 self
.command
= command
668 self
.summary
= summary
669 self
.cmdexec
= cmdexec
671 self
.comment_char
= comment_char
673 # if core.abbrev is set to a higher value then we will notice by
674 # simply tracking the longest oid we've seen
675 oid_len
= self
.__class
__.OID_LENGTH
676 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
678 self
.setText(0, '%02d' % idx
)
679 self
.set_enabled(enabled
)
684 self
.setText(5, cmdexec
)
685 elif self
.is_update_ref():
687 self
.setText(5, branch
)
690 self
.setText(5, summary
)
692 self
.set_remarks(remarks
)
694 flags
= self
.flags() | Qt
.ItemIsUserCheckable
695 flags
= flags | Qt
.ItemIsDragEnabled
696 flags
= flags
& ~Qt
.ItemIsDropEnabled
699 def __eq__(self
, other
):
706 return self
.__class
__(
711 summary
=self
.summary
,
712 cmdexec
=self
.cmdexec
,
714 comment_char
=self
.comment_char
,
715 remarks
=self
.remarks
,
718 def decorate(self
, parent
):
722 elif self
.is_update_ref():
727 idx
= COMMAND_IDX
[self
.command
]
728 combo
= self
.combo
= ComboBox()
729 combo
.setEditable(False)
730 combo
.addItems(items
)
731 combo
.setCurrentIndex(idx
)
732 combo
.setEnabled(self
.is_commit())
734 signal
= combo
.currentIndexChanged
735 # pylint: disable=no-member
736 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
737 combo
.validate
.connect(parent
.validate
)
739 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
742 return self
.command
== EXEC
744 def is_update_ref(self
):
745 return self
.command
== UPDATE_REF
749 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
753 """Return the serialized representation of an item"""
754 if self
.is_enabled():
757 comment
= self
.comment_char
+ ' '
759 return f
'{comment}{self.command} {self.cmdexec}'
760 if self
.is_update_ref():
761 return f
'{comment}{self.command} {self.branch}'
762 return f
'{comment}{self.command} {self.oid} {self.summary}'
764 def is_enabled(self
):
765 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
767 def set_enabled(self
, enabled
):
768 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
770 def toggle_enabled(self
):
771 self
.set_enabled(not self
.is_enabled())
773 def add_remark(self
, remark
):
774 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
776 def remove_remark(self
, remark
):
777 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
779 def set_remarks(self
, remarks
):
780 self
.remarks
= remarks
781 self
.setText(4, ''.join(remarks
))
783 def set_command(self
, command
):
784 """Set the item to a different command, no-op for exec items"""
787 self
.command
= command
790 """Update the view to match the updated state"""
792 command
= self
.command
793 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
795 def reset_command(self
, command
):
796 """Set and refresh the item in one shot"""
797 self
.set_command(command
)
800 def set_command_and_validate(self
, combo
):
801 command
= COMMANDS
[combo
.currentIndex()]
802 self
.set_command(command
)
803 self
.combo
.validate
.emit()
806 def show_help(context
):
812 reword = use commit, but edit the commit message
813 edit = use commit, but stop for amending
814 squash = use commit, but meld into previous commit
815 fixup = like "squash", but discard this commit's log message
816 exec = run command (the rest of the line) using shell
817 update-ref = update branches that point to commits
819 These lines can be re-ordered; they are executed from top to bottom.
821 If you disable a line here THAT COMMIT WILL BE LOST.
823 However, if you disable everything, the rebase will be aborted.
838 spacebar = toggle enabled
840 ctrl+enter = accept changes and rebase
841 ctrl+q = cancel and abort the rebase
842 ctrl+d = launch difftool
845 title
= N_('Help - git-cola-sequence-editor')
846 return text
.text_dialog(context
, help_text
, title
)