class QmlInstantEngine(QQmlApplicationEngine): """ QmlInstantEngine is an utility class helping developing QML applications. It reloads itself whenever one of the watched source files is modified. As it consumes resources, make sure to disable file watching in production mode. """ def __init__(self, sourceFile="", watching=True, verbose=False, parent=None): """ watching -- Defines whether the watcher is active (default: True) verbose -- if True, output log infos (default: False) """ super(QmlInstantEngine, self).__init__(parent) self._fileWatcher = QFileSystemWatcher() # Internal Qt File Watcher self._sourceFile = "" self._watchedFiles = [] # Internal watched files list self._verbose = verbose # Verbose bool self._watching = False # self._extensions = [ "qml", "js" ] # File extensions that defines files to watch when adding a folder self._rootItem = None def onObjectCreated(root, url): if not root: return # Restore root item geometry if self._rootItem: root.setGeometry(self._rootItem.geometry()) self._rootItem.deleteLater() self._rootItem = root self.objectCreated.connect(onObjectCreated) # Update the watching status self.setWatching(watching) if sourceFile: self.load(sourceFile) def load(self, sourceFile): self._sourceFile = sourceFile super(QmlInstantEngine, self).load(sourceFile) def setWatching(self, watchValue): """ Enable (True) or disable (False) the file watching. Tip: file watching should be enable only when developing. """ if self._watching is watchValue: return self._watching = watchValue # Enable the watcher if self._watching: # 1. Add internal list of files to the internal Qt File Watcher self.addFiles(self._watchedFiles) # 2. Connect 'filechanged' signal self._fileWatcher.fileChanged.connect(self.onFileChanged) # Disabling the watcher else: # 1. Remove all files in the internal Qt File Watcher self._fileWatcher.removePaths(self._watchedFiles) # 2. Disconnect 'filechanged' signal self._fileWatcher.fileChanged.disconnect(self.onFileChanged) @property def watchedExtensions(self): """ Returns the list of extensions used when using addFilesFromDirectory. """ return self._extensions @watchedExtensions.setter def watchedExtensions(self, extensions): """ Set the list of extensions to search for when using addFilesFromDirectory. """ self._extensions = extensions def setVerbose(self, verboseValue): """ Activate (True) or desactivate (False) the verbose. """ self._verbose = verboseValue def addFile(self, filename): """ Add the given 'filename' to the watched files list. 'filename' can be an absolute or relative path (str and QUrl accepted) """ # Deal with QUrl type # NOTE: happens when using the source() method on a QQuickView if isinstance(filename, QUrl): filename = filename.path() # Make sure the file exists if not os.path.isfile(filename): raise ValueError("addFile: file %s doesn't exist." % filename) # Return if the file is already in our internal list if filename in self._watchedFiles: return # Add this file to the internal files list self._watchedFiles.append(filename) # And, if watching is active, add it to the internal watcher as well if self._watching: if self._verbose: print("instantcoding: addPath", filename) self._fileWatcher.addPath(filename) def addFiles(self, filenames): """ Add the given 'filenames' to the watched files list. filenames -- a list of absolute or relative paths (str and QUrl accepted) """ # Convert to list if not isinstance(filenames, list): filenames = [filenames] for filename in filenames: self.addFile(filename) def addFilesFromDirectory(self, dirname, recursive=False): """ Add files from the given directory name 'dirname'. dirname -- an absolute or a relative path recursive -- if True, will search inside each subdirectories recursively. """ if not os.path.isdir(dirname): raise RuntimeError( "addFilesFromDirectory : %s is not a valid directory." % dirname) if recursive: for dirpath, dirnames, filenames in os.walk(dirname): for filename in filenames: # Removing the starting dot from extension if os.path.splitext(filename)[1][1:] in self._extensions: self.addFile(os.path.join(dirpath, filename)) else: filenames = os.listdir(dirname) filenames = [ os.path.join(dirname, filename) for filename in filenames if os.path.splitext(filename)[1][1:] in self._extensions ] self.addFiles(filenames) def removeFile(self, filename): """ Remove the given 'filename' from the watched file list. Tip: make sure to use relative or absolute path according to how you add this file. """ if filename in self._watchedFiles: self._watchedFiles.remove(filename) if self._watching: self._fileWatcher.removePath(filename) def getRegisteredFiles(self): """ Returns the list of watched files """ return self._watchedFiles @Slot(str) def onFileChanged(self, filepath): """ Handle changes in a watched file. """ if filepath not in self._watchedFiles: # could happen if a file has just been reloaded # and has not been re-added yet to the watched files return if self._verbose: print("Source file changed : ", filepath) # Clear the QQuickEngine cache self.clearComponentCache() # Remove the modified file from the watched list self.removeFile(filepath) cptTry = 0 # Make sure file is available before doing anything # NOTE: useful to handle editors (Qt Creator) that deletes the source file and # creates a new one when saving while not os.path.exists(filepath) and cptTry < 10: time.sleep(0.1) cptTry += 1 self.reload() # Finally, re-add the modified file to the watch system # after a short cooldown to avoid multiple consecutive reloads QTimer.singleShot(200, lambda: self.addFile(filepath)) def reload(self): print("Reloading {}".format(self._sourceFile)) self.load(self._sourceFile)
class DataConnection(ProjectItem): def __init__(self, toolbox, project, logger, name, description, x, y, references=None): """Data Connection class. Args: toolbox (ToolboxUI): QMainWindow instance project (SpineToolboxProject): the project this item belongs to logger (LoggerInterface): a logger instance name (str): Object name description (str): Object description x (float): Initial X coordinate of item icon y (float): Initial Y coordinate of item icon references (list): a list of file paths """ super().__init__(name, description, x, y, project, logger) self._toolbox = toolbox self.reference_model = QStandardItemModel() # References to files self.data_model = QStandardItemModel( ) # Paths of project internal files. These are found in DC data directory self.datapackage_icon = QIcon(QPixmap(":/icons/datapkg.png")) self.data_dir_watcher = None # Populate references model if references is None: references = list() # Convert relative paths to absolute self.references = [ deserialize_path(r, self._project.project_dir) for r in references ] self.populate_reference_list(self.references) # Populate data (files) model data_files = self.data_files() self.populate_data_list(data_files) self.spine_datapackage_form = None def set_up(self): self.data_dir_watcher = QFileSystemWatcher(self) if os.path.isdir(self.data_dir): self.data_dir_watcher.addPath(self.data_dir) self.data_dir_watcher.directoryChanged.connect(self.refresh) @staticmethod def item_type(): """See base class.""" return ItemInfo.item_type() @staticmethod def item_category(): """See base class.""" return ItemInfo.item_category() def execution_item(self): """Creates DataConnection's execution counterpart.""" data_files = [ os.path.join(self.data_dir, f) for f in self.data_files() ] return ExecutableItem(self.name, self.file_references(), data_files, self._logger) def make_signal_handler_dict(self): """Returns a dictionary of all shared signals and their handlers. This is to enable simpler connecting and disconnecting.""" s = super().make_signal_handler_dict() # pylint: disable=unnecessary-lambda s[self._properties_ui.toolButton_dc_open_dir. clicked] = lambda checked=False: self.open_directory() s[self._properties_ui.toolButton_plus.clicked] = self.add_references s[self._properties_ui.toolButton_minus. clicked] = self.remove_references s[self._properties_ui.toolButton_add.clicked] = self.copy_to_project s[self._properties_ui.pushButton_datapackage. clicked] = self.show_spine_datapackage_form s[self._properties_ui.treeView_dc_references. doubleClicked] = self.open_reference s[self._properties_ui.treeView_dc_data. doubleClicked] = self.open_data_file s[self._properties_ui.treeView_dc_references. files_dropped] = self.add_files_to_references s[self._properties_ui.treeView_dc_data. files_dropped] = self.add_files_to_data_dir s[self.get_icon(). files_dropped_on_icon] = self.receive_files_dropped_on_icon s[self._properties_ui.treeView_dc_references. del_key_pressed] = lambda: self.remove_references() s[self._properties_ui.treeView_dc_data. del_key_pressed] = lambda: self.remove_files() return s def restore_selections(self): """Restore selections into shared widgets when this project item is selected.""" self._properties_ui.label_dc_name.setText(self.name) self._properties_ui.treeView_dc_references.setModel( self.reference_model) self._properties_ui.treeView_dc_data.setModel(self.data_model) @Slot("QVariant") def add_files_to_references(self, paths): """Add multiple file paths to reference list. Args: paths (list): A list of paths to files """ repeated_paths = [] new_paths = [] for path in paths: if any(os.path.samefile(path, ref) for ref in self.references): repeated_paths.append(path) else: new_paths.append(path) repeated_paths = ", ".join(repeated_paths) if repeated_paths: self._logger.msg_warning.emit( f"Reference to file(s) <b>{repeated_paths}</b> already available" ) if new_paths: self._toolbox.undo_stack.push( AddDCReferencesCommand(self, new_paths)) def do_add_files_to_references(self, paths): abspaths = [os.path.abspath(path) for path in paths] self.references.extend(abspaths) self.populate_reference_list(self.references) @Slot("QGraphicsItem", list) def receive_files_dropped_on_icon(self, icon, file_paths): """Called when files are dropped onto a data connection graphics item. If the item is this Data Connection's graphics item, add the files to data.""" if icon == self.get_icon(): self.add_files_to_data_dir(file_paths) @Slot("QVariant") def add_files_to_data_dir(self, file_paths): """Add files to data directory""" for file_path in file_paths: filename = os.path.split(file_path)[1] self._logger.msg.emit( f"Copying file <b>{filename}</b> to <b>{self.name}</b>") try: shutil.copy(file_path, self.data_dir) except OSError: self._logger.msg_error.emit("[OSError] Copying failed") return @Slot(bool) def add_references(self, checked=False): """Let user select references to files for this data connection.""" # noinspection PyCallByClass, PyTypeChecker, PyArgumentList answer = QFileDialog.getOpenFileNames(self._toolbox, "Add file references", self._project.project_dir, "*.*") file_paths = answer[0] if not file_paths: # Cancel button clicked return self.add_files_to_references(file_paths) @Slot(bool) def remove_references(self, checked=False): """Remove selected references from reference list. Do not remove anything if there are no references selected. """ indexes = self._properties_ui.treeView_dc_references.selectedIndexes() if not indexes: # Nothing selected self._logger.msg.emit("Please select references to remove") return references = [ind.data(Qt.DisplayRole) for ind in indexes] self._toolbox.undo_stack.push( RemoveDCReferencesCommand(self, references)) self._logger.msg.emit("Selected references removed") def do_remove_references(self, references): self.references = [ r for r in self.references if not any(os.path.samefile(r, ref) for ref in references) ] self.populate_reference_list(self.references) @Slot(bool) def copy_to_project(self, checked=False): """Copy selected file references to this Data Connection's data directory.""" selected_indexes = self._properties_ui.treeView_dc_references.selectedIndexes( ) if not selected_indexes: self._logger.msg_warning.emit("No files to copy") return for index in selected_indexes: file_path = self.reference_model.itemFromIndex(index).data( Qt.DisplayRole) if not os.path.exists(file_path): self._logger.msg_error.emit( f"File <b>{file_path}</b> does not exist") continue filename = os.path.split(file_path)[1] self._logger.msg.emit( f"Copying file <b>{filename}</b> to Data Connection <b>{self.name}</b>" ) try: shutil.copy(file_path, self.data_dir) except OSError: self._logger.msg_error.emit("[OSError] Copying failed") continue @Slot("QModelIndex") def open_reference(self, index): """Open reference in default program.""" if not index: return if not index.isValid(): logging.error("Index not valid") return reference = self.file_references()[index.row()] url = "file:///" + reference # noinspection PyTypeChecker, PyCallByClass, PyArgumentList res = open_url(url) if not res: self._logger.msg_error.emit( f"Failed to open reference:<b>{reference}</b>") @Slot("QModelIndex") def open_data_file(self, index): """Open data file in default program.""" if not index: return if not index.isValid(): logging.error("Index not valid") return data_file = self.data_files()[index.row()] url = "file:///" + os.path.join(self.data_dir, data_file) # noinspection PyTypeChecker, PyCallByClass, PyArgumentList res = open_url(url) if not res: self._logger.msg_error.emit( f"Opening file <b>{data_file}</b> failed") @busy_effect def show_spine_datapackage_form(self): """Show spine_datapackage_form widget.""" if self.spine_datapackage_form: if self.spine_datapackage_form.windowState() & Qt.WindowMinimized: # Remove minimized status and restore window with the previous state (maximized/normal state) self.spine_datapackage_form.setWindowState( self.spine_datapackage_form.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) self.spine_datapackage_form.activateWindow() else: self.spine_datapackage_form.raise_() return self.spine_datapackage_form = SpineDatapackageWidget(self) self.spine_datapackage_form.destroyed.connect( self.datapackage_form_destroyed) self.spine_datapackage_form.show() @Slot() def datapackage_form_destroyed(self): """Notify a connection that datapackage form has been destroyed.""" self.spine_datapackage_form = None def make_new_file(self): """Create a new blank file to this Data Connections data directory.""" msg = "File name" # noinspection PyCallByClass, PyTypeChecker, PyArgumentList answer = QInputDialog.getText(self._toolbox, "Create new file", msg, flags=Qt.WindowTitleHint | Qt.WindowCloseButtonHint) file_name = answer[0] if not file_name.strip(): return # Check that file name has no invalid chars if any(True for x in file_name if x in INVALID_FILENAME_CHARS): msg = f"File name <b>{file_name}</b> contains invalid characters." self._logger.information_box.emit("Creating file failed", msg) return file_path = os.path.join(self.data_dir, file_name) if os.path.exists(file_path): msg = f"File <b>{file_name}</b> already exists." self._logger.information_box.emit("Creating file failed", msg) return try: with open(file_path, "w"): self._logger.msg.emit( f"File <b>{file_name}</b> created to Data Connection <b>{self.name}</b>" ) except OSError: msg = "Please check directory permissions." self._logger.information_box.emit("Creating file failed", msg) return def remove_files(self): """Remove selected files from data directory.""" indexes = self._properties_ui.treeView_dc_data.selectedIndexes() if not indexes: # Nothing selected self._logger.msg.emit("Please select files to remove") return file_list = list() for index in indexes: file_at_index = self.data_model.itemFromIndex(index).data( Qt.DisplayRole) file_list.append(file_at_index) files = "\n".join(file_list) msg = ( "The following files will be removed permanently from the project\n\n" "{0}\n\n" "Are you sure?".format(files)) title = "Remove {0} File(s)".format(len(file_list)) message_box = QMessageBox(QMessageBox.Question, title, msg, QMessageBox.Ok | QMessageBox.Cancel, parent=self._toolbox) message_box.button(QMessageBox.Ok).setText("Remove Files") answer = message_box.exec_() if answer == QMessageBox.Cancel: return for filename in file_list: path_to_remove = os.path.join(self.data_dir, filename) try: os.remove(path_to_remove) self._logger.msg.emit(f"File <b>{path_to_remove}</b> removed") except OSError: self._logger.msg_error.emit( f"Removing file {path_to_remove} failed.\nCheck permissions." ) return def file_references(self): """Returns a list of paths to files that are in this item as references.""" return self.references def data_files(self): """Returns a list of files that are in the data directory.""" if not os.path.isdir(self.data_dir): return [] files = list() with os.scandir(self.data_dir) as scan_iterator: for entry in scan_iterator: if entry.is_file(): files.append(entry.path) return files @Slot("QString") def refresh(self, _=None): """Refresh data files in Data Connection Properties. NOTE: Might lead to performance issues.""" d = self.data_files() self.populate_data_list(d) def populate_reference_list(self, items, emit_item_changed=True): """List file references in QTreeView. If items is None or empty list, model is cleared. """ self.reference_model.clear() self.reference_model.setHorizontalHeaderItem( 0, QStandardItem("References")) # Add header if items is not None: for item in items: qitem = QStandardItem(item) qitem.setFlags(~Qt.ItemIsEditable) qitem.setData(item, Qt.ToolTipRole) qitem.setData( self._toolbox.style().standardIcon(QStyle.SP_FileLinkIcon), Qt.DecorationRole) self.reference_model.appendRow(qitem) if emit_item_changed: self.item_changed.emit() def populate_data_list(self, items): """List project internal data (files) in QTreeView. If items is None or empty list, model is cleared. """ self.data_model.clear() self.data_model.setHorizontalHeaderItem( 0, QStandardItem("Data")) # Add header if items is not None: for item in items: qitem = QStandardItem(item) qitem.setFlags(~Qt.ItemIsEditable) if item == 'datapackage.json': qitem.setData(self.datapackage_icon, Qt.DecorationRole) else: qitem.setData(QFileIconProvider().icon(QFileInfo(item)), Qt.DecorationRole) full_path = os.path.join(self.data_dir, item) # For drag and drop qitem.setData(full_path, Qt.UserRole) self.data_model.appendRow(qitem) self.item_changed.emit() def update_name_label(self): """Update Data Connection tab name label. Used only when renaming project items.""" self._properties_ui.label_dc_name.setText(self.name) def resources_for_direct_successors(self): """see base class""" refs = self.file_references() f_list = [os.path.join(self.data_dir, f) for f in self.data_files()] resources = [ ProjectItemResource(self, "file", url=pathlib.Path(ref).as_uri()) for ref in refs + f_list ] return resources def _do_handle_dag_changed(self, resources): """See base class.""" if not self.file_references() and not self.data_files(): self.add_notification( "This Data Connection does not have any references or data. " "Add some in the Data Connection Properties panel.") def item_dict(self): """Returns a dictionary corresponding to this item.""" d = super().item_dict() # Convert paths to relative before saving d["references"] = [ serialize_path(f, self._project.project_dir) for f in self.file_references() ] return d def rename(self, new_name): """Rename this item. Args: new_name (str): New name Returns: bool: True if renaming succeeded, False otherwise """ dirs = self.data_dir_watcher.directories() if dirs: self.data_dir_watcher.removePaths(dirs) if not super().rename(new_name): self.data_dir_watcher.addPaths(dirs) return False self.data_dir_watcher.addPath(self.data_dir) self.refresh() return True def tear_down(self): """Tears down this item. Called by toolbox just before closing. Closes the SpineDatapackageWidget instances opened.""" if self.spine_datapackage_form: self.spine_datapackage_form.close() watched_paths = self.data_dir_watcher.directories() if watched_paths: self.data_dir_watcher.removePaths(watched_paths) self.data_dir_watcher.deleteLater() def notify_destination(self, source_item): """See base class.""" if source_item.item_type() == "Tool": self._logger.msg.emit( f"Link established. Tool <b>{source_item.name}</b> output files will be " f"passed as references to item <b>{self.name}</b> after execution." ) elif source_item.item_type() in ["Data Store", "Importer"]: # Does this type of link do anything? self._logger.msg.emit("Link established.") else: super().notify_destination(source_item) @staticmethod def default_name_prefix(): """See base class.""" return "Data Connection"
class Pqgit(QMainWindow): """ main class / entry point """ def __init__(self): super().__init__() self.setAttribute( Qt.WA_DeleteOnClose ) # let Qt delete stuff before the python garbage-collector gets to work self.repo = None self.branches_model = None # instantiate main window self.ui = ui.Ui_MainWindow() self.ui.setupUi(self) self.fs_watch = QFileSystemWatcher(self) self.fs_watch.fileChanged.connect(self.on_file_changed) self.fs_watch.directoryChanged.connect(self.on_dir_changed) self.settings = QSettings(QSettings.IniFormat, QSettings.UserScope, 'pqgit', 'config') # for comparison self.new_c_id, self.old_c_id = None, None # window icon cwd = os.path.dirname(os.path.realpath(__file__)) self.setWindowIcon(QIcon(os.path.join(cwd, 'Git-Icon-White.png'))) self.setWindowTitle('pqgit') # size and position self.move(self.settings.value('w/pos', QPoint(200, 200))) self.resize(self.settings.value('w/size', QSize(1000, 1000))) self.ui.hist_splitter.setSizes([ int(s) for s in self.settings.value('w/hist_splitter', [720, 360]) ]) self.ui.cinf_splitter.setSizes([ int(s) for s in self.settings.value('w/cinf_splitter', [360, 360]) ]) self.ui.diff_splitter.setSizes([ int(s) for s in self.settings.value('w/diff_splitter', [150, 1200, 230]) ]) # open repo dir open_shortcut = QShortcut(QKeySequence('Ctrl+O'), self) open_shortcut.activated.connect(self.open_dir) # set-up ui self.branches_model = BranchesModel() self.ui.tvBranches.setModel(self.branches_model) self.ui.tvBranches.selectionModel().selectionChanged.connect( self.branches_selection_changed) self.ui.tvBranches.resizeColumnsToContents() self.history_model = HistoryModel() self.ui.tvHistory.setModel(self.history_model) self.ui.tvHistory.selectionModel().selectionChanged.connect( self.history_selection_changed) self.files_model = FilesModel() self.ui.tvFiles.setModel(self.files_model) self.ui.tvFiles.selectionModel().selectionChanged.connect( self.files_selection_changed) self.ui.tvFiles.doubleClicked.connect(self.on_file_doubleclicked) for view in (self.ui.tvBranches, self.ui.tvHistory, self.ui.tvFiles): view.horizontalHeader().setSectionResizeMode( 1, QHeaderView.Stretch) view.setSelectionBehavior(QAbstractItemView.SelectRows) view.setShowGrid(False) view.verticalHeader().setDefaultSectionSize( QApplication.font().pointSize() + 2) view.verticalHeader().hide() self.ui.teDiff.setFont(QFont('Monospace')) self.difftools = [] timer = QTimer(self) timer.timeout.connect(self.on_timer) timer.start(5000) self.dir_name = self.settings.value('last_opened_repo', None) try: pygit2.Repository(self.dir_name) except Exception: #pylint: disable=broad-except self.open_dir() return self.open_repo() def open_dir(self): """ show open dir dialog and open repo """ last_dir = self.settings.value('last_fileopen_dir', '') fd = QFileDialog(self, 'Open .git', last_dir) fd.setFileMode(QFileDialog.DirectoryOnly) fd.setFilter( QDir.Filters(QDir.Dirs | QDir.Hidden | QDir.NoDot | QDir.NoDotDot)) while True: if not fd.exec(): return self.dir_name = fd.selectedFiles()[0] parent = os.path.dirname(self.dir_name) self.settings.setValue('last_fileopen_dir', parent) self.settings.setValue('last_opened_repo', self.dir_name) try: pygit2.Repository(self.dir_name) break except pygit2.GitError: QMessageBox(self, text='Cannot open repo: ' + self.dir_name).exec() self.open_repo() def open_repo(self): """ called either on start or after open dialog """ self.setWindowTitle(f'{self.dir_name} - pqgit ({VERSION})') self.repo = pygit2.Repository(self.dir_name) # remove existing files and folder from watch if self.fs_watch.files(): self.fs_watch.removePaths(self.fs_watch.files()) if self.fs_watch.directories(): self.fs_watch.removePaths(self.fs_watch.directories()) wd = self.repo.workdir self.fs_watch.addPath(wd) # get head tree for list of files in repo target = self.repo.head.target last_commit = self.repo[target] tree_id = last_commit.tree_id tree = self.repo[tree_id] # add those files and folder to watch self.fs_watch.addPaths([wd + o[0] for o in parse_tree_rec(tree, True)]) # get files/folders not in repo from status self.fs_watch.addPaths([ wd + p for p, f in self.repo.status().items() if GIT_STATUS[f] != 'I' ]) # (doesn't matter some are in both lists, already monitored ones will not be added by Qt) # local branches branches = [] selected_branch_row = 0 for idx, b_str in enumerate(self.repo.branches.local): b = self.repo.branches[b_str] if b.is_checked_out(): selected_branch_row = idx branches.append( Branch(name=b.branch_name, ref=b.name, c_o=b.is_checked_out())) # tags regex = re.compile('^refs/tags') tags = list(filter(regex.match, self.repo.listall_references())) branches += [Branch(name=t[10:], ref=t, c_o=False) for t in tags] self.branches_model.update(branches) idx1 = self.branches_model.index(selected_branch_row, 0) idx2 = self.branches_model.index(selected_branch_row, self.branches_model.columnCount() - 1) self.ui.tvBranches.selectionModel().select(QItemSelection(idx1, idx2), QItemSelectionModel.Select) self.ui.tvHistory.resizeColumnsToContents() def on_timer(self): """ poll opened diff tools (like meld) and close temp files when finished """ for dt in self.difftools: if subprocess.Popen.poll(dt.proc) is not None: if dt.old_f: dt.old_f.close() if dt.new_f: dt.new_f.close() dt.running = False self.difftools[:] = [dt for dt in self.difftools if dt.running] def on_file_changed(self, path): """ existing files edited """ patch = self.files_model.patches[ self.ui.tvFiles.selectionModel().selectedRows()[0].row()] if self.repo.workdir + patch.path == path: self.files_selection_changed() def on_dir_changed(self, path): """ file added/deleted; refresh history to show it in 'working' """ # remember history selection history_ids = [] for idx in self.ui.tvHistory.selectionModel().selectedRows(): history_ids.append(self.history_model.commits[idx.row()].id) bak_path = self.files_model.patches[ self.ui.tvFiles.selectionModel().selectedRows()[0].row()].path self.refresh_history() # restore history selection for i in history_ids: for row, c in enumerate(self.history_model.commits): if c.id == i: idx1 = self.history_model.index(row, 0) idx2 = self.history_model.index( row, self.history_model.columnCount() - 1) self.ui.tvHistory.selectionModel().select( QItemSelection(idx1, idx2), QItemSelectionModel.Select) # restore file selection if not bak_path: return for row, patch in enumerate(self.files_model.patches): if patch.path == bak_path: idx1 = self.files_model.index(row, 0) idx2 = self.files_model.index( row, self.files_model.columnCount() - 1) self.ui.tvFiles.selectionModel().select( QItemSelection(idx1, idx2), QItemSelectionModel.Select) break def refresh_history(self): """ called and branch check-out (which is also called during start-up) to populate commit log """ commits = [] # working directory status = self.repo.status() if len(status.items()) > 0: commits.append(Commit('working', 'working', None, None, None, None)) for c in self.repo.walk(self.repo.head.target, pygit2.GIT_SORT_TOPOLOGICAL): commit = Commit(id=c.id.hex, tree_id=c.tree_id.hex, author=c.author, dt=c.commit_time, dt_offs=c.commit_time_offset, message=c.message.strip()) commits.append(commit) self.history_model.update(commits) self.ui.tvHistory.resizeColumnsToContents() def branches_selection_changed(self): """ checkout selected branch """ selected_row = self.ui.tvBranches.selectionModel().selectedRows( )[0].row() self.repo.checkout(self.branches_model.branches[selected_row].ref, strategy=pygit2.GIT_CHECKOUT_SAFE) self.refresh_history() def on_file_doubleclicked(self, index): """ get files contents for revisions and start diff tool """ patch = self.files_model.patches[index.row()] if not patch.old_file_id: msg_box = QMessageBox(self) msg_box.setText("Nothing to compare to.") msg_box.exec() return old_f = tempfile.NamedTemporaryFile( prefix=f'old_{self.old_c_id[:7]}__') old_f.write(self.repo[patch.old_file_id].data) old_f.flush() new_f = None if patch.new_file_id: # compare 2 revisions new_f = tempfile.NamedTemporaryFile( prefix=f'new_{self.new_c_id[:7]}__') new_f.write(self.repo[patch.new_file_id].data) new_f.flush() new_f_name = new_f.name else: # compare some revision with working copy new_f_name = self.repo.workdir + patch.path.strip() proc = subprocess.Popen( [self.settings.value('diff_tool', 'meld'), old_f.name, new_f_name]) self.difftools.append(Proc(proc, old_f, new_f, True)) def history_selection_changed(self, selected): """ docstring """ self.ui.teDiff.setText('') self.new_c_id, self.old_c_id = None, None selection_model = self.ui.tvHistory.selectionModel() selected_rows = selection_model.selectedRows() self.ui.teCommit.setPlainText('') commit = None fst_tid, fst_obj = None, None snd_tid, snd_obj = None, None if len(selected_rows) < 1: # nothing to do return if len(selected_rows) > 2: # don't allow more than 2 selected lines selection_model.select(selected, QItemSelectionModel.Deselect) return if len(selected_rows) == 1: # single revision selected commit = self.history_model.commits[selected_rows[0].row()] fst_tid = commit.tree_id if selected_rows[0].row() + 1 < self.history_model.rowCount(): # there is a parent, get it's id to compare to it snd_commit = self.history_model.commits[selected_rows[0].row() + 1] snd_tid = snd_commit.tree_id self.new_c_id = commit.id self.old_c_id = snd_commit.id # set commit details in view if commit.tree_id != 'working': text = 'Commit: ' + commit.id + '\n\n' text += 'Author: ' + commit.author.name + ' <' + commit.author.email + '>\n\n' text += commit.message + '\n' self.ui.teCommit.setPlainText(text) else: # 2 revisions selected fst_row, snd_row = tuple( sorted([selected_rows[0].row(), selected_rows[1].row()])) commit = self.history_model.commits[fst_row] fst_tid = commit.tree_id snd_commit = self.history_model.commits[snd_row] snd_tid = snd_commit.tree_id self.new_c_id = commit.id self.old_c_id = snd_commit.id if fst_tid != 'working': fst_obj = self.repo.revparse_single(fst_tid) if snd_tid: snd_obj = self.repo.revparse_single(snd_tid) diff = None if fst_tid == 'working': # diff for working directory only shows... some files; get them anyway, then insert the ones from status diff = self.repo.diff( snd_obj, None) # regardless of snd_obj being something or None patches = [ Patch( p.delta.new_file.path.strip(), # p.delta.status_char(), None, # p.delta.new_file.id.hex is 'some' id, but it's somehow not ok... p.delta.old_file.id.hex if p.delta.old_file.id.hex.find('00000') < 0 else None, ) for p in diff ] inserted = [p.delta.new_file.path for p in diff] status = self.repo.status() for path, flags in status.items(): if path not in inserted: patches.append( Patch(path.strip(), GIT_STATUS[flags], None, None)) elif snd_obj: diff = self.repo.diff(snd_obj, fst_obj) patches = [ Patch( p.delta.new_file.path.strip(), # p.delta.status_char(), p.delta.new_file.id.hex if p.delta.new_file.id.hex.find('00000') < 0 else None, p.delta.old_file.id.hex if p.delta.old_file.id.hex.find('00000') < 0 else None, ) for p in diff ] else: # initial revision patches = [ Patch(o[0], 'A', o[1], None) for o in parse_tree_rec(fst_obj) ] patches = sorted(patches, key=lambda p: p.path) self.files_model.update(patches) self.ui.tvFiles.resizeColumnsToContents() def files_selection_changed(self): """ show diff (or file content for new, ignored, ... files) """ patch = self.files_model.patches[ self.ui.tvFiles.selectionModel().selectedRows()[0].row()] nf_data, of_data = None, None # new_file, old_file if patch.new_file_id: nf_data = self.repo[patch.new_file_id].data.decode('utf-8') if patch.old_file_id: of_data = self.repo[patch.old_file_id].data.decode('utf-8') if nf_data and of_data: html = _html_diff.make_file( fromlines=nf_data.splitlines(), # tolines=of_data.splitlines(), fromdesc=f'old ({self.old_c_id[:7]})', todesc=f'new ({self.new_c_id[:7]})', context=True) self.ui.teDiff.setHtml(html) elif nf_data: self.ui.teDiff.setText(nf_data) elif of_data: if patch.status == 'M': # this should be working directory compared to something else with open(self.repo.workdir + patch.path.strip()) as f: nf_data = f.read() html = _html_diff.make_file( fromlines=nf_data.splitlines(), tolines=of_data.splitlines(), fromdesc=f'old ({self.old_c_id[:7]})', todesc=f'new ({self.new_c_id[:7]})', context=True) self.ui.teDiff.setHtml(html) else: self.ui.teDiff.setText(of_data) else: with open(self.repo.workdir + patch.path.strip()) as f: self.ui.teDiff.setPlainText(f.read()) self.ui.diff_groupbox.setTitle( 'Diff' if nf_data and of_data else 'File') def closeEvent(self, event): # pylint: disable=invalid-name, no-self-use """ event handler for window closing; save settings """ del event self.settings.setValue('w/pos', self.pos()) self.settings.setValue('w/size', self.size()) self.settings.setValue('w/hist_splitter', self.ui.hist_splitter.sizes()) self.settings.setValue('w/cinf_splitter', self.ui.cinf_splitter.sizes()) self.settings.setValue('w/diff_splitter', self.ui.diff_splitter.sizes()) # delete any left temp files self.on_timer()