3 from qtpy
.QtCore
import Qt
4 from qtpy
.QtCore
import Signal
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
9 from ..models
.browse
import GitRepoModel
10 from ..models
.browse
import GitRepoNameItem
11 from ..models
.selection
import State
13 from ..interaction
import Interaction
16 from .. import difftool
17 from .. import gitcmds
18 from .. import hotkeys
21 from .. import qtutils
22 from .selectcommits
import select_commits
25 from . import standard
28 def worktree_browser(context
, parent
=None, update
=True, show
=True):
29 """Create a new worktree browser"""
30 view
= Browser(context
, parent
, update
=update
)
32 context
.browser_windows
.append(view
)
33 view
.closed
.connect(context
.browser_windows
.remove
)
34 model
= GitRepoModel(context
, view
.tree
)
43 def save_path(context
, path
, model
):
44 """Choose an output filename based on the selected path"""
45 filename
= qtutils
.save_as(path
)
47 model
.filename
= filename
48 cmds
.do(SaveBlob
, context
, model
)
55 class Browser(standard
.Widget
):
56 """A repository branch file browser. Browses files provided by GitRepoModel"""
58 # Read-only mode property
59 mode
= property(lambda self
: self
.model
.mode
)
61 def __init__(self
, context
, parent
, update
=True):
62 standard
.Widget
.__init
__(self
, parent
)
63 self
.tree
= RepoTreeView(context
, self
)
64 self
.mainlayout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.tree
)
65 self
.setLayout(self
.mainlayout
)
67 self
.model
= context
.model
68 self
.model
.updated
.connect(self
._updated
_callback
, type=Qt
.QueuedConnection
)
70 qtutils
.add_close_action(self
)
72 self
._updated
_callback
()
74 self
.init_state(context
.settings
, self
.resize
, 720, 420)
76 def set_model(self
, model
):
78 self
.tree
.set_model(model
)
81 """Refresh the model triggering view updates"""
84 def _updated_callback(self
):
85 branch
= self
.model
.currentbranch
86 curdir
= core
.getcwd()
87 msg
= N_('Repository: %s') % curdir
89 msg
+= N_('Branch: %s') % branch
93 'project': self
.model
.project
,
96 title
= N_('%(project)s: %(branch)s - Browse') % scope
97 if self
.mode
== self
.model
.mode_amend
:
98 title
+= ' %s' % N_('(Amending)')
99 self
.setWindowTitle(title
)
102 class RepoTreeView(standard
.TreeView
):
103 """Provides a filesystem-like view of a git repository."""
105 def __init__(self
, context
, parent
):
106 standard
.TreeView
.__init
__(self
, parent
)
108 self
.context
= context
109 self
.selection
= context
.selection
110 self
.saved_selection
= []
111 self
.saved_current_path
= None
112 self
.saved_open_folders
= set()
113 self
.restoring_selection
= False
114 self
._columns
_sized
= False
116 self
.setDragEnabled(True)
117 self
.setRootIsDecorated(False)
118 self
.setSortingEnabled(False)
119 self
.setSelectionMode(self
.ExtendedSelection
)
121 # Observe model updates
122 model
= context
.model
123 model
.about_to_update
.connect(self
.save_selection
, type=Qt
.QueuedConnection
)
124 model
.updated
.connect(self
.update_actions
, type=Qt
.QueuedConnection
)
125 self
.expanded
.connect(self
.index_expanded
)
127 self
.collapsed
.connect(lambda idx
: self
.size_columns())
128 self
.collapsed
.connect(self
.index_collapsed
)
130 # Sync selection before the key press event changes the model index
131 queued
= Qt
.QueuedConnection
132 self
.index_about_to_change
.connect(self
.sync_selection
, type=queued
)
134 self
.action_history
= qtutils
.add_action_with_tooltip(
136 N_('View History...'),
137 N_('View history for selected paths'),
142 self
.action_stage
= qtutils
.add_action_with_tooltip(
144 cmds
.StageOrUnstage
.name(),
145 N_('Stage/unstage selected paths for commit'),
146 cmds
.run(cmds
.StageOrUnstage
, context
),
147 hotkeys
.STAGE_SELECTION
,
150 self
.action_untrack
= qtutils
.add_action_with_tooltip(
152 N_('Untrack Selected'),
153 N_('Stop tracking paths'),
154 self
.untrack_selected
,
157 self
.action_rename
= qtutils
.add_action_with_tooltip(
158 self
, N_('Rename'), N_('Rename selected paths'), self
.rename_selected
161 self
.action_difftool
= qtutils
.add_action_with_tooltip(
163 difftool
.LaunchDifftool
.name(),
164 N_('Launch git-difftool on the current path'),
165 cmds
.run(difftool
.LaunchDifftool
, context
),
169 self
.action_difftool_predecessor
= qtutils
.add_action_with_tooltip(
171 N_('Diff Against Predecessor...'),
172 N_('Launch git-difftool against previous versions'),
173 self
.diff_predecessor
,
174 hotkeys
.DIFF_SECONDARY
,
177 self
.action_revert_unstaged
= qtutils
.add_action_with_tooltip(
179 cmds
.RevertUnstagedEdits
.name(),
180 N_('Revert unstaged changes to selected paths'),
181 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
186 self
.action_revert_uncommitted
= qtutils
.add_action_with_tooltip(
188 cmds
.RevertUncommittedEdits
.name(),
189 N_('Revert uncommitted changes to selected paths'),
190 cmds
.run(cmds
.RevertUncommittedEdits
, context
),
194 self
.action_editor
= qtutils
.add_action_with_tooltip(
196 cmds
.LaunchEditor
.name(),
197 N_('Edit selected paths'),
198 cmds
.run(cmds
.LaunchEditor
, context
),
202 self
.action_blame
= qtutils
.add_action_with_tooltip(
204 cmds
.BlamePaths
.name(),
205 N_('Blame selected paths'),
206 cmds
.run(cmds
.BlamePaths
, context
),
209 self
.action_refresh
= common
.refresh_action(context
, self
)
211 self
.action_default_app
= common
.default_app_action(
212 context
, self
, self
.selected_paths
215 self
.action_parent_dir
= common
.parent_dir_action(
216 context
, self
, self
.selected_paths
219 self
.action_terminal
= common
.terminal_action(
220 context
, self
, func
=self
.selected_paths
223 self
.x_width
= qtutils
.text_width(self
.font(), 'x')
224 self
.size_columns(force
=True)
226 def index_expanded(self
, index
):
227 """Update information about a directory as it is expanded."""
228 # Remember open folders so that we can restore them when refreshing
229 item
= self
.name_item_from_index(index
)
230 self
.saved_open_folders
.add(item
.path
)
233 # update information about a directory as it is expanded
240 model
.update_entry(path
)
242 for row
in range(item
.rowCount()):
243 path
= item
.child(row
, 0).path
244 model
.update_entry(path
)
248 def index_collapsed(self
, index
):
249 item
= self
.name_item_from_index(index
)
250 self
.saved_open_folders
.remove(item
.path
)
253 self
.model().refresh()
255 def size_columns(self
, force
=False):
256 """Set the column widths."""
257 cfg
= self
.context
.cfg
258 should_resize
= cfg
.get('cola.resizebrowsercolumns', default
=False)
259 if not force
and not should_resize
:
261 self
.resizeColumnToContents(0)
262 self
.resizeColumnToContents(1)
263 self
.resizeColumnToContents(2)
264 self
.resizeColumnToContents(3)
265 self
.resizeColumnToContents(4)
267 def sizeHintForColumn(self
, column
):
268 x_width
= self
.x_width
283 # Filename and others use the actual content
284 size
= super().sizeHintForColumn(column
)
287 def save_selection(self
):
288 selection
= self
.selected_paths()
290 self
.saved_selection
= selection
292 current
= self
.current_item()
294 self
.saved_current_path
= current
.path
297 selection
= self
.selectionModel()
298 flags
= selection
.Select | selection
.Rows
300 self
.restoring_selection
= True
302 # Restore opened folders
304 for path
in sorted(self
.saved_open_folders
):
305 row
= model
.get(path
)
308 index
= row
[0].index()
310 self
.setExpanded(index
, True)
312 # Restore the current item. We do this first, otherwise
313 # setCurrentIndex() can mess with the selection we set below
315 current_path
= self
.saved_current_path
317 row
= model
.get(current_path
)
319 current_index
= row
[0].index()
321 if current_index
and current_index
.isValid():
322 self
.setCurrentIndex(current_index
)
324 # Restore selected items
325 for path
in self
.saved_selection
:
326 row
= model
.get(path
)
329 index
= row
[0].index()
332 selection
.select(index
, flags
)
334 self
.restoring_selection
= False
336 # Resize the columns once when cola.resizebrowsercolumns is False.
337 # This provides a good initial size since we will not be resizing
338 # the columns during expand/collapse.
339 if not self
._columns
_sized
:
340 self
._columns
_sized
= True
341 self
.size_columns(force
=True)
345 def update_actions(self
):
346 """Enable/disable actions."""
347 selection
= self
.selected_paths()
348 selected
= bool(selection
)
349 staged
= bool(self
.selected_staged_paths(selection
=selection
))
350 modified
= bool(self
.selected_modified_paths(selection
=selection
))
351 unstaged
= bool(self
.selected_unstaged_paths(selection
=selection
))
352 tracked
= bool(self
.selected_tracked_paths(selection
=selection
))
353 revertable
= staged
or modified
355 self
.action_editor
.setEnabled(selected
)
356 self
.action_history
.setEnabled(selected
)
357 self
.action_default_app
.setEnabled(selected
)
358 self
.action_parent_dir
.setEnabled(selected
)
360 if self
.action_terminal
is not None:
361 self
.action_terminal
.setEnabled(selected
)
363 self
.action_stage
.setEnabled(staged
or unstaged
)
364 self
.action_untrack
.setEnabled(tracked
)
365 self
.action_rename
.setEnabled(tracked
)
366 self
.action_difftool
.setEnabled(staged
or modified
)
367 self
.action_difftool_predecessor
.setEnabled(tracked
)
368 self
.action_revert_unstaged
.setEnabled(revertable
)
369 self
.action_revert_uncommitted
.setEnabled(revertable
)
371 def contextMenuEvent(self
, event
):
372 """Create a context menu."""
373 self
.update_actions()
374 menu
= qtutils
.create_menu(N_('Actions'), self
)
375 menu
.addAction(self
.action_editor
)
376 menu
.addAction(self
.action_stage
)
378 menu
.addAction(self
.action_history
)
379 menu
.addAction(self
.action_difftool
)
380 menu
.addAction(self
.action_difftool_predecessor
)
381 menu
.addAction(self
.action_blame
)
383 menu
.addAction(self
.action_revert_unstaged
)
384 menu
.addAction(self
.action_revert_uncommitted
)
385 menu
.addAction(self
.action_untrack
)
386 menu
.addAction(self
.action_rename
)
388 menu
.addAction(self
.action_default_app
)
389 menu
.addAction(self
.action_parent_dir
)
391 if self
.action_terminal
is not None:
392 menu
.addAction(self
.action_terminal
)
393 menu
.exec_(self
.mapToGlobal(event
.pos()))
395 def mousePressEvent(self
, event
):
396 """Synchronize the selection on mouse-press."""
397 result
= QtWidgets
.QTreeView
.mousePressEvent(self
, event
)
398 self
.sync_selection()
401 def sync_selection(self
):
402 """Push selection into the selection model."""
407 state
= State(staged
, unmerged
, modified
, untracked
)
409 paths
= self
.selected_paths()
410 model
= self
.context
.model
411 model_staged
= utils
.add_parents(model
.staged
)
412 model_modified
= utils
.add_parents(model
.modified
)
413 model_unmerged
= utils
.add_parents(model
.unmerged
)
414 model_untracked
= utils
.add_parents(model
.untracked
)
417 if path
in model_unmerged
:
418 unmerged
.append(path
)
419 elif path
in model_untracked
:
420 untracked
.append(path
)
421 elif path
in model_staged
:
423 elif path
in model_modified
:
424 modified
.append(path
)
427 # Push the new selection into the model.
428 self
.selection
.set_selection(state
)
431 def selectionChanged(self
, old
, new
):
432 """Override selectionChanged to update available actions."""
433 result
= QtWidgets
.QTreeView
.selectionChanged(self
, old
, new
)
434 if not self
.restoring_selection
:
435 self
.update_actions()
439 def update_diff(self
):
440 context
= self
.context
441 model
= context
.model
442 paths
= self
.sync_selection()
443 if paths
and self
.model().path_is_interesting(paths
[0]):
444 cached
= paths
[0] in model
.staged
445 cmds
.do(cmds
.Diff
, context
, paths
[0], cached
)
447 def set_model(self
, model
):
448 """Set the concrete QAbstractItemModel instance."""
450 model
.restore
.connect(self
.restore
, type=Qt
.QueuedConnection
)
452 def name_item_from_index(self
, model_index
):
453 """Return the name item corresponding to the model index."""
454 index
= model_index
.sibling(model_index
.row(), 0)
455 return self
.model().itemFromIndex(index
)
457 def paths_from_indexes(self
, indexes
):
458 return qtutils
.paths_from_indexes(
459 self
.model(), indexes
, item_type
=GitRepoNameItem
.TYPE
462 def selected_paths(self
):
463 """Return the selected paths."""
464 return self
.paths_from_indexes(self
.selectedIndexes())
466 def selected_staged_paths(self
, selection
=None):
467 """Return selected staged paths."""
468 if selection
is None:
469 selection
= self
.selected_paths()
470 model
= self
.context
.model
471 staged
= utils
.add_parents(model
.staged
)
472 return [p
for p
in selection
if p
in staged
]
474 def selected_modified_paths(self
, selection
=None):
475 """Return selected modified paths."""
476 if selection
is None:
477 selection
= self
.selected_paths()
478 model
= self
.context
.model
479 modified
= utils
.add_parents(model
.modified
)
480 return [p
for p
in selection
if p
in modified
]
482 def selected_unstaged_paths(self
, selection
=None):
483 """Return selected unstaged paths."""
484 if selection
is None:
485 selection
= self
.selected_paths()
486 model
= self
.context
.model
487 modified
= utils
.add_parents(model
.modified
)
488 untracked
= utils
.add_parents(model
.untracked
)
489 unstaged
= modified
.union(untracked
)
490 return [p
for p
in selection
if p
in unstaged
]
492 def selected_tracked_paths(self
, selection
=None):
493 """Return selected tracked paths."""
494 if selection
is None:
495 selection
= self
.selected_paths()
496 model
= self
.context
.model
497 staged
= set(self
.selected_staged_paths(selection
=selection
))
498 modified
= set(self
.selected_modified_paths(selection
=selection
))
499 untracked
= utils
.add_parents(model
.untracked
)
500 tracked
= staged
.union(modified
)
501 return [p
for p
in selection
if p
not in untracked
or p
in tracked
]
503 def view_history(self
):
504 """Launch the configured history browser path-limited to entries."""
505 paths
= self
.selected_paths()
506 cmds
.do(cmds
.VisualizePaths
, self
.context
, paths
)
508 def untrack_selected(self
):
509 """Untrack selected paths."""
510 context
= self
.context
511 cmds
.do(cmds
.Untrack
, context
, self
.selected_tracked_paths())
513 def rename_selected(self
):
514 """Untrack selected paths."""
515 context
= self
.context
516 cmds
.do(cmds
.Rename
, context
, self
.selected_tracked_paths())
518 def diff_predecessor(self
):
519 """Diff paths against previous versions."""
520 context
= self
.context
521 paths
= self
.selected_tracked_paths()
522 args
= ['--'] + paths
523 revs
, summaries
= gitcmds
.log_helper(context
, all
=False, extra_args
=args
)
524 commits
= select_commits(
525 context
, N_('Select Previous Version'), revs
, summaries
, multiselect
=False
530 difftool
.difftool_launch(context
, left
=commit
, paths
=paths
)
532 def current_path(self
):
533 """Return the path for the current item."""
534 index
= self
.currentIndex()
535 if not index
.isValid():
537 return self
.name_item_from_index(index
).path
541 """Context data used for browsing branches via git-ls-tree"""
543 def __init__(self
, ref
, filename
=None):
545 self
.relpath
= filename
546 self
.filename
= filename
549 class SaveBlob(cmds
.ContextCommand
):
550 def __init__(self
, context
, model
):
551 super().__init
__(context
)
552 self
.browse_model
= model
555 git
= self
.context
.git
556 model
= self
.browse_model
557 ref
= f
'{model.ref}:{model.relpath}'
558 with core
.xopen(model
.filename
, 'wb') as fp
:
559 status
, output
, err
= git
.show(ref
, _stdout
=fp
)
561 out
= '# git show {} >{}\n{}'.format(
563 shlex
.quote(model
.filename
),
566 Interaction
.command(N_('Error Saving File'), 'git show', status
, out
, err
)
570 msg
= N_('Saved "%(filename)s" from "%(ref)s" to "%(destination)s"') % {
571 'filename': model
.relpath
,
573 'destination': model
.filename
,
575 Interaction
.log_status(status
, msg
, '')
577 Interaction
.information(
578 N_('File Saved'), N_('File saved to "%s"') % model
.filename
582 class BrowseBranch(standard
.Dialog
):
584 def browse(cls
, context
, ref
):
585 model
= BrowseModel(ref
)
586 dlg
= cls(context
, model
, parent
=qtutils
.active_window())
587 dlg_model
= GitTreeModel(context
, ref
, dlg
)
588 dlg
.setModel(dlg_model
)
589 dlg
.setWindowTitle(N_('Browsing %s') % model
.ref
)
592 if dlg
.exec_() != dlg
.Accepted
:
596 def __init__(self
, context
, model
, parent
=None):
597 standard
.Dialog
.__init
__(self
, parent
=parent
)
598 if parent
is not None:
599 self
.setWindowModality(Qt
.WindowModal
)
601 # updated for use by commands
602 self
.context
= context
606 self
.tree
= GitTreeWidget(parent
=self
)
607 self
.close_button
= qtutils
.close_button()
610 self
.save
= qtutils
.create_button(text
=text
, enabled
=False, default
=True)
613 self
.btnlayt
= qtutils
.hbox(
614 defs
.margin
, defs
.spacing
, self
.close_button
, qtutils
.STRETCH
, self
.save
617 self
.layt
= qtutils
.vbox(defs
.margin
, defs
.spacing
, self
.tree
, self
.btnlayt
)
618 self
.setLayout(self
.layt
)
621 self
.tree
.path_chosen
.connect(self
.save_path
)
623 self
.tree
.selection_changed
.connect(
624 self
.selection_changed
, type=Qt
.QueuedConnection
627 qtutils
.connect_button(self
.close_button
, self
.close
)
628 qtutils
.connect_button(self
.save
, self
.save_blob
)
629 self
.init_size(parent
=parent
)
632 self
.tree
.expandAll()
634 def setModel(self
, model
):
635 self
.tree
.setModel(model
)
637 def path_chosen(self
, path
, close
=True):
638 """Update the model from the view"""
641 model
.filename
= path
645 def save_path(self
, path
):
646 """Choose an output filename based on the selected path"""
647 self
.path_chosen(path
, close
=False)
648 if save_path(self
.context
, path
, self
.model
):
652 """Save the currently selected file"""
653 filenames
= self
.tree
.selected_files()
656 self
.save_path(filenames
[0])
658 def selection_changed(self
):
659 """Update actions based on the current selection"""
660 filenames
= self
.tree
.selected_files()
661 self
.save
.setEnabled(bool(filenames
))
664 class GitTreeWidget(standard
.TreeView
):
665 selection_changed
= Signal()
666 path_chosen
= Signal(object)
668 def __init__(self
, parent
=None):
669 standard
.TreeView
.__init
__(self
, parent
)
670 self
.setHeaderHidden(True)
671 self
.doubleClicked
.connect(self
.double_clicked
)
673 def double_clicked(self
, index
):
674 item
= self
.model().itemFromIndex(index
)
679 self
.path_chosen
.emit(item
.path
)
681 def selected_files(self
):
682 items
= self
.selected_items()
683 return [i
.path
for i
in items
if not i
.is_dir
]
685 def selectionChanged(self
, old_selection
, new_selection
):
686 QtWidgets
.QTreeView
.selectionChanged(self
, old_selection
, new_selection
)
687 self
.selection_changed
.emit()
689 def select_first_file(self
):
690 """Select the first filename in the tree"""
692 idx
= self
.indexAt(QtCore
.QPoint(0, 0))
693 item
= model
.itemFromIndex(idx
)
694 while idx
and idx
.isValid() and item
and item
.is_dir
:
695 idx
= self
.indexBelow(idx
)
696 item
= model
.itemFromIndex(idx
)
698 if idx
and idx
.isValid() and item
:
699 self
.setCurrentIndex(idx
)
702 class GitFileTreeModel(QtGui
.QStandardItemModel
):
703 """Presents a list of file paths as a hierarchical tree."""
705 def __init__(self
, parent
):
706 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
707 self
.dir_entries
= {'': self
.invisibleRootItem()}
711 QtGui
.QStandardItemModel
.clear(self
)
713 self
.dir_entries
= {'': self
.invisibleRootItem()}
715 def add_files(self
, files
):
716 """Add a list of files"""
717 add_file
= self
.add_file
721 def add_file(self
, path
):
722 """Add a file to the model."""
723 dirname
= utils
.dirname(path
)
724 dir_entries
= self
.dir_entries
726 parent
= dir_entries
[dirname
]
728 parent
= dir_entries
[dirname
] = self
.create_dir_entry(dirname
)
730 row_items
= create_row(path
, False)
731 parent
.appendRow(row_items
)
733 def add_directory(self
, parent
, path
):
734 """Add a directory entry to the model."""
736 row_items
= create_row(path
, True)
739 parent_path
= parent
.path
740 except AttributeError: # root QStandardItem
743 # Insert directories before file paths
745 row
= self
.dir_rows
[parent_path
]
747 row
= self
.dir_rows
[parent_path
] = 0
749 parent
.insertRow(row
, row_items
)
750 self
.dir_rows
[parent_path
] += 1
751 self
.dir_entries
[path
] = row_items
[0]
755 def create_dir_entry(self
, dirname
):
757 Create a directory entry for the model.
759 This ensures that directories are always listed before files.
762 entries
= dirname
.split('/')
764 parent
= self
.invisibleRootItem()
765 curdir_append
= curdir
.append
766 self_add_directory
= self
.add_directory
767 dir_entries
= self
.dir_entries
768 for entry
in entries
:
770 path
= '/'.join(curdir
)
772 parent
= dir_entries
[path
]
775 parent
= self_add_directory(grandparent
, path
)
776 dir_entries
[path
] = parent
780 def create_row(path
, is_dir
):
781 """Return a list of items representing a row."""
782 return [GitTreeItem(path
, is_dir
)]
785 class GitTreeModel(GitFileTreeModel
):
786 def __init__(self
, context
, ref
, parent
):
787 GitFileTreeModel
.__init
__(self
, parent
)
788 self
.context
= context
792 def _initialize(self
):
793 """Iterate over git-ls-tree and create GitTreeItems."""
794 git
= self
.context
.git
795 status
, out
, err
= git
.ls_tree('--full-tree', '-r', '-t', '-z', self
.ref
)
797 Interaction
.log_status(status
, out
, err
)
803 for line
in out
[:-1].split('\0'):
804 # .....6 ...4 ......................................40
805 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
806 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
808 relpath
= line
[6 + 1 + 4 + 1 + 40 + 1 :]
810 parent
= self
.dir_entries
[utils
.dirname(relpath
)]
811 self
.add_directory(parent
, relpath
)
813 self
.add_file(relpath
)
816 class GitTreeItem(QtGui
.QStandardItem
):
818 Represents a cell in a tree view.
820 Many GitRepoItems could map to a single repository path,
821 but this tree only has a single column.
822 Each GitRepoItem manages a different cell in the tree view.
826 def __init__(self
, path
, is_dir
):
827 QtGui
.QStandardItem
.__init
__(self
)
830 self
.setEditable(False)
831 self
.setDragEnabled(False)
832 self
.setText(utils
.basename(path
))
834 icon
= icons
.directory()
836 icon
= icons
.file_text()