Browse Source

Merge remote-tracking branch 'origin/pr/258'

* origin/pr/258:
  qvm-template-gui: auto resize columns to the content
  qvm-template-gui: change date format to '%d %b %Y'
  qvm-template-gui: UX improvements
  Make pylint happy
  qvm-template-gui: improve displaying progress
  qvm-template-gui: UI improvements
  qvm-template: Include files in deb/rpm package
  qvm-template: Change size unit from kB to MB.
  qvm-template: Disable multi-selection for simplicity.
  qvm-template: Port initial code to PyQt.
  qvm-template: Initial GUI implementation.
Marek Marczykowski-Górecki 3 years ago
parent
commit
b2c40eb578

+ 5 - 0
debian/install

@@ -7,6 +7,7 @@
 /usr/bin/qubes-qube-manager
 /usr/bin/qubes-log-viewer
 /usr/bin/qubes-template-manager
+/usr/bin/qvm-template-gui
 /usr/bin/qubes-vm-clone
 /usr/libexec/qubes-manager/mount_for_backup.sh
 /usr/libexec/qubes-manager/qvm_about.sh
@@ -34,6 +35,7 @@
 /usr/lib/*/dist-packages/qubesmanager/bootfromdevice.py
 /usr/lib/*/dist-packages/qubesmanager/device_list.py
 /usr/lib/*/dist-packages/qubesmanager/template_manager.py
+/usr/lib/*/dist-packages/qubesmanager/qvm_template_gui.py
 /usr/lib/*/dist-packages/qubesmanager/clone_vm.py
 
 /usr/lib/*/dist-packages/qubesmanager/resources_rc.py
@@ -54,6 +56,9 @@
 /usr/lib/*/dist-packages/qubesmanager/ui_devicelist.py
 /usr/lib/*/dist-packages/qubesmanager/ui_templatemanager.py
 /usr/lib/*/dist-packages/qubesmanager/ui_clonevmdlg.py
+/usr/lib/*/dist-packages/qubesmanager/ui_qvmtemplate.py
+/usr/lib/*/dist-packages/qubesmanager/ui_templateinstallconfirmdlg.py
+/usr/lib/*/dist-packages/qubesmanager/ui_templateinstallprogressdlg.py
 /usr/lib/*/dist-packages/qubesmanager/i18n/qubesmanager_*.qm
 /usr/lib/*/dist-packages/qubesmanager/i18n/qubesmanager_*.ts
 

+ 446 - 0
qubesmanager/qvm_template_gui.py

@@ -0,0 +1,446 @@
+import asyncio
+import collections
+from datetime import datetime
+import itertools
+import json
+import os
+import typing
+
+import PyQt5  # pylint: disable=import-error
+import PyQt5.QtWidgets  # pylint: disable=import-error
+
+from . import ui_qvmtemplate  # pylint: disable=no-name-in-module
+from . import ui_templateinstallconfirmdlg  # pylint: disable=no-name-in-module
+from . import ui_templateinstallprogressdlg  # pylint: disable=no-name-in-module
+from . import utils
+
+#pylint: disable=invalid-name
+
+BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes']
+
+# singleton for "no date"
+ZERO_DATE = datetime.utcfromtimestamp(0)
+
+# pylint: disable=too-few-public-methods,inherit-non-class
+class Template(typing.NamedTuple):
+    status: str
+    name: str
+    evr: str
+    reponame: str
+    size: int
+    buildtime: datetime
+    installtime: typing.Optional[datetime]
+    #licence: str
+    #url: str
+    #summary: str
+    # ---- internal ----
+    description: str
+    default_status: str
+    # ------------------
+
+    COL_NAMES = [
+        'Status',
+        'Name',
+        'Version',
+        'Repository',
+        'Download Size (MB)',
+        'Build',
+        'Install',
+        #'License',
+        #'URL',
+        #'Summary'
+    ]
+
+    @staticmethod
+    def build(status, entry):
+        cli_format = '%Y-%m-%d %H:%M:%S'
+        buildtime = datetime.strptime(entry['buildtime'], cli_format)
+        if entry['installtime']:
+            installtime = datetime.strptime(entry['installtime'], cli_format)
+        else:
+            installtime = ZERO_DATE
+        return Template(
+            status,
+            entry['name'],
+            '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
+            entry['reponame'],
+            int(entry['size']) // 1000000,
+            buildtime,
+            installtime,
+            #entry['license'],
+            #entry['url'],
+            entry['description'],
+            status
+        )
+
+class Action(typing.NamedTuple):
+    op: str
+    name: str
+    evr: str
+
+    TYPES = [str, str, str]
+    COL_NAMES = ['Operation', 'Name', 'Version']
+
+class TemplateStatusDelegate(PyQt5.QtWidgets.QStyledItemDelegate):
+    OPS = [
+        ['Installed', 'Reinstall', 'Remove'],
+        ['Extra', 'Remove'],
+        ['Upgradable', 'Upgrade', 'Remove'],
+        ['Downgradable', 'Downgrade', 'Remove'],
+        ['Available', 'Install']
+    ]
+
+    def createEditor(self, parent, option, index):
+        _ = option # unused
+        editor = PyQt5.QtWidgets.QComboBox(parent)
+        # Otherwise the internalPointer can be overwritten with a QComboBox
+        index = index.model().index(index.row(), index.column())
+        kind = index.internalPointer().default_status
+        for op_list in TemplateStatusDelegate.OPS:
+            if op_list[0] == kind:
+                for op in op_list:
+                    editor.addItem(op)
+                editor.currentIndexChanged.connect(self.currentIndexChanged)
+                editor.showPopup()
+                return editor
+        return None
+
+    def setEditorData(self, editor, index):
+        #pylint: disable=no-self-use
+        cur = index.data()
+        idx = editor.findText(cur)
+        if idx >= 0:
+            editor.setCurrentIndex(idx)
+
+    def setModelData(self, editor, model, index):
+        #pylint: disable=no-self-use
+        model.setData(index, editor.currentText())
+
+    def updateEditorGeometry(self, editor, option, index):
+        #pylint: disable=no-self-use
+        _ = index # unused
+        editor.setGeometry(option.rect)
+
+    @PyQt5.QtCore.pyqtSlot()
+    def currentIndexChanged(self):
+        self.commitData.emit(self.sender())
+
+class TemplateModel(PyQt5.QtCore.QAbstractItemModel):
+    def __init__(self):
+        super().__init__()
+
+        self.children = []
+
+    def flags(self, index):
+        if index.isValid() and index.column() == 0:
+            return super().flags(index) | PyQt5.QtCore.Qt.ItemIsEditable
+        return super().flags(index)
+
+    def sort(self, idx, order):
+        rev = (order == PyQt5.QtCore.Qt.AscendingOrder)
+        self.children.sort(key=lambda x: x[idx], reverse=rev)
+
+        self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
+
+    def index(self, row, column, parent=PyQt5.QtCore.QModelIndex()):
+        if not self.hasIndex(row, column, parent):
+            return PyQt5.QtCore.QModelIndex()
+
+        return self.createIndex(row, column, self.children[row])
+
+    def parent(self, child):
+        #pylint: disable=no-self-use
+        _ = child # unused
+        return PyQt5.QtCore.QModelIndex()
+
+    def rowCount(self, parent=PyQt5.QtCore.QModelIndex()):
+        #pylint: disable=no-self-use
+        _ = parent # unused
+        return len(self.children)
+
+    def columnCount(self, parent=PyQt5.QtCore.QModelIndex()):
+        #pylint: disable=no-self-use
+        _ = parent # unused
+        return len(Template.COL_NAMES)
+
+    def hasChildren(self, index=PyQt5.QtCore.QModelIndex()):
+        #pylint: disable=no-self-use
+        return index == PyQt5.QtCore.QModelIndex()
+
+    def data(self, index, role=PyQt5.QtCore.Qt.DisplayRole):
+        if index.isValid():
+            data = self.children[index.row()][index.column()]
+            if role == PyQt5.QtCore.Qt.DisplayRole:
+                if data is ZERO_DATE:
+                    return ''
+                if isinstance(data, datetime):
+                    return data.strftime('%d %b %Y')
+                return data
+            if role == PyQt5.QtCore.Qt.FontRole:
+                font = PyQt5.QtGui.QFont()
+                tpl = self.children[index.row()]
+                font.setBold(tpl.status != tpl.default_status)
+                return font
+            if role == PyQt5.QtCore.Qt.TextAlignmentRole:
+                if isinstance(data, int):
+                    return PyQt5.QtCore.Qt.AlignRight
+                return PyQt5.QtCore.Qt.AlignLeft
+        return None
+
+    def setData(self, index, value, role=PyQt5.QtCore.Qt.EditRole):
+        if index.isValid() and role == PyQt5.QtCore.Qt.EditRole:
+            old_list = list(self.children[index.row()])
+            old_list[index.column()] = value
+            new_tpl = Template(*old_list)
+            self.children[index.row()] = new_tpl
+            self.dataChanged.emit(index, index)
+            return True
+        return False
+
+    def headerData(self, section, orientation,
+            role=PyQt5.QtCore.Qt.DisplayRole):
+        #pylint: disable=no-self-use
+        if section < len(Template.COL_NAMES) \
+                and orientation == PyQt5.QtCore.Qt.Horizontal \
+                and role == PyQt5.QtCore.Qt.DisplayRole:
+            return Template.COL_NAMES[section]
+        return None
+
+    def removeRows(self, row, count, parent=PyQt5.QtCore.QModelIndex()):
+        _ = parent # unused
+        self.beginRemoveRows(PyQt5.QtCore.QModelIndex(), row, row + count)
+        del self.children[row:row+count]
+        self.endRemoveRows()
+        self.dataChanged.emit(*self.row_index(row, row + count))
+
+    def row_index(self, low, high):
+        return self.createIndex(low, 0), \
+            self.createIndex(high, self.columnCount())
+
+    def set_templates(self, templates):
+        self.removeRows(0, self.rowCount())
+        cnt = sum(len(g) for _, g in templates.items())
+        self.beginInsertRows(PyQt5.QtCore.QModelIndex(), 0, cnt - 1)
+        for status, grp in templates.items():
+            for tpl in grp:
+                self.children.append(Template.build(status, tpl))
+        self.endInsertRows()
+        self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
+
+    def get_actions(self):
+        actions = []
+        for tpl in self.children:
+            if tpl.status != tpl.default_status:
+                actions.append(Action(tpl.status, tpl.name, tpl.evr))
+        return actions
+
+    async def refresh(self, refresh=True):
+        cmd = BASE_CMD[:]
+        if refresh:
+            # Force refresh if triggered by button press
+            cmd.append('--refresh')
+        cmd.extend(['info', '--machine-readable-json', '--installed',
+            '--available', '--upgrades', '--extras'])
+        proc = await asyncio.create_subprocess_exec(
+            *cmd,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE)
+        output, stderr = await proc.communicate()
+        output = output.decode('ASCII')
+        if proc.returncode != 0:
+            stderr = stderr.decode('ASCII')
+            return False, stderr
+        # Default type is dict as we're going to replace the lists with
+        # dicts shortly after
+        tpls = collections.defaultdict(dict, json.loads(output))
+        # Remove duplicates
+        # Should this be done in qvm-template?
+        # TODO: Merge templates with same name?
+        #       If so, we may need to have a separate UI to force versions.
+        local_names = set(x['name'] for x in tpls['installed'])
+        # Convert to dict for easier subtraction
+        for key in tpls:
+            tpls[key] = {
+                (x['name'], x['epoch'], x['version'], x['release']): x
+                for x in tpls[key]}
+        tpls['installed'] = {
+            k: v for k, v in tpls['installed'].items()
+                if k not in tpls['extra'] and k not in tpls['upgradable']}
+        tpls['available'] = {
+            k: v for k, v in tpls['available'].items()
+                if k not in tpls['installed']
+                    and k not in tpls['upgradable']}
+        # If the package name is installed but the specific version is
+        # neither installed or an upgrade, then it must be a downgrade
+        tpls['downgradable'] = {
+            k: v for k, v in tpls['available'].items()
+                if k[0] in local_names}
+        tpls['available'] = {
+            k: v for k, v in tpls['available'].items()
+                if k not in tpls['downgradable']}
+        # Convert back to list
+        tpls = {k.title(): list(v.values()) for k, v in tpls.items()}
+        self.set_templates(tpls)
+        return True, None
+
+class TemplateInstallConfirmDialog(
+        ui_templateinstallconfirmdlg.Ui_TemplateInstallConfirmDlg,
+        PyQt5.QtWidgets.QDialog):
+    def __init__(self, actions):
+        super().__init__()
+        self.setupUi(self)
+
+        model = PyQt5.QtGui.QStandardItemModel()
+        model.setHorizontalHeaderLabels(Action.COL_NAMES)
+        self.treeView.setModel(model)
+
+        for act in actions:
+            model.appendRow([PyQt5.QtGui.QStandardItem(x) for x in act])
+
+class TemplateInstallProgressDialog(
+        ui_templateinstallprogressdlg.Ui_TemplateInstallProgressDlg,
+        PyQt5.QtWidgets.QDialog):
+    def __init__(self, actions):
+        super().__init__()
+        self.setupUi(self)
+        self.actions = actions
+        self.buttonBox.hide()
+
+    @staticmethod
+    def _process_cr(text):
+        """Reduce lines replaced using CR character (\r)"""
+        while '\r' in text:
+            prefix, suffix = text.rsplit('\r', 1)
+            if '\n' in prefix:
+                prefix = prefix.rsplit('\n', 1)[0]
+                prefix += '\n'
+            else:
+                prefix = ''
+            text = prefix + suffix
+        return text
+
+    def install(self):
+        async def coro():
+            self.actions.sort()
+            for oper, grp in itertools.groupby(self.actions, lambda x: x[0]):
+                oper = oper.lower()
+                # No need to specify versions for local operations
+                if oper in ('remove', 'purge'):
+                    specs = [x.name for x in grp]
+                else:
+                    specs = [x.name + '-' + x.evr for x in grp]
+                # FIXME: (C)Python versions before 3.9 fully-buffers stderr in
+                #        this context, cf. https://bugs.python.org/issue13601
+                #        Forcing it to be unbuffered for the time being so that
+                #        the messages can be displayed in time.
+                envs = os.environ.copy()
+                envs['PYTHONUNBUFFERED'] = '1'
+                proc = await asyncio.create_subprocess_exec(
+                    *(BASE_CMD + [oper, '--'] + specs),
+                    stdout=asyncio.subprocess.PIPE,
+                    stderr=asyncio.subprocess.STDOUT,
+                    env=envs)
+                #pylint: disable=cell-var-from-loop
+                status_text = ''
+                while True:
+                    line = await proc.stdout.read(100)
+                    if line == b'':
+                        break
+                    line = line.decode('UTF-8')
+                    status_text = self._process_cr(status_text + line)
+                    self.textEdit.setPlainText(status_text)
+                if await proc.wait() != 0:
+                    self.buttonBox.show()
+                    self.progressBar.setMaximum(100)
+                    self.progressBar.setValue(0)
+                    return False
+            self.progressBar.setMaximum(100)
+            self.progressBar.setValue(100)
+            self.buttonBox.show()
+            return True
+        asyncio.create_task(coro())
+
+class QvmTemplateWindow(
+        ui_qvmtemplate.Ui_QubesTemplateManager,
+        PyQt5.QtWidgets.QMainWindow):
+    def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
+        _ = parent # unused
+
+        super().__init__()
+        self.setupUi(self)
+        self.listing.header().setSectionResizeMode(
+            PyQt5.QtWidgets.QHeaderView.ResizeToContents)
+
+        self.qubes_app = qubes_app
+        self.qt_app = qt_app
+        self.dispatcher = dispatcher
+
+        self.listing_model = TemplateModel()
+        self.listing_delegate = TemplateStatusDelegate(self.listing)
+
+        self.listing.setModel(self.listing_model)
+        self.listing.setItemDelegateForColumn(0, self.listing_delegate)
+
+        self.refresh(False)
+        self.listing.setItemDelegateForColumn(0, self.listing_delegate)
+        self.listing.selectionModel() \
+            .selectionChanged.connect(self.update_info)
+
+        self.actionRefresh.triggered.connect(lambda: self.refresh(True))
+        self.actionInstall.triggered.connect(self.do_install)
+
+    def update_info(self, selected):
+        _ = selected # unused
+        indices = [
+            x
+            for x in self.listing.selectionModel().selectedIndexes()
+            if x.column() == 0]
+        if len(indices) == 0:
+            return
+        self.infobox.clear()
+        cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
+        bold_fmt = PyQt5.QtGui.QTextCharFormat()
+        bold_fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
+        norm_fmt = PyQt5.QtGui.QTextCharFormat()
+        if len(indices) > 1:
+            cursor.insertText('Selected templates:\n', bold_fmt)
+            for idx in indices:
+                tpl = self.listing_model.children[idx.row()]
+                cursor.insertText(tpl.name + '-' + tpl.evr + '\n', norm_fmt)
+        else:
+            idx = indices[0]
+            tpl = self.listing_model.children[idx.row()]
+            cursor.insertText('Name: ', bold_fmt)
+            cursor.insertText(tpl.name + '\n', norm_fmt)
+            cursor.insertText('Description:\n', bold_fmt)
+            cursor.insertText(tpl.description + '\n', norm_fmt)
+
+    def refresh(self, refresh=True):
+        self.progressBar.show()
+        async def coro():
+            ok, stderr = await self.listing_model.refresh(refresh)
+            self.infobox.clear()
+            if not ok:
+                cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
+                fmt = PyQt5.QtGui.QTextCharFormat()
+                fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
+                cursor.insertText('Failed to fetch template list:\n', fmt)
+                fmt.setFontWeight(PyQt5.QtGui.QFont.Normal)
+                cursor.insertText(stderr, fmt)
+            self.progressBar.hide()
+        asyncio.create_task(coro())
+
+    def do_install(self):
+        actions = self.listing_model.get_actions()
+        confirm = TemplateInstallConfirmDialog(actions)
+        if confirm.exec_():
+            progress = TemplateInstallProgressDialog(actions)
+            progress.install()
+            progress.exec_()
+        self.refresh()
+
+def main():
+    utils.run_asynchronous(QvmTemplateWindow)
+
+if __name__ == '__main__':
+    main()

+ 5 - 0
rpm_spec/qmgr.spec.in

@@ -64,6 +64,7 @@ rm -rf $RPM_BUILD_ROOT
 /usr/bin/qubes-qube-manager
 /usr/bin/qubes-log-viewer
 /usr/bin/qubes-template-manager
+/usr/bin/qvm-template-gui
 /usr/libexec/qubes-manager/mount_for_backup.sh
 /usr/libexec/qubes-manager/qvm_about.sh
 /usr/libexec/qubes-manager/dsa-4371-update
@@ -92,6 +93,7 @@ rm -rf $RPM_BUILD_ROOT
 %{python3_sitelib}/qubesmanager/bootfromdevice.py
 %{python3_sitelib}/qubesmanager/device_list.py
 %{python3_sitelib}/qubesmanager/template_manager.py
+%{python3_sitelib}/qubesmanager/qvm_template_gui.py
 
 %{python3_sitelib}/qubesmanager/resources_rc.py
 
@@ -111,6 +113,9 @@ rm -rf $RPM_BUILD_ROOT
 %{python3_sitelib}/qubesmanager/ui_devicelist.py
 %{python3_sitelib}/qubesmanager/ui_templatemanager.py
 %{python3_sitelib}/qubesmanager/ui_clonevmdlg.py
+%{python3_sitelib}/qubesmanager/ui_qvmtemplate.py
+%{python3_sitelib}/qubesmanager/ui_templateinstallconfirmdlg.py
+%{python3_sitelib}/qubesmanager/ui_templateinstallprogressdlg.py
 %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.qm
 %{python3_sitelib}/qubesmanager/i18n/qubesmanager_*.ts
 

+ 2 - 1
setup.py

@@ -27,6 +27,7 @@ if __name__ == '__main__':
                 'qubes-backup-restore = qubesmanager.restore:main',
                 'qubes-qube-manager = qubesmanager.qube_manager:main',
                 'qubes-log-viewer = qubesmanager.log_dialog:main',
-                'qubes-template-manager = qubesmanager.template_manager:main'
+                'qubes-template-manager = qubesmanager.template_manager:main',
+                'qvm-template-gui = qubesmanager.qvm_template_gui:main'
             ],
         })

+ 114 - 0
ui/qvmtemplate.ui

@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>QubesTemplateManager</class>
+ <widget class="QMainWindow" name="QubesTemplateManager">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>930</width>
+    <height>478</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Qubes Template Manager</string>
+  </property>
+  <widget class="QWidget" name="centralwidget">
+   <layout class="QVBoxLayout" name="verticalLayout">
+    <item>
+     <widget class="QProgressBar" name="progressBar">
+      <property name="maximum">
+       <number>0</number>
+      </property>
+      <property name="value">
+       <number>-1</number>
+      </property>
+     </widget>
+    </item>
+    <item>
+     <widget class="QLabel" name="label">
+      <property name="text">
+       <string>Select actions to perform in &quot;Status&quot; column:</string>
+      </property>
+     </widget>
+    </item>
+    <item>
+     <widget class="QTreeView" name="listing">
+      <property name="editTriggers">
+       <set>QAbstractItemView::AllEditTriggers</set>
+      </property>
+      <property name="selectionMode">
+       <enum>QAbstractItemView::SingleSelection</enum>
+      </property>
+      <property name="itemsExpandable">
+       <bool>false</bool>
+      </property>
+      <property name="sortingEnabled">
+       <bool>true</bool>
+      </property>
+      <property name="expandsOnDoubleClick">
+       <bool>false</bool>
+      </property>
+     </widget>
+    </item>
+    <item>
+     <widget class="QTextEdit" name="infobox">
+      <property name="readOnly">
+       <bool>true</bool>
+      </property>
+      <property name="placeholderText">
+       <string>Use &quot;Status&quot; column to select actions to execute on a given template; when desired actions are selected, use &quot;Apply&quot; to perform them.</string>
+      </property>
+     </widget>
+    </item>
+   </layout>
+  </widget>
+  <widget class="QMenuBar" name="menubar">
+   <property name="geometry">
+    <rect>
+     <x>0</x>
+     <y>0</y>
+     <width>930</width>
+     <height>24</height>
+    </rect>
+   </property>
+  </widget>
+  <widget class="QStatusBar" name="statusbar"/>
+  <widget class="QToolBar" name="toolBar">
+   <property name="windowTitle">
+    <string>toolBar</string>
+   </property>
+   <property name="toolButtonStyle">
+    <enum>Qt::ToolButtonTextUnderIcon</enum>
+   </property>
+   <attribute name="toolBarArea">
+    <enum>TopToolBarArea</enum>
+   </attribute>
+   <attribute name="toolBarBreak">
+    <bool>false</bool>
+   </attribute>
+   <addaction name="actionRefresh"/>
+   <addaction name="actionInstall"/>
+  </widget>
+  <action name="actionRefresh">
+   <property name="icon">
+    <iconset theme="view-refresh">
+     <normaloff>.</normaloff>.</iconset>
+   </property>
+   <property name="text">
+    <string>Refresh</string>
+   </property>
+  </action>
+  <action name="actionInstall">
+   <property name="icon">
+    <iconset theme="go-down">
+     <normaloff>.</normaloff>.</iconset>
+   </property>
+   <property name="text">
+    <string>Apply</string>
+   </property>
+  </action>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 84 - 0
ui/templateinstallconfirmdlg.ui

@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>TemplateInstallConfirmDlg</class>
+ <widget class="QDialog" name="TemplateInstallConfirmDlg">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>400</width>
+    <height>290</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Template Install Confirmation</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QLabel" name="label">
+     <property name="text">
+      <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;WARNING: Local changes made to the following templates will be overwritten! Continue?&lt;/span&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+     </property>
+     <property name="wordWrap">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTreeView" name="treeView">
+     <property name="sortingEnabled">
+      <bool>true</bool>
+     </property>
+     <property name="expandsOnDoubleClick">
+      <bool>false</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>TemplateInstallConfirmDlg</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>TemplateInstallConfirmDlg</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>

+ 81 - 0
ui/templateinstallprogressdlg.ui

@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>TemplateInstallProgressDlg</class>
+ <widget class="QDialog" name="TemplateInstallProgressDlg">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>840</width>
+    <height>260</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Installing Templates...</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QProgressBar" name="progressBar">
+     <property name="maximum">
+      <number>0</number>
+     </property>
+     <property name="value">
+      <number>-1</number>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTextEdit" name="textEdit">
+     <property name="readOnly">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>accepted()</signal>
+   <receiver>TemplateInstallProgressDlg</receiver>
+   <slot>accept()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>248</x>
+     <y>254</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>157</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+  <connection>
+   <sender>buttonBox</sender>
+   <signal>rejected()</signal>
+   <receiver>TemplateInstallProgressDlg</receiver>
+   <slot>reject()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>316</x>
+     <y>260</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>286</x>
+     <y>274</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>