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
9 from qtpy
import QtWidgets
10 from qtpy
.QtCore
import Qt
11 from qtpy
.QtCore
import Signal
13 # pylint: disable=ungrouped-imports
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
35 UPDATE_REF
= 'update-ref'
44 COMMAND_IDX
= {cmd_
: idx_
for idx_
, cmd_
in enumerate(COMMANDS
)}
57 """Start a git-cola-sequence-editor session"""
59 context
= app
.application_init(args
)
60 view
= new_window(context
, args
.filename
)
61 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
65 def stop(_context
, _view
):
66 """All done, cleanup"""
67 QtCore
.QThreadPool
.globalInstance().waitForDone()
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
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
)
123 def set_editor(self
, editor
):
125 self
.setCentralWidget(editor
)
126 editor
.exit
.connect(self
.exit
)
129 def start(self
, _context
, _view
):
132 def exit(self
, status
):
137 class Editor(QtWidgets
.QWidget
):
140 def __init__(self
, context
, filename
, parent
=None):
141 super().__init
__(parent
)
143 self
.widget_version
= 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'),
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(
174 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
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(
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
)
213 insns
= core
.read(self
.filename
)
214 self
.parse_sequencer_instructions(insns
)
217 def commits_selected(self
, commits
):
218 self
.extdiff_button
.setEnabled(bool(commits
))
221 def parse_sequencer_instructions(self
, insns
):
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(
233 + r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
234 + r
'\s+([0-9a-f]{7,40})'
239 for line
in insns
.splitlines():
240 match
= pick_rgx
.match(line
)
242 enabled
= match
.group(1) is None
243 command
= unabbrev(match
.group(2))
245 summary
= match
.group(4)
246 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
249 match
= exec_rgx
.match(line
)
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
)
257 match
= update_ref_rgx
.match(line
)
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
)
266 self
.tree
.decorate(self
.tree
.items())
268 self
.tree
.select_first()
272 if self
.cancel_action
== 'save':
273 status
= self
.save('')
278 self
.exit
.emit(status
)
281 lines
= [item
.value() for item
in self
.tree
.items()]
282 sequencer_instructions
= '\n'.join(lines
) + '\n'
283 status
= self
.save(sequencer_instructions
)
285 self
.exit
.emit(status
)
287 def save(self
, string
):
288 """Save the instruction sheet"""
290 core
.write(self
.filename
, string
)
292 except (OSError, ValueError) as exc
:
293 msg
, details
= utils
.format_exception(exc
)
294 sys
.stderr
.write(msg
+ '\n\n' + details
)
298 def external_diff(self
):
299 items
= self
.tree
.selected_items()
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
317 self
.setHeaderLabels([
324 self
.header().setStretchLastSection(True)
325 self
.setColumnCount(5)
326 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
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(
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(
359 lambda: self
.set_selected_to(FIXUP
),
360 *hotkeys
.REBASE_FIXUP
,
363 self
.action_squash
= qtutils
.add_action(
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
)
385 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
387 comment_char
= self
.comment_char
388 item
= RebaseTreeWidgetItem(
396 comment_char
=comment_char
,
398 self
.invisibleRootItem().addChild(item
)
400 def decorate(self
, items
):
405 self
.resizeColumnToContents(0)
406 self
.resizeColumnToContents(1)
407 self
.resizeColumnToContents(2)
408 self
.resizeColumnToContents(3)
409 self
.resizeColumnToContents(4)
412 def item_changed(self
, item
, column
):
413 if column
== item
.ENABLED_COLUMN
:
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
)
424 def set_selected_to(self
, command
):
425 for i
in self
.selected_items():
426 i
.reset_command(command
)
429 def set_command(self
, item
, command
):
430 item
.reset_command(command
)
434 item
= self
.selected_item()
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():
444 context
= self
.context
446 params
= dag
.DAG(oid
, 2)
447 repo
= dag
.RepoReader(context
, params
)
449 for commit
in repo
.get():
450 commits
.append(commit
)
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)
459 item
.set_enabled(not logic_or
)
461 def select_first(self
):
465 idx
= self
.model().index(0, 0)
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
])
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
)
483 sel_items
= self
.selected_items()
484 all_items
= self
.items()
485 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
490 self
.move_rows
.emit(sel_idx
, idx
)
492 def move(self
, src_idxs
, dst_idx
):
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])
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.
508 self
.decorate(self
.items())
510 self
.decorate(moved_items
)
512 for item
in moved_items
:
513 item
.setSelected(True)
518 def dropEvent(self
, event
):
519 super().dropEvent(event
)
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
)
531 menu
.addAction(self
.toggle_enabled_action
)
533 menu
.addAction(self
.copy_oid_action
)
535 self
.copy_oid_action
.setDisabled(True)
536 menu
.addAction(self
.external_diff_action
)
538 self
.external_diff_action
.setDisabled(True)
539 menu
.exec_(self
.mapToGlobal(event
.pos()))
542 class ComboBox(QtWidgets
.QComboBox
):
546 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
563 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
565 self
.command
= command
568 self
.summary
= summary
569 self
.cmdexec
= cmdexec
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
)
584 self
.setText(4, cmdexec
)
585 elif self
.is_update_ref():
587 self
.setText(4, branch
)
590 self
.setText(4, summary
)
592 flags
= self
.flags() | Qt
.ItemIsUserCheckable
593 flags
= flags | Qt
.ItemIsDragEnabled
594 flags
= flags
& ~Qt
.ItemIsDropEnabled
597 def __eq__(self
, other
):
604 return self
.__class
__(
609 summary
=self
.summary
,
610 cmdexec
=self
.cmdexec
,
612 comment_char
=self
.comment_char
,
615 def decorate(self
, parent
):
619 elif self
.is_update_ref():
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
)
639 return self
.command
== EXEC
641 def is_update_ref(self
):
642 return self
.command
== UPDATE_REF
646 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
650 """Return the serialized representation of an item"""
651 if self
.is_enabled():
654 comment
= self
.comment_char
+ ' '
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"""
674 self
.command
= command
677 """Update the view to match the updated state"""
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
)
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
):
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.
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
)