eric6/Helpviewer/Download/DownloadItem.py

changeset 6942
2602857055c5
parent 6645
ad476851d7e0
equal deleted inserted replaced
6941:f99d60d6b59b 6942:2602857055c5
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2010 - 2019 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a widget controlling a download.
8 """
9
10 from __future__ import unicode_literals
11 try:
12 str = unicode
13 except NameError:
14 pass
15
16 from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QTime, QFile, QFileInfo, \
17 QUrl, QIODevice, QCryptographicHash, PYQT_VERSION_STR, QDateTime
18 from PyQt5.QtGui import QPalette, QDesktopServices
19 from PyQt5.QtWidgets import QWidget, QStyle, QDialog
20 from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
21
22 from E5Gui import E5FileDialog
23
24 from .Ui_DownloadItem import Ui_DownloadItem
25
26 from .DownloadUtilities import timeString, dataString
27 from ..HelpUtilities import parseContentDisposition
28
29 import UI.PixmapCache
30 import Preferences
31
32
33 class DownloadItem(QWidget, Ui_DownloadItem):
34 """
35 Class implementing a widget controlling a download.
36
37 @signal statusChanged() emitted upon a status change of a download
38 @signal downloadFinished() emitted when a download finished
39 @signal progress(int, int) emitted to signal the download progress
40 """
41 statusChanged = pyqtSignal()
42 downloadFinished = pyqtSignal()
43 progress = pyqtSignal(int, int)
44
45 Downloading = 0
46 DownloadSuccessful = 1
47 DownloadCancelled = 2
48
49 def __init__(self, reply=None, requestFilename=False, webPage=None,
50 download=False, parent=None, mainWindow=None):
51 """
52 Constructor
53
54 @keyparam reply reference to the network reply object (QNetworkReply)
55 @keyparam requestFilename flag indicating to ask the user for a
56 filename (boolean)
57 @keyparam webPage reference to the web page object the download
58 originated from (QWebPage)
59 @keyparam download flag indicating a download operation (boolean)
60 @keyparam parent reference to the parent widget (QWidget)
61 @keyparam mainWindow reference to the main window (HelpWindow)
62 """
63 super(DownloadItem, self).__init__(parent)
64 self.setupUi(self)
65
66 p = self.infoLabel.palette()
67 p.setColor(QPalette.Text, Qt.darkGray)
68 self.infoLabel.setPalette(p)
69
70 self.progressBar.setMaximum(0)
71
72 self.__isFtpDownload = reply is not None and \
73 reply.url().scheme() == "ftp"
74
75 self.tryAgainButton.setIcon(UI.PixmapCache.getIcon("restart.png"))
76 self.tryAgainButton.setEnabled(False)
77 self.tryAgainButton.setVisible(False)
78 self.stopButton.setIcon(UI.PixmapCache.getIcon("stopLoading.png"))
79 self.pauseButton.setIcon(UI.PixmapCache.getIcon("pause.png"))
80 self.openButton.setIcon(UI.PixmapCache.getIcon("open.png"))
81 self.openButton.setEnabled(False)
82 self.openButton.setVisible(False)
83 if self.__isFtpDownload:
84 self.stopButton.setEnabled(False)
85 self.stopButton.setVisible(False)
86 self.pauseButton.setEnabled(False)
87 self.pauseButton.setVisible(False)
88
89 self.__state = DownloadItem.Downloading
90
91 icon = self.style().standardIcon(QStyle.SP_FileIcon)
92 self.fileIcon.setPixmap(icon.pixmap(48, 48))
93
94 self.__mainWindow = mainWindow
95 self.__reply = reply
96 self.__requestFilename = requestFilename
97 self.__page = webPage
98 self.__pageUrl = webPage and webPage.mainFrame().url() or QUrl()
99 self.__toDownload = download
100 self.__bytesReceived = 0
101 self.__bytesTotal = -1
102 self.__downloadTime = QTime()
103 self.__output = QFile()
104 self.__fileName = ""
105 self.__originalFileName = ""
106 self.__startedSaving = False
107 self.__finishedDownloading = False
108 self.__gettingFileName = False
109 self.__canceledFileSelect = False
110 self.__autoOpen = False
111 self.__downloadedDateTime = QDateTime()
112
113 self.__sha1Hash = QCryptographicHash(QCryptographicHash.Sha1)
114 self.__md5Hash = QCryptographicHash(QCryptographicHash.Md5)
115
116 if not requestFilename:
117 self.__requestFilename = \
118 Preferences.getUI("RequestDownloadFilename")
119
120 self.__initialize()
121
122 def __initialize(self, tryAgain=False):
123 """
124 Private method to (re)initialize the widget.
125
126 @param tryAgain flag indicating a retry (boolean)
127 """
128 if self.__reply is None:
129 return
130
131 self.__startedSaving = False
132 self.__finishedDownloading = False
133 self.__bytesReceived = 0
134 self.__bytesTotal = -1
135
136 self.__sha1Hash.reset()
137 self.__md5Hash.reset()
138
139 # start timer for the download estimation
140 self.__downloadTime.start()
141
142 # attach to the reply object
143 self.__url = self.__reply.url()
144 self.__reply.setParent(self)
145 self.__reply.setReadBufferSize(16 * 1024 * 1024)
146 self.__reply.readyRead.connect(self.__readyRead)
147 self.__reply.error.connect(self.__networkError)
148 self.__reply.downloadProgress.connect(self.__downloadProgress)
149 self.__reply.metaDataChanged.connect(self.__metaDataChanged)
150 self.__reply.finished.connect(self.__finished)
151
152 # reset info
153 self.datetimeLabel.clear()
154 self.datetimeLabel.hide()
155 self.infoLabel.clear()
156 self.progressBar.setValue(0)
157 self.__getFileName()
158
159 if self.__reply.error() != QNetworkReply.NoError:
160 self.__networkError()
161 self.__finished()
162
163 def __getFileName(self):
164 """
165 Private method to get the file name to save to from the user.
166 """
167 if self.__gettingFileName:
168 return
169
170 import Helpviewer.HelpWindow
171 downloadDirectory = Helpviewer.HelpWindow.HelpWindow\
172 .downloadManager().downloadDirectory()
173
174 if self.__fileName:
175 fileName = self.__fileName
176 originalFileName = self.__originalFileName
177 self.__toDownload = True
178 ask = False
179 else:
180 defaultFileName, originalFileName = \
181 self.__saveFileName(downloadDirectory)
182 fileName = defaultFileName
183 self.__originalFileName = originalFileName
184 ask = True
185 self.__autoOpen = False
186 if not self.__toDownload:
187 from .DownloadAskActionDialog import DownloadAskActionDialog
188 url = self.__reply.url()
189 dlg = DownloadAskActionDialog(
190 QFileInfo(originalFileName).fileName(),
191 self.__reply.header(QNetworkRequest.ContentTypeHeader),
192 "{0}://{1}".format(url.scheme(), url.authority()),
193 self)
194 if dlg.exec_() == QDialog.Rejected or dlg.getAction() == "cancel":
195 self.progressBar.setVisible(False)
196 self.__reply.close()
197 self.on_stopButton_clicked()
198 self.filenameLabel.setText(
199 self.tr("Download canceled: {0}").format(
200 QFileInfo(defaultFileName).fileName()))
201 self.__canceledFileSelect = True
202 self.__setDateTime()
203 return
204
205 if dlg.getAction() == "scan":
206 self.__mainWindow.requestVirusTotalScan(url)
207
208 self.progressBar.setVisible(False)
209 self.__reply.close()
210 self.on_stopButton_clicked()
211 self.filenameLabel.setText(
212 self.tr("VirusTotal scan scheduled: {0}").format(
213 QFileInfo(defaultFileName).fileName()))
214 self.__canceledFileSelect = True
215 return
216
217 self.__autoOpen = dlg.getAction() == "open"
218 if PYQT_VERSION_STR >= "5.0.0":
219 from PyQt5.QtCore import QStandardPaths
220 tempLocation = QStandardPaths.standardLocations(
221 QStandardPaths.TempLocation)[0]
222 else:
223 from PyQt5.QtGui import QDesktopServices
224 tempLocation = QDesktopServices.storageLocation(
225 QDesktopServices.TempLocation)
226 fileName = tempLocation + '/' + \
227 QFileInfo(fileName).completeBaseName()
228
229 if ask and not self.__autoOpen and self.__requestFilename:
230 self.__gettingFileName = True
231 fileName = E5FileDialog.getSaveFileName(
232 None,
233 self.tr("Save File"),
234 defaultFileName,
235 "")
236 self.__gettingFileName = False
237 if not fileName:
238 self.progressBar.setVisible(False)
239 self.__reply.close()
240 self.on_stopButton_clicked()
241 self.filenameLabel.setText(
242 self.tr("Download canceled: {0}")
243 .format(QFileInfo(defaultFileName).fileName()))
244 self.__canceledFileSelect = True
245 self.__setDateTime()
246 return
247
248 fileInfo = QFileInfo(fileName)
249 Helpviewer.HelpWindow.HelpWindow.downloadManager()\
250 .setDownloadDirectory(fileInfo.absoluteDir().absolutePath())
251 self.filenameLabel.setText(fileInfo.fileName())
252
253 self.__output.setFileName(fileName + ".part")
254 self.__fileName = fileName
255
256 # check file path for saving
257 saveDirPath = QFileInfo(self.__fileName).dir()
258 if not saveDirPath.exists():
259 if not saveDirPath.mkpath(saveDirPath.absolutePath()):
260 self.progressBar.setVisible(False)
261 self.on_stopButton_clicked()
262 self.infoLabel.setText(self.tr(
263 "Download directory ({0}) couldn't be created.")
264 .format(saveDirPath.absolutePath()))
265 self.__setDateTime()
266 return
267
268 self.filenameLabel.setText(QFileInfo(self.__fileName).fileName())
269 if self.__requestFilename:
270 self.__readyRead()
271
272 def __saveFileName(self, directory):
273 """
274 Private method to calculate a name for the file to download.
275
276 @param directory name of the directory to store the file into (string)
277 @return proposed filename and original filename (string, string)
278 """
279 path = parseContentDisposition(self.__reply)
280 info = QFileInfo(path)
281 baseName = info.completeBaseName()
282 endName = info.suffix()
283
284 origName = baseName
285 if endName:
286 origName += '.' + endName
287
288 name = directory + baseName
289 if endName:
290 name += '.' + endName
291 if not self.__requestFilename:
292 # do not overwrite, if the user is not being asked
293 i = 1
294 while QFile.exists(name):
295 # file exists already, don't overwrite
296 name = directory + baseName + ('-{0:d}'.format(i))
297 if endName:
298 name += '.' + endName
299 i += 1
300 return name, origName
301
302 @pyqtSlot()
303 def on_tryAgainButton_clicked(self):
304 """
305 Private slot to retry the download.
306 """
307 self.retry()
308
309 def retry(self):
310 """
311 Public slot to retry the download.
312 """
313 if not self.tryAgainButton.isEnabled():
314 return
315
316 self.tryAgainButton.setEnabled(False)
317 self.tryAgainButton.setVisible(False)
318 self.openButton.setEnabled(False)
319 self.openButton.setVisible(False)
320 if not self.__isFtpDownload:
321 self.stopButton.setEnabled(True)
322 self.stopButton.setVisible(True)
323 self.pauseButton.setEnabled(True)
324 self.pauseButton.setVisible(True)
325 self.progressBar.setVisible(True)
326
327 if self.__page:
328 nam = self.__page.networkAccessManager()
329 else:
330 import Helpviewer.HelpWindow
331 nam = Helpviewer.HelpWindow.HelpWindow.networkAccessManager()
332 reply = nam.get(QNetworkRequest(self.__url))
333 if self.__output.exists():
334 self.__output.remove()
335 self.__output = QFile()
336 self.__reply = reply
337 self.__initialize(tryAgain=True)
338 self.__state = DownloadItem.Downloading
339 self.statusChanged.emit()
340
341 @pyqtSlot(bool)
342 def on_pauseButton_clicked(self, checked):
343 """
344 Private slot to pause the download.
345
346 @param checked flag indicating the state of the button (boolean)
347 """
348 if checked:
349 self.__reply.readyRead.disconnect(self.__readyRead)
350 self.__reply.setReadBufferSize(16 * 1024)
351 else:
352 self.__reply.readyRead.connect(self.__readyRead)
353 self.__reply.setReadBufferSize(16 * 1024 * 1024)
354 self.__readyRead()
355
356 @pyqtSlot()
357 def on_stopButton_clicked(self):
358 """
359 Private slot to stop the download.
360 """
361 self.cancelDownload()
362
363 def cancelDownload(self):
364 """
365 Public slot to stop the download.
366 """
367 self.setUpdatesEnabled(False)
368 if not self.__isFtpDownload:
369 self.stopButton.setEnabled(False)
370 self.stopButton.setVisible(False)
371 self.pauseButton.setEnabled(False)
372 self.pauseButton.setVisible(False)
373 self.tryAgainButton.setEnabled(True)
374 self.tryAgainButton.setVisible(True)
375 self.openButton.setEnabled(False)
376 self.openButton.setVisible(False)
377 self.setUpdatesEnabled(True)
378 self.__state = DownloadItem.DownloadCancelled
379 self.__reply.abort()
380 self.__setDateTime()
381 self.downloadFinished.emit()
382
383 @pyqtSlot()
384 def on_openButton_clicked(self):
385 """
386 Private slot to open the downloaded file.
387 """
388 self.openFile()
389
390 def openFile(self):
391 """
392 Public slot to open the downloaded file.
393 """
394 info = QFileInfo(self.__fileName)
395 url = QUrl.fromLocalFile(info.absoluteFilePath())
396 QDesktopServices.openUrl(url)
397
398 def openFolder(self):
399 """
400 Public slot to open the folder containing the downloaded file.
401 """
402 info = QFileInfo(self.__fileName)
403 url = QUrl.fromLocalFile(info.absolutePath())
404 QDesktopServices.openUrl(url)
405
406 def __readyRead(self):
407 """
408 Private slot to read the available data.
409 """
410 if self.__requestFilename and not self.__output.fileName():
411 return
412
413 if not self.__output.isOpen():
414 # in case someone else has already put a file there
415 if not self.__requestFilename:
416 self.__getFileName()
417 if not self.__output.open(QIODevice.WriteOnly):
418 self.infoLabel.setText(
419 self.tr("Error opening save file: {0}")
420 .format(self.__output.errorString()))
421 self.on_stopButton_clicked()
422 self.statusChanged.emit()
423 return
424 self.statusChanged.emit()
425
426 buffer = self.__reply.readAll()
427 self.__sha1Hash.addData(buffer)
428 self.__md5Hash.addData(buffer)
429 bytesWritten = self.__output.write(buffer)
430 if bytesWritten == -1:
431 self.infoLabel.setText(
432 self.tr("Error saving: {0}")
433 .format(self.__output.errorString()))
434 self.on_stopButton_clicked()
435 else:
436 self.__startedSaving = True
437 if self.__finishedDownloading:
438 self.__finished()
439
440 def __networkError(self):
441 """
442 Private slot to handle a network error.
443 """
444 self.infoLabel.setText(
445 self.tr("Network Error: {0}")
446 .format(self.__reply.errorString()))
447 self.tryAgainButton.setEnabled(True)
448 self.tryAgainButton.setVisible(True)
449 self.downloadFinished.emit()
450
451 def __metaDataChanged(self):
452 """
453 Private slot to handle a change of the meta data.
454 """
455 locationHeader = self.__reply.header(QNetworkRequest.LocationHeader)
456 if locationHeader and locationHeader.isValid():
457 self.__url = QUrl(locationHeader)
458 import Helpviewer.HelpWindow
459 self.__reply = Helpviewer.HelpWindow.HelpWindow\
460 .networkAccessManager().get(QNetworkRequest(self.__url))
461 self.__initialize()
462
463 def __downloadProgress(self, bytesReceived, bytesTotal):
464 """
465 Private method to show the download progress.
466
467 @param bytesReceived number of bytes received (integer)
468 @param bytesTotal number of total bytes (integer)
469 """
470 self.__bytesReceived = bytesReceived
471 self.__bytesTotal = bytesTotal
472 currentValue = 0
473 totalValue = 0
474 if bytesTotal > 0:
475 currentValue = bytesReceived * 100 / bytesTotal
476 totalValue = 100
477 self.progressBar.setValue(currentValue)
478 self.progressBar.setMaximum(totalValue)
479
480 self.progress.emit(currentValue, totalValue)
481 self.__updateInfoLabel()
482
483 def bytesTotal(self):
484 """
485 Public method to get the total number of bytes of the download.
486
487 @return total number of bytes (integer)
488 """
489 if self.__bytesTotal == -1:
490 self.__bytesTotal = self.__reply.header(
491 QNetworkRequest.ContentLengthHeader)
492 if self.__bytesTotal is None:
493 self.__bytesTotal = -1
494 return self.__bytesTotal
495
496 def bytesReceived(self):
497 """
498 Public method to get the number of bytes received.
499
500 @return number of bytes received (integer)
501 """
502 return self.__bytesReceived
503
504 def remainingTime(self):
505 """
506 Public method to get an estimation for the remaining time.
507
508 @return estimation for the remaining time (float)
509 """
510 if not self.downloading():
511 return -1.0
512
513 if self.bytesTotal() == -1:
514 return -1.0
515
516 cSpeed = self.currentSpeed()
517 if cSpeed != 0:
518 timeRemaining = (self.bytesTotal() - self.bytesReceived()) / cSpeed
519 else:
520 timeRemaining = 1
521
522 # ETA should never be 0
523 if timeRemaining == 0:
524 timeRemaining = 1
525
526 return timeRemaining
527
528 def currentSpeed(self):
529 """
530 Public method to get an estimation for the download speed.
531
532 @return estimation for the download speed (float)
533 """
534 if not self.downloading():
535 return -1.0
536
537 return self.__bytesReceived * 1000.0 / self.__downloadTime.elapsed()
538
539 def __updateInfoLabel(self):
540 """
541 Private method to update the info label.
542 """
543 if self.__reply.error() != QNetworkReply.NoError:
544 return
545
546 bytesTotal = self.bytesTotal()
547 running = not self.downloadedSuccessfully()
548
549 speed = self.currentSpeed()
550 timeRemaining = self.remainingTime()
551
552 info = ""
553 if running:
554 remaining = ""
555
556 if bytesTotal > 0:
557 remaining = timeString(timeRemaining)
558
559 info = self.tr("{0} of {1} ({2}/sec)\n{3}")\
560 .format(
561 dataString(self.__bytesReceived),
562 bytesTotal == -1 and self.tr("?") or
563 dataString(bytesTotal),
564 dataString(int(speed)),
565 remaining)
566 else:
567 if self.__bytesReceived == bytesTotal or bytesTotal == -1:
568 info = self.tr("{0} downloaded\nSHA1: {1}\nMD5: {2}")\
569 .format(dataString(self.__output.size()),
570 str(self.__sha1Hash.result().toHex(),
571 encoding="ascii"),
572 str(self.__md5Hash.result().toHex(),
573 encoding="ascii")
574 )
575 else:
576 info = self.tr("{0} of {1} - Stopped")\
577 .format(dataString(self.__bytesReceived),
578 dataString(bytesTotal))
579 self.infoLabel.setText(info)
580
581 def downloading(self):
582 """
583 Public method to determine, if a download is in progress.
584
585 @return flag indicating a download is in progress (boolean)
586 """
587 return self.__state == DownloadItem.Downloading
588
589 def downloadedSuccessfully(self):
590 """
591 Public method to check for a successful download.
592
593 @return flag indicating a successful download (boolean)
594 """
595 return self.__state == DownloadItem.DownloadSuccessful
596
597 def downloadCanceled(self):
598 """
599 Public method to check, if the download was cancelled.
600
601 @return flag indicating a canceled download (boolean)
602 """
603 return self.__state == DownloadItem.DownloadCancelled
604
605 def __finished(self):
606 """
607 Private slot to handle the download finished.
608 """
609 self.__finishedDownloading = True
610 if not self.__startedSaving:
611 return
612
613 noError = self.__reply.error() == QNetworkReply.NoError
614
615 self.progressBar.setVisible(False)
616 if not self.__isFtpDownload:
617 self.stopButton.setEnabled(False)
618 self.stopButton.setVisible(False)
619 self.pauseButton.setEnabled(False)
620 self.pauseButton.setVisible(False)
621 self.openButton.setEnabled(noError)
622 self.openButton.setVisible(noError)
623 self.__output.close()
624 if QFile.exists(self.__fileName):
625 QFile.remove(self.__fileName)
626 self.__output.rename(self.__fileName)
627 self.__state = DownloadItem.DownloadSuccessful
628 self.__updateInfoLabel()
629 self.__setDateTime()
630
631 self.__adjustSize()
632
633 self.statusChanged.emit()
634 self.downloadFinished.emit()
635
636 if self.__autoOpen:
637 self.openFile()
638
639 def canceledFileSelect(self):
640 """
641 Public method to check, if the user canceled the file selection.
642
643 @return flag indicating cancellation (boolean)
644 """
645 return self.__canceledFileSelect
646
647 def setIcon(self, icon):
648 """
649 Public method to set the download icon.
650
651 @param icon reference to the icon to be set (QIcon)
652 """
653 self.fileIcon.setPixmap(icon.pixmap(48, 48))
654
655 def fileName(self):
656 """
657 Public method to get the name of the output file.
658
659 @return name of the output file (string)
660 """
661 return self.__fileName
662
663 def absoluteFilePath(self):
664 """
665 Public method to get the absolute path of the output file.
666
667 @return absolute path of the output file (string)
668 """
669 return QFileInfo(self.__fileName).absoluteFilePath()
670
671 def getData(self):
672 """
673 Public method to get the relevant download data.
674
675 @return tuple of URL, save location, flag, the
676 URL of the related web page and the date and time
677 of the download
678 @rtype tuple of (QUrl, str, bool, QUrl, QDateTime)
679 """
680 return (self.__url, QFileInfo(self.__fileName).filePath(),
681 self.downloadedSuccessfully(), self.__pageUrl,
682 self.__downloadedDateTime)
683
684 def setData(self, data):
685 """
686 Public method to set the relevant download data.
687
688 @param data tuple of URL, save location, flag, the
689 URL of the related web page and the date and time
690 of the download
691 @type QUrl, str, bool, QUrl, QDateTime
692 """
693 self.__url = data[0]
694 self.__fileName = data[1]
695 self.__pageUrl = data[3]
696 self.__isFtpDownload = self.__url.scheme() == "ftp"
697
698 self.filenameLabel.setText(QFileInfo(self.__fileName).fileName())
699 self.infoLabel.setText(self.__fileName)
700
701 if len(data) == 5:
702 self.__setDateTime(data[4])
703 else:
704 self.__setDateTime(QDateTime())
705
706 self.stopButton.setEnabled(False)
707 self.stopButton.setVisible(False)
708 self.pauseButton.setEnabled(False)
709 self.pauseButton.setVisible(False)
710 self.openButton.setEnabled(data[2])
711 self.openButton.setVisible(data[2])
712 self.tryAgainButton.setEnabled(not data[2])
713 self.tryAgainButton.setVisible(not data[2])
714 if data[2]:
715 self.__state = DownloadItem.DownloadSuccessful
716 else:
717 self.__state = DownloadItem.DownloadCancelled
718 self.progressBar.setVisible(False)
719
720 self.__adjustSize()
721
722 def getInfoData(self):
723 """
724 Public method to get the text of the info label.
725
726 @return text of the info label (string)
727 """
728 return self.infoLabel.text()
729
730 def getPageUrl(self):
731 """
732 Public method to get the URL of the download page.
733
734 @return URL of the download page (QUrl)
735 """
736 return self.__pageUrl
737
738 def __adjustSize(self):
739 """
740 Private method to adjust the size of the download item.
741 """
742 self.ensurePolished()
743
744 msh = self.minimumSizeHint()
745 self.resize(max(self.width(), msh.width()), msh.height())
746
747 def __setDateTime(self, dateTime=None):
748 """
749 Private method to set the download date and time.
750
751 @param dateTime date and time to be set
752 @type QDateTime
753 """
754 if dateTime is None:
755 self.__downloadedDateTime = QDateTime.currentDateTime()
756 else:
757 self.__downloadedDateTime = dateTime
758 if self.__downloadedDateTime.isValid():
759 labelText = self.__downloadedDateTime.toString("yyyy-MM-dd hh:mm")
760 self.datetimeLabel.setText(labelText)
761 self.datetimeLabel.show()
762 else:
763 self.datetimeLabel.clear()
764 self.datetimeLabel.hide()

eric ide

mercurial