1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2014 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing a dialog to browse the reflog history.
8"""
9
10import os
11
12from PyQt5.QtCore import pyqtSlot, Qt, QProcess, QTimer, QPoint
13from PyQt5.QtWidgets import (
14    QWidget, QDialogButtonBox, QHeaderView, QTreeWidgetItem, QApplication,
15    QLineEdit
16)
17
18from E5Gui import E5MessageBox
19from E5Gui.E5OverrideCursor import E5OverrideCursorProcess
20
21from .Ui_GitReflogBrowserDialog import Ui_GitReflogBrowserDialog
22
23import Preferences
24from Globals import strToQByteArray
25
26
27class GitReflogBrowserDialog(QWidget, Ui_GitReflogBrowserDialog):
28    """
29    Class implementing a dialog to browse the reflog history.
30    """
31    CommitIdColumn = 0
32    SelectorColumn = 1
33    NameColumn = 2
34    OperationColumn = 3
35    SubjectColumn = 4
36
37    def __init__(self, vcs, parent=None):
38        """
39        Constructor
40
41        @param vcs reference to the vcs object
42        @param parent reference to the parent widget (QWidget)
43        """
44        super().__init__(parent)
45        self.setupUi(self)
46
47        self.__position = QPoint()
48
49        self.buttonBox.button(
50            QDialogButtonBox.StandardButton.Close).setEnabled(False)
51        self.buttonBox.button(
52            QDialogButtonBox.StandardButton.Cancel).setDefault(True)
53
54        self.logTree.headerItem().setText(self.logTree.columnCount(), "")
55
56        self.refreshButton = self.buttonBox.addButton(
57            self.tr("&Refresh"), QDialogButtonBox.ButtonRole.ActionRole)
58        self.refreshButton.setToolTip(
59            self.tr("Press to refresh the list of commits"))
60        self.refreshButton.setEnabled(False)
61
62        self.vcs = vcs
63
64        self.__formatTemplate = (
65            'format:recordstart%n'
66            'commit|%h%n'
67            'selector|%gd%n'
68            'name|%gn%n'
69            'subject|%gs%n'
70            'recordend%n'
71        )
72
73        self.repodir = ""
74        self.__currentCommitId = ""
75
76        self.__initData()
77        self.__resetUI()
78
79        self.__process = E5OverrideCursorProcess()
80        self.__process.finished.connect(self.__procFinished)
81        self.__process.readyReadStandardOutput.connect(self.__readStdout)
82        self.__process.readyReadStandardError.connect(self.__readStderr)
83
84    def __initData(self):
85        """
86        Private method to (re-)initialize some data.
87        """
88        self.buf = []        # buffer for stdout
89        self.__started = False
90        self.__skipEntries = 0
91
92    def closeEvent(self, e):
93        """
94        Protected slot implementing a close event handler.
95
96        @param e close event (QCloseEvent)
97        """
98        if (
99            self.__process is not None and
100            self.__process.state() != QProcess.ProcessState.NotRunning
101        ):
102            self.__process.terminate()
103            QTimer.singleShot(2000, self.__process.kill)
104            self.__process.waitForFinished(3000)
105
106        self.__position = self.pos()
107
108        e.accept()
109
110    def show(self):
111        """
112        Public slot to show the dialog.
113        """
114        if not self.__position.isNull():
115            self.move(self.__position)
116        self.__resetUI()
117
118        super().show()
119
120    def __resetUI(self):
121        """
122        Private method to reset the user interface.
123        """
124        self.limitSpinBox.setValue(self.vcs.getPlugin().getPreferences(
125            "LogLimit"))
126
127        self.logTree.clear()
128
129    def __resizeColumnsLog(self):
130        """
131        Private method to resize the log tree columns.
132        """
133        self.logTree.header().resizeSections(
134            QHeaderView.ResizeMode.ResizeToContents)
135        self.logTree.header().setStretchLastSection(True)
136
137    def __generateReflogItem(self, commitId, selector, name, subject):
138        """
139        Private method to generate a reflog tree entry.
140
141        @param commitId commit id info (string)
142        @param selector selector info (string)
143        @param name name info (string)
144        @param subject subject of the reflog entry (string)
145        @return reference to the generated item (QTreeWidgetItem)
146        """
147        operation, subject = subject.strip().split(": ", 1)
148        columnLabels = [
149            commitId,
150            selector,
151            name,
152            operation,
153            subject,
154        ]
155        itm = QTreeWidgetItem(self.logTree, columnLabels)
156        return itm
157
158    def __getReflogEntries(self, skip=0):
159        """
160        Private method to retrieve reflog entries from the repository.
161
162        @param skip number of reflog entries to skip (integer)
163        """
164        self.refreshButton.setEnabled(False)
165        self.buttonBox.button(
166            QDialogButtonBox.StandardButton.Close).setEnabled(False)
167        self.buttonBox.button(
168            QDialogButtonBox.StandardButton.Cancel).setEnabled(True)
169        self.buttonBox.button(
170            QDialogButtonBox.StandardButton.Cancel).setDefault(True)
171        QApplication.processEvents()
172
173        self.buf = []
174        self.cancelled = False
175        self.errors.clear()
176        self.intercept = False
177
178        args = self.vcs.initCommand("log")
179        args.append("--walk-reflogs")
180        args.append('--max-count={0}'.format(self.limitSpinBox.value()))
181        args.append('--abbrev={0}'.format(
182            self.vcs.getPlugin().getPreferences("CommitIdLength")))
183        args.append('--format={0}'.format(self.__formatTemplate))
184        args.append('--skip={0}'.format(skip))
185
186        self.__process.kill()
187
188        self.__process.setWorkingDirectory(self.repodir)
189
190        self.inputGroup.setEnabled(True)
191        self.inputGroup.show()
192
193        self.__process.start('git', args)
194        procStarted = self.__process.waitForStarted(5000)
195        if not procStarted:
196            self.inputGroup.setEnabled(False)
197            self.inputGroup.hide()
198            E5MessageBox.critical(
199                self,
200                self.tr('Process Generation Error'),
201                self.tr(
202                    'The process {0} could not be started. '
203                    'Ensure, that it is in the search path.'
204                ).format('git'))
205
206    def start(self, projectdir):
207        """
208        Public slot to start the git log command.
209
210        @param projectdir directory name of the project (string)
211        """
212        self.errorGroup.hide()
213        QApplication.processEvents()
214
215        self.__initData()
216
217        # find the root of the repo
218        self.repodir = projectdir
219        while not os.path.isdir(os.path.join(self.repodir, self.vcs.adminDir)):
220            self.repodir = os.path.dirname(self.repodir)
221            if os.path.splitdrive(self.repodir)[1] == os.sep:
222                return
223
224        self.activateWindow()
225        self.raise_()
226
227        self.logTree.clear()
228        self.__started = True
229        self.__getReflogEntries()
230
231    def __procFinished(self, exitCode, exitStatus):
232        """
233        Private slot connected to the finished signal.
234
235        @param exitCode exit code of the process (integer)
236        @param exitStatus exit status of the process (QProcess.ExitStatus)
237        """
238        self.__processBuffer()
239        self.__finish()
240
241    def __finish(self):
242        """
243        Private slot called when the process finished or the user pressed
244        the button.
245        """
246        if (
247            self.__process is not None and
248            self.__process.state() != QProcess.ProcessState.NotRunning
249        ):
250            self.__process.terminate()
251            QTimer.singleShot(2000, self.__process.kill)
252            self.__process.waitForFinished(3000)
253
254        self.buttonBox.button(
255            QDialogButtonBox.StandardButton.Close).setEnabled(True)
256        self.buttonBox.button(
257            QDialogButtonBox.StandardButton.Cancel).setEnabled(False)
258        self.buttonBox.button(
259            QDialogButtonBox.StandardButton.Close).setDefault(True)
260
261        self.inputGroup.setEnabled(False)
262        self.inputGroup.hide()
263        self.refreshButton.setEnabled(True)
264
265    def __processBuffer(self):
266        """
267        Private method to process the buffered output of the git log command.
268        """
269        noEntries = 0
270
271        for line in self.buf:
272            line = line.rstrip()
273            if line == "recordstart":
274                logEntry = {}
275            elif line == "recordend":
276                if len(logEntry) > 1:
277                    self.__generateReflogItem(
278                        logEntry["commit"], logEntry["selector"],
279                        logEntry["name"], logEntry["subject"],
280                    )
281                    noEntries += 1
282            else:
283                try:
284                    key, value = line.split("|", 1)
285                except ValueError:
286                    key = ""
287                    value = line
288                if key in ("commit", "selector", "name", "subject"):
289                    logEntry[key] = value.strip()
290
291        self.__resizeColumnsLog()
292
293        if self.__started:
294            self.logTree.setCurrentItem(self.logTree.topLevelItem(0))
295            self.__started = False
296
297        self.__skipEntries += noEntries
298        if noEntries < self.limitSpinBox.value() and not self.cancelled:
299            self.nextButton.setEnabled(False)
300            self.limitSpinBox.setEnabled(False)
301        else:
302            self.nextButton.setEnabled(True)
303            self.limitSpinBox.setEnabled(True)
304
305        # restore current item
306        if self.__currentCommitId:
307            items = self.logTree.findItems(
308                self.__currentCommitId, Qt.MatchFlag.MatchExactly,
309                self.CommitIdColumn)
310            if items:
311                self.logTree.setCurrentItem(items[0])
312                self.__currentCommitId = ""
313
314    def __readStdout(self):
315        """
316        Private slot to handle the readyReadStandardOutput signal.
317
318        It reads the output of the process and inserts it into a buffer.
319        """
320        self.__process.setReadChannel(QProcess.ProcessChannel.StandardOutput)
321
322        while self.__process.canReadLine():
323            line = str(self.__process.readLine(),
324                       Preferences.getSystem("IOEncoding"),
325                       'replace')
326            self.buf.append(line)
327
328    def __readStderr(self):
329        """
330        Private slot to handle the readyReadStandardError signal.
331
332        It reads the error output of the process and inserts it into the
333        error pane.
334        """
335        if self.__process is not None:
336            s = str(self.__process.readAllStandardError(),
337                    Preferences.getSystem("IOEncoding"),
338                    'replace')
339            self.__showError(s)
340
341    def __showError(self, out):
342        """
343        Private slot to show some error.
344
345        @param out error to be shown (string)
346        """
347        self.errorGroup.show()
348        self.errors.insertPlainText(out)
349        self.errors.ensureCursorVisible()
350
351    def on_buttonBox_clicked(self, button):
352        """
353        Private slot called by a button of the button box clicked.
354
355        @param button button that was clicked (QAbstractButton)
356        """
357        if button == self.buttonBox.button(
358            QDialogButtonBox.StandardButton.Close
359        ):
360            self.close()
361        elif button == self.buttonBox.button(
362            QDialogButtonBox.StandardButton.Cancel
363        ):
364            self.cancelled = True
365            self.__finish()
366        elif button == self.refreshButton:
367            self.on_refreshButton_clicked()
368
369    @pyqtSlot()
370    def on_refreshButton_clicked(self):
371        """
372        Private slot to refresh the log.
373        """
374        # save the current item's commit ID
375        itm = self.logTree.currentItem()
376        if itm is not None:
377            self.__currentCommitId = itm.text(self.CommitIdColumn)
378        else:
379            self.__currentCommitId = ""
380
381        self.start(self.repodir)
382
383    def on_passwordCheckBox_toggled(self, isOn):
384        """
385        Private slot to handle the password checkbox toggled.
386
387        @param isOn flag indicating the status of the check box (boolean)
388        """
389        if isOn:
390            self.input.setEchoMode(QLineEdit.EchoMode.Password)
391        else:
392            self.input.setEchoMode(QLineEdit.EchoMode.Normal)
393
394    @pyqtSlot()
395    def on_sendButton_clicked(self):
396        """
397        Private slot to send the input to the git process.
398        """
399        inputTxt = self.input.text()
400        inputTxt += os.linesep
401
402        if self.passwordCheckBox.isChecked():
403            self.errors.insertPlainText(os.linesep)
404            self.errors.ensureCursorVisible()
405        else:
406            self.errors.insertPlainText(inputTxt)
407            self.errors.ensureCursorVisible()
408        self.errorGroup.show()
409
410        self.__process.write(strToQByteArray(inputTxt))
411
412        self.passwordCheckBox.setChecked(False)
413        self.input.clear()
414
415    def on_input_returnPressed(self):
416        """
417        Private slot to handle the press of the return key in the input field.
418        """
419        self.intercept = True
420        self.on_sendButton_clicked()
421
422    def keyPressEvent(self, evt):
423        """
424        Protected slot to handle a key press event.
425
426        @param evt the key press event (QKeyEvent)
427        """
428        if self.intercept:
429            self.intercept = False
430            evt.accept()
431            return
432        super().keyPressEvent(evt)
433
434    @pyqtSlot()
435    def on_nextButton_clicked(self):
436        """
437        Private slot to handle the Next button.
438        """
439        if self.__skipEntries > 0:
440            self.__getReflogEntries(self.__skipEntries)
441