Example #1
0
class Firefox(QObject):
    NAME: str = "Firefox"
    SESSION_LOCATION_COMMAND: list = [
        "find ~/.mozilla/firefox*/*.*/sessionstore-backups/recovery.jsonlz4"
    ]
    MOZILLA_MAGIC_NUMBER: int = 8  # NOTE: https://gist.github.com/mnordhoff/25e42a0d29e5c12785d0

    tabs_changed = Signal()

    def __init__(self):
        super(Firefox, self).__init__(None)

        self.logger = create_logger(__name__)
        self.tabs_model = WebTabsModel()
        self.file_expect = FileExpect()

        self.detect()

        self.file_expect.file_exists.connect(self.get_tabs)

    def detect(self) -> None:
        try:
            self.tabs_location = subprocess.check_output(
                Firefox.SESSION_LOCATION_COMMAND,
                shell=True).decode("utf-8").replace("\n", "")
            self.logger.info("Firefox detected={tabs_location}".format(
                tabs_location=(bool(self.tabs_location != ""))))

            self.tabs_file_watcher = QFileSystemWatcher()
            self.get_tabs(self.tabs_location)
            self.tabs_file_watcher.fileChanged.connect(self.get_tabs,
                                                       Qt.QueuedConnection)

            self.detected = True

        except subprocess.CalledProcessError as error:
            self.detected = False

    @Slot(str)
    def get_tabs(self, path: str) -> None:
        tabs = []

        if not os.path.isfile(path):
            self.file_expect.observe(path)
            return

        if path not in (self.tabs_file_watcher.files()):
            self.tabs_file_watcher.addPath(self.tabs_location)

        with open(self.tabs_location, "rb") as tabs_file:
            mozilla_magic = tabs_file.read(Firefox.MOZILLA_MAGIC_NUMBER)
            j_data = json.loads(
                lz4.block.decompress(tabs_file.read()).decode("utf-8"))

        for window in j_data.get("windows"):
            for tab in window.get("tabs"):
                index = int(tab.get("index")) - 1

                if (is_youtube(tab.get("entries")[index].get("url"))):
                    tabs.append(
                        BrowserTab(
                            tab.get("entries")[index].get("url"),
                            tab.get("entries")[index].get("title")))

        if (self.tabs_model.tabs != tabs):
            self.tabs_model.set_tabs(tabs)

    @Property(str, constant=True)
    def name(self) -> str:
        return Firefox.NAME

    @Property(QObject, constant=True)
    def tabs(self) -> WebTabsModel:
        return self.tabs_model
Example #2
0
class QFileSystemLibraryModel(QFileSystemModel):
    """
    File System Model for displaying QLibrary in MetalGUI
    Has additional FileWatcher added to keep track of edited
    QComponent files and, in developer mode,
    to alert the view/delegate to let the user know these files
    are dirty and refresh the design
    """
    FILENAME = 0  # Column index to display filenames
    REBUILD = 1  # Column index to display Rebuild button

    file_dirtied_signal = Signal()
    file_cleaned_signal = Signal()

    def __init__(self, parent: QWidget = None):
        """
        Initializes Model


        Args:
            parent(QWidget): Parent widget
        """
        super().__init__(parent)

        self.file_system_watcher = QFileSystemWatcher()
        self.dirtied_files = {}
        self.ignored_substrings = {'.cpython', '__pycache__'}
        self.is_dev_mode = False
        self.columns = ['QComponents', 'Rebuild Buttons']

    def is_valid_file(self, file: str) -> bool:
        """
        Whether it's a file the FileWatcher should track
        Args:
            file(str): Filename

        Returns:
            bool: Whether file is one the FileWatcher should track

        """
        for sub in self.ignored_substrings:
            if sub in file:
                return False
        return True

    def clean_file(self, filepath: str):
        """
        Remove file from the dirtied_files dictionary
        and remove any parent files who are only dirty due to
        this file. Emits file_cleaned_signal.
        Args:
            filepath(str):  File path of file to be cleaned

        """
        filename = self.filepath_to_filename(filepath)
        self.dirtied_files.pop(filename, f"failed to pop {filepath}")

        sep = os.sep if os.sep in filepath else '/'
        for file in filepath.split(sep):
            if file in self.dirtied_files:
                # if file was in dirtied files only because it is a parent dir
                # of filename, remove
                self.dirtied_files[file].discard(filename)

                if len(self.dirtied_files[file]) < 1:
                    self.dirtied_files.pop(file)
        self.file_cleaned_signal.emit()

    def dirty_file(self, filepath: str):
        """
        Adds file and parent directories to the dirtied_files dictionary.
        Emits file_dirtied_signal
        Args:
            filepath (str): Dirty file path

        """
        filename = self.filepath_to_filename(filepath)
        if not self.is_valid_file(filename):
            return

        sep = os.sep if os.sep in filepath else '/'
        for file in filepath.split(sep):

            if file in self.dirtied_files:
                self.dirtied_files[file].add(filename)
            else:
                self.dirtied_files[file] = {filename}

        # overwrite filename entry from above
        self.dirtied_files[filename] = {filepath}

        self.file_dirtied_signal.emit()

    def is_file_dirty(self, filepath: str) -> bool:
        """
        Checks whether file is dirty
        Args:
            filepath (str): File in question

        Returns:
            bool: Whether file is dirty

        """
        filename = self.filepath_to_filename(filepath)
        return filename in self.dirtied_files

    def filepath_to_filename(self, filepath: str) -> str:  # pylint: disable=R0201, no-self-use
        """
        Gets just the filename from the full filepath
        Args:
            filepath (str): Full file path

        Returns:
            str: Filename

        """

        # split on os.sep and / because PySide appears to sometimes use / on
        # certain Windows
        filename = filepath.split(os.sep)[-1].split('/')[-1]
        if '.py' in filename:
            return filename[:-len('.py')]
        return filename

    def setRootPath(self, path: str) -> QModelIndex:
        """
        Sets FileWatcher on root path and adds rootpath to model
        Args:
            path (str): Root path

        Returns:
            QModelIndex: Root index

        """

        for root, _, files in os.walk(path):
            # do NOT use directory changed -- fails for some reason
            for name in files:
                self.file_system_watcher.addPath(os.path.join(root, name))

        self.file_system_watcher.fileChanged.connect(self.alert_highlight_row)

        return super().setRootPath(path)

    def alert_highlight_row(self, filepath: str):
        """
        Dirties file and re-adds edited file to the FileWatcher
        Args:
            filepath (str): Dirty file


        """
        # ensure get only filename
        if filepath not in self.file_system_watcher.files():
            if os.path.exists(filepath):
                self.file_system_watcher.addPath(filepath)
        self.dirty_file(filepath)

    def headerData(self,
                   section: int,
                   orientation: Qt.Orientation,
                   role: int = ...) -> typing.Any:
        """ Set the headers to be displayed.

        Args:
            section (int): Section number
            orientation (Qt orientation): Section orientation
            role (Qt display role): Display role.  Defaults to DisplayRole.

        Returns:
            typing.Any: The header data, or None if not found
        """

        if role == Qt.DisplayRole:
            if not self.is_dev_mode and section == self.REBUILD:
                return ""

            if orientation == Qt.Horizontal:
                if section < len(self.columns):
                    return self.columns[section]

        elif role == Qt.FontRole:
            if section == 0:
                font = QFont()
                font.setBold(True)
                return font

        return super().headerData(section, orientation, role)

    def set_file_is_dev_mode(self, ison: bool):
        """
        Set dev_mode
        Args:
            ison(bool): Whether dev_mode is on

        """
        self.is_dev_mode = ison
Example #3
0
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()