3 from argparse
import ArgumentParser
4 from functools
import partial
6 from cola
import app
# prints a message if Qt cannot be found
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
13 from cola
import difftool
14 from cola
import gitcmds
15 from cola
import hotkeys
16 from cola
import icons
17 from cola
import qtutils
18 from cola
import utils
19 from cola
.i18n
import N_
20 from cola
.models
import dag
21 from cola
.models
import prefs
22 from cola
.widgets
import defs
23 from cola
.widgets
import filelist
24 from cola
.widgets
import diff
25 from cola
.widgets
import standard
26 from cola
.widgets
import text
34 UPDATE_REF
= 'update-ref'
43 COMMAND_IDX
= {cmd_
: idx_
for idx_
, cmd_
in enumerate(COMMANDS
)}
56 """Start a git-cola-sequence-editor session"""
58 context
= app
.application_init(args
)
59 view
= new_window(context
, args
.filename
)
60 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
64 def stop(context
, _view
):
65 """All done, cleanup"""
67 context
.runtask
.wait()
71 parser
= ArgumentParser()
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
)
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
98 # If the user closes the window without confirmation it's considered cancelled.
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
)
104 self
.show_help_action
= qtutils
.add_action(
105 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
)
123 def set_editor(self
, editor
):
125 self
.setCentralWidget(editor
)
126 editor
.cancel
.connect(self
.close
)
127 editor
.rebase
.connect(self
.rebase
)
130 def start(self
, _context
, _view
):
131 """Start background tasks"""
135 """Stop background tasks"""
139 """Exit the editor and initiate a rebase"""
140 self
.status
= self
.editor
.save()
144 class Editor(QtWidgets
.QWidget
):
148 def __init__(self
, context
, filename
, parent
=None):
149 super().__init
__(parent
)
151 self
.widget_version
= 1
152 self
.context
= context
153 self
.filename
= filename
154 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
156 self
.diff
= diff
.DiffWidget(context
, self
)
157 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
158 self
.filewidget
= filelist
.FileWidget(context
, self
, remarks
=True)
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\nShortcut: Ctrl+Enter'),
168 self
.extdiff_button
= qtutils
.create_button(
169 text
=N_('Launch Diff Tool'),
170 tooltip
=N_('Launch external diff tool\nShortcut: 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(
180 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
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(
199 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
200 self
.setLayout(layout
)
202 self
.action_rebase
= qtutils
.add_action(
210 self
.tree
.commits_selected
.connect(self
.commits_selected
)
211 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
212 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
213 self
.tree
.external_diff
.connect(self
.external_diff
)
215 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
216 self
.filewidget
.remark_toggled
.connect(self
.remark_toggled_for_files
)
218 # `git` calls are expensive. When user toggles a remark of all commits touching
219 # selected paths the GUI freezes for a while on a big enough sequence. This
220 # cache is used (commit ID to paths tuple) to minimize calls to git.
221 self
.oid_to_paths
= {}
222 self
.task
= None # A task fills the cache in the background.
223 self
.running
= False # This flag stops it.
225 qtutils
.connect_button(self
.rebase_button
, self
.rebase
.emit
)
226 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
227 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
228 qtutils
.connect_button(self
.cancel_button
, self
.cancel
.emit
)
231 insns
= core
.read(self
.filename
)
232 self
.parse_sequencer_instructions(insns
)
234 # Assume that the tree is filled at this point.
236 self
.task
= qtutils
.SimpleTask(self
.calculate_oid_to_paths
)
237 self
.context
.runtask
.start(self
.task
)
243 def commits_selected(self
, commits
):
244 self
.extdiff_button
.setEnabled(bool(commits
))
246 def remark_toggled_for_files(self
, remark
, filenames
):
247 filenames
= set(filenames
)
249 items
= self
.tree
.items()
253 if not item
.is_commit():
256 paths
= self
.paths_touched_by_oid(oid
)
257 if filenames
.intersection(paths
):
258 touching_items
.append(item
)
260 self
.tree
.toggle_remark_of_items(remark
, touching_items
)
262 def external_diff(self
):
263 items
= self
.tree
.selected_items()
267 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
271 def paths_touched_by_oid(self
, oid
):
273 return self
.oid_to_paths
[oid
]
277 paths
= gitcmds
.changed_files(self
.context
, oid
)
278 self
.oid_to_paths
[oid
] = paths
282 def calculate_oid_to_paths(self
):
283 """Fills the oid_to_paths cache in the background"""
284 for item
in self
.tree
.items():
287 self
.paths_touched_by_oid(item
.oid
)
289 def parse_sequencer_instructions(self
, insns
):
291 re_comment_char
= re
.escape(self
.comment_char
)
292 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
293 update_ref_rgx
= re
.compile(
294 r
'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
296 # The upper bound of 40 below must match git.OID_LENGTH.
297 # We'll have to update this to the new hash length when that happens.
298 pick_rgx
= re
.compile(
301 + r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
302 + r
'\s+([0-9a-f]{7,40})'
307 for line
in insns
.splitlines():
308 match
= pick_rgx
.match(line
)
310 enabled
= match
.group(1) is None
311 command
= unabbrev(match
.group(2))
313 summary
= match
.group(4)
314 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
317 match
= exec_rgx
.match(line
)
319 enabled
= match
.group(1) is None
320 command
= unabbrev(match
.group(2))
321 cmdexec
= match
.group(3)
322 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
325 match
= update_ref_rgx
.match(line
)
327 enabled
= match
.group(1) is None
328 command
= unabbrev(match
.group(2))
329 branch
= match
.group(3)
330 self
.tree
.add_item(idx
, enabled
, command
, branch
=branch
)
334 self
.tree
.decorate(self
.tree
.items())
336 self
.tree
.select_first()
338 def save(self
, string
=None):
339 """Save the instruction sheet"""
342 lines
= [item
.value() for item
in self
.tree
.items()]
343 # sequencer instructions
344 string
= '\n'.join(lines
) + '\n'
347 core
.write(self
.filename
, string
)
349 except (OSError, ValueError) as exc
:
350 msg
, details
= utils
.format_exception(exc
)
351 sys
.stderr
.write(msg
+ '\n\n' + details
)
356 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
357 commits_selected
= Signal(object)
358 external_diff
= Signal()
359 move_rows
= Signal(object, object)
361 def __init__(self
, context
, comment_char
, parent
):
362 super().__init
__(parent
=parent
)
363 self
.context
= context
364 self
.comment_char
= comment_char
366 self
.setHeaderLabels([
374 self
.header().setStretchLastSection(True)
375 self
.setColumnCount(6)
376 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
379 self
.copy_oid_action
= qtutils
.add_action(
380 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
383 self
.external_diff_action
= qtutils
.add_action(
384 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
387 self
.toggle_enabled_action
= qtutils
.add_action(
388 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
391 self
.action_pick
= qtutils
.add_action(
392 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
395 self
.action_reword
= qtutils
.add_action(
398 lambda: self
.set_selected_to(REWORD
),
399 *hotkeys
.REBASE_REWORD
,
402 self
.action_edit
= qtutils
.add_action(
403 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
406 self
.action_fixup
= qtutils
.add_action(
409 lambda: self
.set_selected_to(FIXUP
),
410 *hotkeys
.REBASE_FIXUP
,
413 self
.action_squash
= qtutils
.add_action(
416 lambda: self
.set_selected_to(SQUASH
),
417 *hotkeys
.REBASE_SQUASH
,
420 self
.action_shift_down
= qtutils
.add_action(
421 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
424 self
.action_shift_up
= qtutils
.add_action(
425 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
428 self
.toggle_remark_actions
= tuple(
432 lambda remark
=r
: self
.toggle_remark(remark
),
433 hotkeys
.hotkey(Qt
.CTRL |
getattr(Qt
, 'Key_' + r
)),
435 for r
in map(str, range(10))
438 self
.itemChanged
.connect(self
.item_changed
)
439 self
.itemSelectionChanged
.connect(self
.selection_changed
)
440 self
.move_rows
.connect(self
.move
)
441 self
.items_moved
.connect(self
.decorate
)
444 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
446 comment_char
= self
.comment_char
447 item
= RebaseTreeWidgetItem(
455 comment_char
=comment_char
,
458 self
.invisibleRootItem().addChild(item
)
460 def decorate(self
, items
):
465 """Resize columns to fit content"""
466 for i
in range(RebaseTreeWidgetItem
.COLUMN_COUNT
- 1):
467 self
.resizeColumnToContents(i
)
469 def item_changed(self
, item
, column
):
470 """Validate item ordering when toggling their enabled state"""
471 if column
== item
.ENABLED_COLUMN
:
475 invalid_first_choice
= {FIXUP
, SQUASH
}
476 for item
in self
.items():
477 if item
.is_enabled() and item
.is_commit():
478 if item
.command
in invalid_first_choice
:
479 item
.reset_command(PICK
)
482 def set_selected_to(self
, command
):
483 for i
in self
.selected_items():
484 i
.reset_command(command
)
487 def set_command(self
, item
, command
):
488 item
.reset_command(command
)
492 item
= self
.selected_item()
495 clipboard
= item
.oid
or item
.cmdexec
496 qtutils
.set_clipboard(clipboard
)
498 def selection_changed(self
):
499 item
= self
.selected_item()
500 if item
is None or not item
.is_commit():
502 context
= self
.context
504 params
= dag
.DAG(oid
, 2)
505 repo
= dag
.RepoReader(context
, params
)
507 for commit
in repo
.get():
508 commits
.append(commit
)
510 commits
= commits
[-1:]
511 self
.commits_selected
.emit(commits
)
513 def toggle_enabled(self
):
514 """Toggle the enabled state of each selected item"""
515 items
= self
.selected_items()
516 enable
= should_enable(items
, lambda item
: item
.is_enabled())
519 needs_update
= not item
.is_enabled()
521 needs_update
= item
.is_enabled()
523 item
.set_enabled(enable
)
525 def select_first(self
):
529 idx
= self
.model().index(0, 0)
531 self
.setCurrentIndex(idx
)
533 def shift_down(self
):
534 sel_items
= self
.selected_items()
535 all_items
= self
.items()
536 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
541 idx
> len(all_items
) - len(sel_items
)
542 or all_items
[sel_idx
[-1]] is all_items
[-1]
544 self
.move_rows
.emit(sel_idx
, idx
)
547 sel_items
= self
.selected_items()
548 all_items
= self
.items()
549 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
554 self
.move_rows
.emit(sel_idx
, idx
)
556 def toggle_remark(self
, remark
):
557 """Toggle remarks for all selected items"""
558 items
= self
.selected_items()
559 self
.toggle_remark_of_items(remark
, items
)
561 def toggle_remark_of_items(self
, remark
, items
):
562 """Toggle remarks for the selected items"""
563 enable
= should_enable(items
, lambda item
: remark
in item
.remarks
)
565 needs_update
= enable ^
(remark
in item
.remarks
)
568 item
.add_remark(remark
)
570 item
.remove_remark(remark
)
572 def move(self
, src_idxs
, dst_idx
):
574 src_base
= sorted(src_idxs
)[0]
575 for idx
in reversed(sorted(src_idxs
)):
576 item
= self
.invisibleRootItem().takeChild(idx
)
577 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
579 for item
in moved_items
:
580 self
.invisibleRootItem().insertChild(item
[0], item
[1])
581 self
.setCurrentItem(item
[1])
584 moved_items
= [item
[1] for item
in moved_items
]
585 # If we've moved to the top then we need to re-decorate all items.
586 # Otherwise, we can decorate just the new items.
588 self
.decorate(self
.items())
590 self
.decorate(moved_items
)
592 for item
in moved_items
:
593 item
.setSelected(True)
598 def dropEvent(self
, event
):
599 super().dropEvent(event
)
602 def contextMenuEvent(self
, event
):
603 items
= self
.selected_items()
604 menu
= qtutils
.create_menu(N_('Actions'), self
)
605 menu
.addAction(self
.action_pick
)
606 menu
.addAction(self
.action_reword
)
607 menu
.addAction(self
.action_edit
)
608 menu
.addAction(self
.action_fixup
)
609 menu
.addAction(self
.action_squash
)
611 menu
.addAction(self
.toggle_enabled_action
)
613 menu
.addAction(self
.copy_oid_action
)
614 self
.copy_oid_action
.setDisabled(len(items
) > 1)
615 menu
.addAction(self
.external_diff_action
)
616 self
.external_diff_action
.setDisabled(len(items
) > 1)
618 menu_toggle_remark
= menu
.addMenu(N_('Toggle Remark'))
619 for action
in self
.toggle_remark_actions
:
620 menu_toggle_remark
.addAction(action
)
621 menu
.exec_(self
.mapToGlobal(event
.pos()))
624 def should_enable(items
, predicate
):
625 """Calculate whether items should be toggled on or off.
627 If all items are enabled then return False.
628 If all items are disabled then return True.
629 If more items are enabled then return True, otherwise return False.
632 enabled
= sum(predicate(item
) for item
in items
)
633 disabled
= len(items
) - enabled
634 enable
= count
> enabled
>= disabled
or disabled
== count
638 class ComboBox(QtWidgets
.QComboBox
):
642 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
643 """A single data row in the rebase tree widget"""
654 '0': ('white', 'darkred'),
655 '1': ('black', 'salmon'),
656 '2': ('black', 'sandybrown'),
657 '3': ('black', 'yellow'),
658 '4': ('black', 'yellowgreen'),
659 '5': ('white', 'forestgreen'),
660 '6': ('white', 'dodgerblue'),
661 '7': ('white', 'royalblue'),
662 '8': ('white', 'slateblue'),
663 '9': ('black', 'rosybrown'),
679 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
681 self
.command
= command
684 self
.summary
= summary
685 self
.cmdexec
= cmdexec
687 self
.comment_char
= comment_char
688 self
.remarks
= remarks
689 self
.remarks_label
= None
690 self
._parent
= parent
692 # if core.abbrev is set to a higher value then we will notice by
693 # simply tracking the longest oid we've seen
694 oid_len
= self
.OID_LENGTH
695 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
697 self
.setText(self
.NUMBER_COLUMN
, '%02d' % idx
)
698 self
.set_enabled(enabled
)
702 self
.setText(self
.COMMIT_COLUMN
, '')
703 self
.setText(self
.SUMMARY_COLUMN
, cmdexec
)
704 elif self
.is_update_ref():
705 self
.setText(self
.COMMIT_COLUMN
, '')
706 self
.setText(self
.SUMMARY_COLUMN
, branch
)
708 self
.setText(self
.COMMIT_COLUMN
, oid
)
709 self
.setText(self
.SUMMARY_COLUMN
, summary
)
711 self
.set_remarks(remarks
)
713 flags
= self
.flags() | Qt
.ItemIsUserCheckable
714 flags
= flags | Qt
.ItemIsDragEnabled
715 flags
= flags
& ~Qt
.ItemIsDropEnabled
718 def __eq__(self
, other
):
725 return self
.__class
__(
730 summary
=self
.summary
,
731 cmdexec
=self
.cmdexec
,
733 comment_char
=self
.comment_char
,
734 remarks
=self
.remarks
,
737 def decorate(self
, parent
):
741 elif self
.is_update_ref():
746 idx
= COMMAND_IDX
[self
.command
]
747 combo
= self
.combo
= ComboBox()
748 combo
.setEditable(False)
749 combo
.addItems(items
)
750 combo
.setCurrentIndex(idx
)
751 combo
.setEnabled(self
.is_commit())
753 signal
= combo
.currentIndexChanged
754 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
755 combo
.validate
.connect(parent
.validate
)
757 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
759 self
.remarks_label
= remarks_label
= QtWidgets
.QLabel()
760 parent
.setItemWidget(self
, self
.REMARKS_COLUMN
, remarks_label
)
761 self
.update_remarks()
764 return self
.command
== EXEC
766 def is_update_ref(self
):
767 return self
.command
== UPDATE_REF
771 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
775 """Return the serialized representation of an item"""
776 if self
.is_enabled():
779 comment
= self
.comment_char
+ ' '
781 return f
'{comment}{self.command} {self.cmdexec}'
782 if self
.is_update_ref():
783 return f
'{comment}{self.command} {self.branch}'
784 return f
'{comment}{self.command} {self.oid} {self.summary}'
786 def is_enabled(self
):
787 """Is the item enabled?"""
788 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
790 def set_enabled(self
, enabled
):
791 """Enable the item by checking its enabled checkbox"""
792 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
794 def toggle_enabled(self
):
795 """Toggle the enabled state of the item"""
796 self
.set_enabled(not self
.is_enabled())
798 def add_remark(self
, remark
):
799 """Add a remark to the item"""
800 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
802 def remove_remark(self
, remark
):
803 """Remove a remark from the item"""
804 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
806 def set_remarks(self
, remarks
):
807 """Set the remarks and update the remark display"""
808 if remarks
== self
.remarks
:
810 self
.remarks
= remarks
811 self
.update_remarks()
812 self
._parent
.resizeColumnToContents(self
.REMARKS_COLUMN
)
814 def update_remarks(self
):
815 """Update the remarks label display to match the current remarks"""
816 label
= self
.remarks_label
820 for remark
in self
.remarks
:
821 fg_color
, bg_color
= self
.COLORS
[remark
]
825 background-color: {bg_color};
826 "> {remark} </span>
828 label
.setText(label_text
)
830 def set_command(self
, command
):
831 """Set the item to a different command, no-op for exec items"""
834 self
.command
= command
837 """Update the view to match the updated state"""
839 command
= self
.command
840 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
842 def reset_command(self
, command
):
843 """Set and refresh the item in one shot"""
844 self
.set_command(command
)
847 def set_command_and_validate(self
, combo
):
848 """Set the command and validate the command order"""
849 command
= COMMANDS
[combo
.currentIndex()]
850 self
.set_command(command
)
851 self
.combo
.validate
.emit()
854 def show_help(context
):
860 reword = use commit, but edit the commit message
861 edit = use commit, but stop for amending
862 squash = use commit, but meld into previous commit
863 fixup = like "squash", but discard this commit's log message
864 exec = run command (the rest of the line) using shell
865 update-ref = update branches that point to commits
867 These lines can be re-ordered; they are executed from top to bottom.
869 If you disable a line here THAT COMMIT WILL BE LOST.
871 However, if you disable everything, the rebase will be aborted.
886 spacebar = toggle enabled
888 ctrl+enter = accept changes and rebase
889 ctrl+q = cancel and abort the rebase
890 ctrl+d = launch difftool
893 title
= N_('Help - git-cola-sequence-editor')
894 return text
.text_dialog(context
, help_text
, title
)