1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
4 from qtpy
.QtCore
import Qt
5 from qtpy
.QtCore
import Signal
6 from qtpy
import QtCore
8 from qtpy
import QtWidgets
10 from ..models
.browse
import GitRepoModel
11 from ..models
.browse
import GitRepoNameItem
12 from ..models
.selection
import State
14 from ..interaction
import Interaction
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 # Read-only mode property
57 mode
= property(lambda self
: self
.model
.mode
)
59 def __init__(self
, context
, parent
, update
=True):
60 standard
.Widget
.__init
__(self
, parent
)
61 self
.tree
= RepoTreeView(context
, self
)
62 self
.mainlayout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.tree
)
63 self
.setLayout(self
.mainlayout
)
65 self
.model
= context
.model
66 self
.model
.updated
.connect(self
._updated
_callback
, type=Qt
.QueuedConnection
)
68 qtutils
.add_close_action(self
)
70 self
._updated
_callback
()
72 self
.init_state(context
.settings
, self
.resize
, 720, 420)
74 def set_model(self
, model
):
76 self
.tree
.set_model(model
)
79 """Refresh the model triggering view updates"""
82 def _updated_callback(self
):
83 branch
= self
.model
.currentbranch
84 curdir
= core
.getcwd()
85 msg
= N_('Repository: %s') % curdir
87 msg
+= N_('Branch: %s') % branch
90 scope
= dict(project
=self
.model
.project
, branch
=branch
)
91 title
= N_('%(project)s: %(branch)s - Browse') % scope
92 if self
.mode
== self
.model
.mode_amend
:
93 title
+= ' %s' % N_('(Amending)')
94 self
.setWindowTitle(title
)
97 # pylint: disable=too-many-ancestors
98 class RepoTreeView(standard
.TreeView
):
99 """Provides a filesystem-like view of a git repository."""
101 def __init__(self
, context
, parent
):
102 standard
.TreeView
.__init
__(self
, parent
)
104 self
.context
= context
105 self
.selection
= context
.selection
106 self
.saved_selection
= []
107 self
.saved_current_path
= None
108 self
.saved_open_folders
= set()
109 self
.restoring_selection
= False
110 self
._columns
_sized
= False
112 self
.setDragEnabled(True)
113 self
.setRootIsDecorated(False)
114 self
.setSortingEnabled(False)
115 self
.setSelectionMode(self
.ExtendedSelection
)
117 # Observe model updates
118 model
= context
.model
119 model
.about_to_update
.connect(self
.save_selection
, type=Qt
.QueuedConnection
)
120 model
.updated
.connect(self
.update_actions
, type=Qt
.QueuedConnection
)
121 self
.expanded
.connect(self
.index_expanded
)
123 self
.collapsed
.connect(lambda idx
: self
.size_columns())
124 self
.collapsed
.connect(self
.index_collapsed
)
126 # Sync selection before the key press event changes the model index
127 queued
= Qt
.QueuedConnection
128 self
.index_about_to_change
.connect(self
.sync_selection
, type=queued
)
130 self
.action_history
= qtutils
.add_action_with_status_tip(
132 N_('View History...'),
133 N_('View history for selected paths'),
138 self
.action_stage
= qtutils
.add_action_with_status_tip(
140 cmds
.StageOrUnstage
.name(),
141 N_('Stage/unstage selected paths for commit'),
142 cmds
.run(cmds
.StageOrUnstage
, context
),
143 hotkeys
.STAGE_SELECTION
,
146 self
.action_untrack
= qtutils
.add_action_with_status_tip(
148 N_('Untrack Selected'),
149 N_('Stop tracking paths'),
150 self
.untrack_selected
,
153 self
.action_rename
= qtutils
.add_action_with_status_tip(
154 self
, N_('Rename'), N_('Rename selected paths'), self
.rename_selected
157 self
.action_difftool
= qtutils
.add_action_with_status_tip(
159 cmds
.LaunchDifftool
.name(),
160 N_('Launch git-difftool on the current path'),
161 cmds
.run(cmds
.LaunchDifftool
, context
),
165 self
.action_difftool_predecessor
= qtutils
.add_action_with_status_tip(
167 N_('Diff Against Predecessor...'),
168 N_('Launch git-difftool against previous versions'),
169 self
.diff_predecessor
,
170 hotkeys
.DIFF_SECONDARY
,
173 self
.action_revert_unstaged
= qtutils
.add_action_with_status_tip(
175 cmds
.RevertUnstagedEdits
.name(),
176 N_('Revert unstaged changes to selected paths'),
177 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
181 self
.action_revert_uncommitted
= qtutils
.add_action_with_status_tip(
183 cmds
.RevertUncommittedEdits
.name(),
184 N_('Revert uncommitted changes to selected paths'),
185 cmds
.run(cmds
.RevertUncommittedEdits
, context
),
189 self
.action_editor
= qtutils
.add_action_with_status_tip(
191 cmds
.LaunchEditor
.name(),
192 N_('Edit selected paths'),
193 cmds
.run(cmds
.LaunchEditor
, context
),
197 self
.action_blame
= qtutils
.add_action_with_status_tip(
199 cmds
.BlamePaths
.name(),
200 N_('Blame selected paths'),
201 cmds
.run(cmds
.BlamePaths
, context
),
204 self
.action_refresh
= common
.refresh_action(context
, self
)
206 self
.action_default_app
= common
.default_app_action(
207 context
, self
, self
.selected_paths
210 self
.action_parent_dir
= common
.parent_dir_action(
211 context
, self
, self
.selected_paths
214 self
.action_terminal
= common
.terminal_action(
215 context
, self
, func
=self
.selected_paths
218 self
.x_width
= QtGui
.QFontMetrics(self
.font()).width('x')
219 self
.size_columns(force
=True)
221 def index_expanded(self
, index
):
222 """Update information about a directory as it is expanded."""
223 # Remember open folders so that we can restore them when refreshing
224 item
= self
.name_item_from_index(index
)
225 self
.saved_open_folders
.add(item
.path
)
228 # update information about a directory as it is expanded
235 model
.update_entry(path
)
237 for row
in range(item
.rowCount()):
238 path
= item
.child(row
, 0).path
239 model
.update_entry(path
)
243 def index_collapsed(self
, index
):
244 item
= self
.name_item_from_index(index
)
245 self
.saved_open_folders
.remove(item
.path
)
248 self
.model().refresh()
250 def size_columns(self
, force
=False):
251 """Set the column widths."""
252 cfg
= self
.context
.cfg
253 should_resize
= cfg
.get('cola.resizebrowsercolumns', default
=False)
254 if not force
and not should_resize
:
256 self
.resizeColumnToContents(0)
257 self
.resizeColumnToContents(1)
258 self
.resizeColumnToContents(2)
259 self
.resizeColumnToContents(3)
260 self
.resizeColumnToContents(4)
262 def sizeHintForColumn(self
, column
):
263 x_width
= self
.x_width
278 # Filename and others use the actual content
279 size
= super(RepoTreeView
, self
).sizeHintForColumn(column
)
282 def save_selection(self
):
283 selection
= self
.selected_paths()
285 self
.saved_selection
= selection
287 current
= self
.current_item()
289 self
.saved_current_path
= current
.path
292 selection
= self
.selectionModel()
293 flags
= selection
.Select | selection
.Rows
295 self
.restoring_selection
= True
297 # Restore opened folders
299 for path
in sorted(self
.saved_open_folders
):
300 row
= model
.get(path
)
303 index
= row
[0].index()
305 self
.setExpanded(index
, True)
307 # Restore the current item. We do this first, otherwise
308 # setCurrentIndex() can mess with the selection we set below
310 current_path
= self
.saved_current_path
312 row
= model
.get(current_path
)
314 current_index
= row
[0].index()
316 if current_index
and current_index
.isValid():
317 self
.setCurrentIndex(current_index
)
319 # Restore selected items
320 for path
in self
.saved_selection
:
321 row
= model
.get(path
)
324 index
= row
[0].index()
327 selection
.select(index
, flags
)
329 self
.restoring_selection
= False
331 # Resize the columns once when cola.resizebrowsercolumns is False.
332 # This provides a good initial size since we will not be resizing
333 # the columns during expand/collapse.
334 if not self
._columns
_sized
:
335 self
._columns
_sized
= True
336 self
.size_columns(force
=True)
340 def update_actions(self
):
341 """Enable/disable actions."""
342 selection
= self
.selected_paths()
343 selected
= bool(selection
)
344 staged
= bool(self
.selected_staged_paths(selection
=selection
))
345 modified
= bool(self
.selected_modified_paths(selection
=selection
))
346 unstaged
= bool(self
.selected_unstaged_paths(selection
=selection
))
347 tracked
= bool(self
.selected_tracked_paths(selection
=selection
))
348 revertable
= staged
or modified
350 self
.action_editor
.setEnabled(selected
)
351 self
.action_history
.setEnabled(selected
)
352 self
.action_default_app
.setEnabled(selected
)
353 self
.action_parent_dir
.setEnabled(selected
)
355 if self
.action_terminal
is not None:
356 self
.action_terminal
.setEnabled(selected
)
358 self
.action_stage
.setEnabled(staged
or unstaged
)
359 self
.action_untrack
.setEnabled(tracked
)
360 self
.action_rename
.setEnabled(tracked
)
361 self
.action_difftool
.setEnabled(staged
or modified
)
362 self
.action_difftool_predecessor
.setEnabled(tracked
)
363 self
.action_revert_unstaged
.setEnabled(revertable
)
364 self
.action_revert_uncommitted
.setEnabled(revertable
)
366 def contextMenuEvent(self
, event
):
367 """Create a context menu."""
368 self
.update_actions()
369 menu
= qtutils
.create_menu(N_('Actions'), self
)
370 menu
.addAction(self
.action_editor
)
371 menu
.addAction(self
.action_stage
)
373 menu
.addAction(self
.action_history
)
374 menu
.addAction(self
.action_difftool
)
375 menu
.addAction(self
.action_difftool_predecessor
)
376 menu
.addAction(self
.action_blame
)
378 menu
.addAction(self
.action_revert_unstaged
)
379 menu
.addAction(self
.action_revert_uncommitted
)
380 menu
.addAction(self
.action_untrack
)
381 menu
.addAction(self
.action_rename
)
383 menu
.addAction(self
.action_default_app
)
384 menu
.addAction(self
.action_parent_dir
)
386 if self
.action_terminal
is not None:
387 menu
.addAction(self
.action_terminal
)
388 menu
.exec_(self
.mapToGlobal(event
.pos()))
390 def mousePressEvent(self
, event
):
391 """Synchronize the selection on mouse-press."""
392 result
= QtWidgets
.QTreeView
.mousePressEvent(self
, event
)
393 self
.sync_selection()
396 def sync_selection(self
):
397 """Push selection into the selection model."""
402 state
= State(staged
, unmerged
, modified
, untracked
)
404 paths
= self
.selected_paths()
405 model
= self
.context
.model
406 model_staged
= utils
.add_parents(model
.staged
)
407 model_modified
= utils
.add_parents(model
.modified
)
408 model_unmerged
= utils
.add_parents(model
.unmerged
)
409 model_untracked
= utils
.add_parents(model
.untracked
)
412 if path
in model_unmerged
:
413 unmerged
.append(path
)
414 elif path
in model_untracked
:
415 untracked
.append(path
)
416 elif path
in model_staged
:
418 elif path
in model_modified
:
419 modified
.append(path
)
422 # Push the new selection into the model.
423 self
.selection
.set_selection(state
)
426 def selectionChanged(self
, old
, new
):
427 """Override selectionChanged to update available actions."""
428 result
= QtWidgets
.QTreeView
.selectionChanged(self
, old
, new
)
429 if not self
.restoring_selection
:
430 self
.update_actions()
434 def update_diff(self
):
435 context
= self
.context
436 model
= context
.model
437 paths
= self
.sync_selection()
438 if paths
and self
.model().path_is_interesting(paths
[0]):
439 cached
= paths
[0] in model
.staged
440 cmds
.do(cmds
.Diff
, context
, paths
[0], cached
)
442 def set_model(self
, model
):
443 """Set the concrete QAbstractItemModel instance."""
445 model
.restore
.connect(self
.restore
, type=Qt
.QueuedConnection
)
447 def name_item_from_index(self
, model_index
):
448 """Return the name item corresponding to the model index."""
449 index
= model_index
.sibling(model_index
.row(), 0)
450 return self
.model().itemFromIndex(index
)
452 def paths_from_indexes(self
, indexes
):
453 return qtutils
.paths_from_indexes(
454 self
.model(), indexes
, item_type
=GitRepoNameItem
.TYPE
457 def selected_paths(self
):
458 """Return the selected paths."""
459 return self
.paths_from_indexes(self
.selectedIndexes())
461 def selected_staged_paths(self
, selection
=None):
462 """Return selected staged paths."""
463 if selection
is None:
464 selection
= self
.selected_paths()
465 model
= self
.context
.model
466 staged
= utils
.add_parents(model
.staged
)
467 return [p
for p
in selection
if p
in staged
]
469 def selected_modified_paths(self
, selection
=None):
470 """Return selected modified paths."""
471 if selection
is None:
472 selection
= self
.selected_paths()
473 model
= self
.context
.model
474 modified
= utils
.add_parents(model
.modified
)
475 return [p
for p
in selection
if p
in modified
]
477 def selected_unstaged_paths(self
, selection
=None):
478 """Return selected unstaged paths."""
479 if selection
is None:
480 selection
= self
.selected_paths()
481 model
= self
.context
.model
482 modified
= utils
.add_parents(model
.modified
)
483 untracked
= utils
.add_parents(model
.untracked
)
484 unstaged
= modified
.union(untracked
)
485 return [p
for p
in selection
if p
in unstaged
]
487 def selected_tracked_paths(self
, selection
=None):
488 """Return selected tracked paths."""
489 if selection
is None:
490 selection
= self
.selected_paths()
491 model
= self
.context
.model
492 staged
= set(self
.selected_staged_paths(selection
=selection
))
493 modified
= set(self
.selected_modified_paths(selection
=selection
))
494 untracked
= utils
.add_parents(model
.untracked
)
495 tracked
= staged
.union(modified
)
496 return [p
for p
in selection
if p
not in untracked
or p
in tracked
]
498 def view_history(self
):
499 """Launch the configured history browser path-limited to entries."""
500 paths
= self
.selected_paths()
501 cmds
.do(cmds
.VisualizePaths
, self
.context
, paths
)
503 def untrack_selected(self
):
504 """untrack selected paths."""
505 context
= self
.context
506 cmds
.do(cmds
.Untrack
, context
, self
.selected_tracked_paths())
508 def rename_selected(self
):
509 """untrack selected paths."""
510 context
= self
.context
511 cmds
.do(cmds
.Rename
, context
, self
.selected_tracked_paths())
513 def diff_predecessor(self
):
514 """Diff paths against previous versions."""
515 context
= self
.context
516 paths
= self
.selected_tracked_paths()
517 args
= ['--'] + paths
518 revs
, summaries
= gitcmds
.log_helper(context
, all
=False, extra_args
=args
)
519 commits
= select_commits(
520 context
, N_('Select Previous Version'), revs
, summaries
, multiselect
=False
525 cmds
.difftool_launch(context
, left
=commit
, paths
=paths
)
527 def current_path(self
):
528 """Return the path for the current item."""
529 index
= self
.currentIndex()
530 if not index
.isValid():
532 return self
.name_item_from_index(index
).path
535 class BrowseModel(object):
536 """Context data used for browsing branches via git-ls-tree"""
538 def __init__(self
, ref
, filename
=None):
540 self
.relpath
= filename
541 self
.filename
= filename
544 class SaveBlob(cmds
.ContextCommand
):
545 def __init__(self
, context
, model
):
546 super(SaveBlob
, self
).__init
__(context
)
547 self
.browse_model
= model
550 git
= self
.context
.git
551 model
= self
.browse_model
552 ref
= '%s:%s' % (model
.ref
, model
.relpath
)
553 with core
.xopen(model
.filename
, 'wb') as fp
:
554 status
, output
, err
= git
.show(ref
, _stdout
=fp
)
556 out
= '# git show %s >%s\n%s' % (
558 shlex
.quote(model
.filename
),
561 Interaction
.command(N_('Error Saving File'), 'git show', status
, out
, err
)
565 msg
= N_('Saved "%(filename)s" from "%(ref)s" to "%(destination)s"') % dict(
566 filename
=model
.relpath
, ref
=model
.ref
, destination
=model
.filename
568 Interaction
.log_status(status
, msg
, '')
570 Interaction
.information(
571 N_('File Saved'), N_('File saved to "%s"') % model
.filename
575 class BrowseBranch(standard
.Dialog
):
577 def browse(cls
, context
, ref
):
578 model
= BrowseModel(ref
)
579 dlg
= cls(context
, model
, parent
=qtutils
.active_window())
580 dlg_model
= GitTreeModel(context
, ref
, dlg
)
581 dlg
.setModel(dlg_model
)
582 dlg
.setWindowTitle(N_('Browsing %s') % model
.ref
)
585 if dlg
.exec_() != dlg
.Accepted
:
589 def __init__(self
, context
, model
, parent
=None):
590 standard
.Dialog
.__init
__(self
, parent
=parent
)
591 if parent
is not None:
592 self
.setWindowModality(Qt
.WindowModal
)
594 # updated for use by commands
595 self
.context
= context
599 self
.tree
= GitTreeWidget(parent
=self
)
600 self
.close_button
= qtutils
.close_button()
603 self
.save
= qtutils
.create_button(text
=text
, enabled
=False, default
=True)
606 self
.btnlayt
= qtutils
.hbox(
607 defs
.margin
, defs
.spacing
, self
.close_button
, qtutils
.STRETCH
, self
.save
610 self
.layt
= qtutils
.vbox(defs
.margin
, defs
.spacing
, self
.tree
, self
.btnlayt
)
611 self
.setLayout(self
.layt
)
614 self
.tree
.path_chosen
.connect(self
.save_path
)
616 self
.tree
.selection_changed
.connect(
617 self
.selection_changed
, type=Qt
.QueuedConnection
620 qtutils
.connect_button(self
.close_button
, self
.close
)
621 qtutils
.connect_button(self
.save
, self
.save_blob
)
622 self
.init_size(parent
=parent
)
625 self
.tree
.expandAll()
627 def setModel(self
, model
):
628 self
.tree
.setModel(model
)
630 def path_chosen(self
, path
, close
=True):
631 """Update the model from the view"""
634 model
.filename
= path
638 def save_path(self
, path
):
639 """Choose an output filename based on the selected path"""
640 self
.path_chosen(path
, close
=False)
641 if save_path(self
.context
, path
, self
.model
):
645 """Save the currently selected file"""
646 filenames
= self
.tree
.selected_files()
649 self
.save_path(filenames
[0])
651 def selection_changed(self
):
652 """Update actions based on the current selection"""
653 filenames
= self
.tree
.selected_files()
654 self
.save
.setEnabled(bool(filenames
))
657 # pylint: disable=too-many-ancestors
658 class GitTreeWidget(standard
.TreeView
):
660 selection_changed
= Signal()
661 path_chosen
= Signal(object)
663 def __init__(self
, parent
=None):
664 standard
.TreeView
.__init
__(self
, parent
)
665 self
.setHeaderHidden(True)
666 # pylint: disable=no-member
667 self
.doubleClicked
.connect(self
.double_clicked
)
669 def double_clicked(self
, index
):
670 item
= self
.model().itemFromIndex(index
)
675 self
.path_chosen
.emit(item
.path
)
677 def selected_files(self
):
678 items
= self
.selected_items()
679 return [i
.path
for i
in items
if not i
.is_dir
]
681 def selectionChanged(self
, old_selection
, new_selection
):
682 QtWidgets
.QTreeView
.selectionChanged(self
, old_selection
, new_selection
)
683 self
.selection_changed
.emit()
685 def select_first_file(self
):
686 """Select the first filename in the tree"""
688 idx
= self
.indexAt(QtCore
.QPoint(0, 0))
689 item
= model
.itemFromIndex(idx
)
690 while idx
and idx
.isValid() and item
and item
.is_dir
:
691 idx
= self
.indexBelow(idx
)
692 item
= model
.itemFromIndex(idx
)
694 if idx
and idx
.isValid() and item
:
695 self
.setCurrentIndex(idx
)
698 class GitFileTreeModel(QtGui
.QStandardItemModel
):
699 """Presents a list of file paths as a hierarchical tree."""
701 def __init__(self
, parent
):
702 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
703 self
.dir_entries
= {'': self
.invisibleRootItem()}
707 QtGui
.QStandardItemModel
.clear(self
)
709 self
.dir_entries
= {'': self
.invisibleRootItem()}
711 def add_files(self
, files
):
712 """Add a list of files"""
713 add_file
= self
.add_file
717 def add_file(self
, path
):
718 """Add a file to the model."""
719 dirname
= utils
.dirname(path
)
720 dir_entries
= self
.dir_entries
722 parent
= dir_entries
[dirname
]
724 parent
= dir_entries
[dirname
] = self
.create_dir_entry(dirname
)
726 row_items
= create_row(path
, False)
727 parent
.appendRow(row_items
)
729 def add_directory(self
, parent
, path
):
730 """Add a directory entry to the model."""
732 row_items
= create_row(path
, True)
735 parent_path
= parent
.path
736 except AttributeError: # root QStandardItem
739 # Insert directories before file paths
741 row
= self
.dir_rows
[parent_path
]
743 row
= self
.dir_rows
[parent_path
] = 0
745 parent
.insertRow(row
, row_items
)
746 self
.dir_rows
[parent_path
] += 1
747 self
.dir_entries
[path
] = row_items
[0]
751 def create_dir_entry(self
, dirname
):
753 Create a directory entry for the model.
755 This ensures that directories are always listed before files.
758 entries
= dirname
.split('/')
760 parent
= self
.invisibleRootItem()
761 curdir_append
= curdir
.append
762 self_add_directory
= self
.add_directory
763 dir_entries
= self
.dir_entries
764 for entry
in entries
:
766 path
= '/'.join(curdir
)
768 parent
= dir_entries
[path
]
771 parent
= self_add_directory(grandparent
, path
)
772 dir_entries
[path
] = parent
776 def create_row(path
, is_dir
):
777 """Return a list of items representing a row."""
778 return [GitTreeItem(path
, is_dir
)]
781 class GitTreeModel(GitFileTreeModel
):
782 def __init__(self
, context
, ref
, parent
):
783 GitFileTreeModel
.__init
__(self
, parent
)
784 self
.context
= context
788 def _initialize(self
):
789 """Iterate over git-ls-tree and create GitTreeItems."""
790 git
= self
.context
.git
791 status
, out
, err
= git
.ls_tree('--full-tree', '-r', '-t', '-z', self
.ref
)
793 Interaction
.log_status(status
, out
, err
)
799 for line
in out
[:-1].split('\0'):
800 # .....6 ...4 ......................................40
801 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
802 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
804 relpath
= line
[6 + 1 + 4 + 1 + 40 + 1 :]
806 parent
= self
.dir_entries
[utils
.dirname(relpath
)]
807 self
.add_directory(parent
, relpath
)
809 self
.add_file(relpath
)
812 class GitTreeItem(QtGui
.QStandardItem
):
814 Represents a cell in a treeview.
816 Many GitRepoItems could map to a single repository path,
817 but this tree only has a single column.
818 Each GitRepoItem manages a different cell in the tree view.
822 def __init__(self
, path
, is_dir
):
823 QtGui
.QStandardItem
.__init
__(self
)
826 self
.setEditable(False)
827 self
.setDragEnabled(False)
828 self
.setText(utils
.basename(path
))
830 icon
= icons
.directory()
832 icon
= icons
.file_text()