diff --git a/ui/qmls/Components/DirectorySelector.qml b/ui/qmls/Components/DirectorySelector.qml
new file mode 100644
index 0000000..e43cd2e
--- /dev/null
+++ b/ui/qmls/Components/DirectorySelector.qml
@@ -0,0 +1,25 @@
+import QtQuick
+import QtQuick.Dialogs
+
+import internal.ui.utils
+
+SelectorBase {
+ id: base
+
+ FolderDialog {
+ id: folderDialog
+ selectedFolder: base.directoryUrl
+ onAccepted: {
+ base.directoryUrl = this.selectedFolder;
+ this.currentFolder = this.selectedFolder;
+ }
+ }
+
+ property alias directoryUrl: base.url
+
+ shouldAcceptUrl: url => UrlUtils.isDir(url)
+ onBrowseButtonClicked: {
+ folderDialog.open();
+ }
+ placeholderText: 'Select a directory…'
+}
diff --git a/ui/qmls/Components/FileSelector.qml b/ui/qmls/Components/FileSelector.qml
new file mode 100644
index 0000000..b71ac0f
--- /dev/null
+++ b/ui/qmls/Components/FileSelector.qml
@@ -0,0 +1,33 @@
+import QtQuick
+import QtQuick.Dialogs
+
+import internal.ui.utils
+
+SelectorBase {
+ id: base
+
+ FileDialog {
+ id: fileDialog
+ onAccepted: {
+ base.url = this.selectedFile;
+ }
+ }
+
+ function isFileUrlValid(url: url): bool {
+ return url.toString().startsWith("file://");
+ }
+
+ property alias fileUrl: base.url
+ onFileUrlChanged: {
+ if (isFileUrlValid(fileUrl)) {
+ fileDialog.selectedFile = fileUrl;
+ fileDialog.currentFolder = UrlUtils.parent(fileUrl);
+ }
+ }
+
+ shouldAcceptUrl: url => UrlUtils.isFile(url)
+ onBrowseButtonClicked: {
+ fileDialog.open();
+ }
+ placeholderText: 'Select a file…'
+}
diff --git a/ui/qmls/Components/SectionTitle.qml b/ui/qmls/Components/SectionTitle.qml
new file mode 100644
index 0000000..9d2744d
--- /dev/null
+++ b/ui/qmls/Components/SectionTitle.qml
@@ -0,0 +1,13 @@
+import QtQuick.Controls
+import QtQuick.Layouts
+
+Label {
+ Layout.topMargin: 7
+ Layout.bottomMargin: 10
+
+ anchors.topMargin: 7
+ anchors.bottomMargin: 10
+
+ font.pointSize: 12
+ font.bold: true
+}
diff --git a/ui/qmls/Components/SelectorBase.qml b/ui/qmls/Components/SelectorBase.qml
new file mode 100644
index 0000000..e83b123
--- /dev/null
+++ b/ui/qmls/Components/SelectorBase.qml
@@ -0,0 +1,64 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import internal.ui.utils
+
+RowLayout {
+ id: root
+
+ required property var shouldAcceptUrl // function (url)
+ signal browseButtonClicked
+ property string placeholderText: '…'
+
+ required property url url
+ onUrlChanged: {
+ Qt.callLater(() => {
+ updateLabel();
+ });
+ }
+
+ function updateLabel(): void {
+ urlLabel.text = url.toString().length > 0 ? UrlFormatUtils.toLocalFile(url) : root.placeholderText;
+ }
+
+ spacing: 2
+
+ Label {
+ id: urlLabel
+ Layout.fillWidth: true
+ text: root.placeholderText
+
+ DropArea {
+ anchors.fill: parent
+
+ onEntered: drag => {
+ if (!drag.hasUrls || drag.urls.length !== 1) {
+ drag.accepted = false;
+ return false;
+ }
+
+ const url = drag.urls[0];
+ const shouldAccept = root.shouldAcceptUrl(url);
+ if (!shouldAccept) {
+ drag.accepted = false;
+ return false;
+ }
+
+ urlLabel.text = `Drop "${UrlUtils.name(url)}"?`;
+ }
+ onExited: {
+ root.updateLabel();
+ }
+ onDropped: drop => {
+ root.url = drop.urls[0];
+ root.updateLabel();
+ }
+ }
+ }
+
+ Button {
+ text: "Browse"
+ onClicked: root.browseButtonClicked()
+ }
+}
diff --git a/ui/qmls/Components/qmldir b/ui/qmls/Components/qmldir
new file mode 100644
index 0000000..3a76167
--- /dev/null
+++ b/ui/qmls/Components/qmldir
@@ -0,0 +1,6 @@
+module Components
+internal SelectorBase SelectorBase.qml
+DirectorySelector 1.0 DirectorySelector.qml
+FileSelector 1.0 FileSelector.qml
+
+SectionTitle 1.0 SectionTitle.qml