v0.2.0: data-safety, unsaved-changes guard, FormCard UI, filesystem UX

- Preserve override keys we do not model so Save never drops data
- Prompt before discarding unsaved changes when switching applications
- Native FormCard layout for the permission list (grouped cards)
- Filesystem entries gain an access-mode selector and a folder browser
- Add resolution autotests (toggle render/save, filesystem split, key preservation)
This commit is contained in:
2026-07-01 14:46:24 -05:00
parent 3419a1acac
commit 5f37debd01
9 changed files with 559 additions and 265 deletions
+6 -1
View File
@@ -3,7 +3,7 @@
cmake_minimum_required(VERSION 3.16)
project(flatkontrol VERSION 0.1.0 LANGUAGES CXX)
project(flatkontrol VERSION 0.2.0 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0)
@@ -30,6 +30,7 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
Quick
QuickControls2
DBus
Test
)
find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
@@ -47,6 +48,10 @@ ecm_setup_version(${PROJECT_VERSION}
add_subdirectory(src)
if(BUILD_TESTING)
add_subdirectory(autotests)
endif()
install(PROGRAMS io.github.toservetheking.FlatKontrol.desktop
DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES io.github.toservetheking.FlatKontrol.metainfo.xml
+22
View File
@@ -0,0 +1,22 @@
# SPDX-License-Identifier: GPL-3.0-or-later
add_executable(resolutiontest
resolutiontest.cpp
../src/keyfile.cpp
../src/flatpakinstallations.cpp
../src/permissioncatalog.cpp
../src/portalsbackend.cpp
../src/permissionscontroller.cpp
)
target_include_directories(resolutiontest PRIVATE ../src)
target_link_libraries(resolutiontest PRIVATE
Qt6::Core
Qt6::Qml
Qt6::DBus
Qt6::Test
KF6::I18n
)
add_test(NAME resolutiontest COMMAND resolutiontest)
+145
View File
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "keyfile.h"
#include "permissionscontroller.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
using PC = PermissionsController;
class ResolutionTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir m_dir;
QString userBase() const
{
return m_dir.path() + QStringLiteral("/user");
}
void makeApp(const QString &appId, const QString &metadata)
{
const QString d = userBase() + QStringLiteral("/app/") + appId + QStringLiteral("/current/active");
QVERIFY(QDir().mkpath(d));
QFile f(d + QStringLiteral("/metadata"));
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(metadata.toUtf8());
}
void writeOverride(const QString &appId, const QString &content)
{
const QString d = userBase() + QStringLiteral("/overrides");
QVERIFY(QDir().mkpath(d));
QFile f(d + QLatin1Char('/') + appId);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(content.toUtf8());
}
KeyFile readOverride(const QString &appId)
{
KeyFile kf;
kf.load(userBase() + QStringLiteral("/overrides/") + appId);
return kf;
}
static int findToggle(PC &c, const QString &catId, const QString &option)
{
for (int i = 0; i < c.rowCount(); ++i) {
const QModelIndex idx = c.index(i);
if (c.data(idx, PC::RowTypeRole).toInt() == PC::Toggle && c.data(idx, PC::CategoryIdRole).toString() == catId
&& c.data(idx, PC::OptionKeyRole).toString() == option) {
return i;
}
}
return -1;
}
private Q_SLOTS:
void initTestCase()
{
QVERIFY(m_dir.isValid());
qputenv("FLATPAK_USER_DIR", userBase().toUtf8());
qputenv("FLATPAK_SYSTEM_DIR", (m_dir.path() + QStringLiteral("/system")).toUtf8());
// Point config at an empty dir so host custom installations don't interfere.
qputenv("FLATPAK_CONFIG_DIR", (m_dir.path() + QStringLiteral("/config")).toUtf8());
}
void toggleRenderAndSave()
{
makeApp(QStringLiteral("org.test.Toggle"), QStringLiteral("[Context]\nshared=network;ipc\n"));
PC c;
c.setAppId(QStringLiteral("org.test.Toggle"));
const int net = findToggle(c, QStringLiteral("share"), QStringLiteral("network"));
QVERIFY(net >= 0);
QCOMPARE(c.data(c.index(net), PC::ValueRole).toBool(), true);
QCOMPARE(c.data(c.index(net), PC::StatusRole).toInt(), int(PC::Original));
QVERIFY(!c.modified());
c.setToggleValue(net, false);
QVERIFY(c.modified());
QCOMPARE(c.data(c.index(net), PC::StatusRole).toInt(), int(PC::User));
c.save();
QVERIFY(!c.modified());
const QStringList shared = readOverride(QStringLiteral("org.test.Toggle")).value(QStringLiteral("Context"), QStringLiteral("shared")).split(QLatin1Char(';'), Qt::SkipEmptyParts);
QVERIFY(shared.contains(QStringLiteral("!network")));
QVERIFY(!shared.contains(QStringLiteral("!ipc"))); // unchanged options are not written
}
void filesystemsSplitByMode()
{
makeApp(QStringLiteral("org.test.Fs"), QStringLiteral("[Context]\nfilesystems=home;~/Docs:ro;xdg-download\n"));
PC c;
c.setAppId(QStringLiteral("org.test.Fs"));
// "home" is a preset toggle, on.
const int home = findToggle(c, QStringLiteral("filesystems-presets"), QStringLiteral("home"));
QVERIFY(home >= 0);
QCOMPARE(c.data(c.index(home), PC::ValueRole).toBool(), true);
// The non-preset paths become entries split into path + access mode.
QMap<QString, QString> paths;
for (int i = 0; i < c.rowCount(); ++i) {
const QModelIndex idx = c.index(i);
if (c.data(idx, PC::RowTypeRole).toInt() == PC::PathEntry && c.data(idx, PC::CategoryIdRole).toString() == QStringLiteral("filesystems-other")) {
paths.insert(c.data(idx, PC::ValueRole).toString(), c.data(idx, PC::SecondaryRole).toString());
}
}
QCOMPARE(paths.value(QStringLiteral("~/Docs")), QStringLiteral("ro"));
QCOMPARE(paths.value(QStringLiteral("xdg-download")), QStringLiteral("rw"));
}
void unknownKeysPreserved()
{
makeApp(QStringLiteral("org.test.Unknown"), QStringLiteral("[Context]\nshared=network\n"));
writeOverride(QStringLiteral("org.test.Unknown"),
QStringLiteral("[Context]\nshared=!network\nunknownkey=foo\n\n[Weird Group]\na=b\n"));
PC c;
c.setAppId(QStringLiteral("org.test.Unknown"));
// Loading an existing file must be idempotent (no spurious "modified").
QVERIFY(!c.modified());
const int net = findToggle(c, QStringLiteral("share"), QStringLiteral("network"));
QVERIFY(net >= 0);
QCOMPARE(c.data(c.index(net), PC::ValueRole).toBool(), false); // overridden off
c.setToggleValue(net, true); // back to the metadata default -> override removed
QVERIFY(c.modified());
c.save();
const KeyFile kf = readOverride(QStringLiteral("org.test.Unknown"));
// Keys we do not model must survive the round-trip.
QCOMPARE(kf.value(QStringLiteral("Context"), QStringLiteral("unknownkey")), QStringLiteral("foo"));
QCOMPARE(kf.value(QStringLiteral("Weird Group"), QStringLiteral("a")), QStringLiteral("b"));
}
};
QTEST_GUILESS_MAIN(ResolutionTest)
#include "resolutiontest.moc"
@@ -61,6 +61,16 @@
<content_rating type="oars-1.1"/>
<releases>
<release version="0.2.0" date="2026-07-01">
<description>
<ul>
<li>Preserve override keys that are not modelled, so saving never drops data</li>
<li>Ask before discarding unsaved changes when switching applications</li>
<li>Native FormCard layout for the permission list</li>
<li>Filesystem entries now have an access-mode selector and a folder browser</li>
</ul>
</description>
</release>
<release version="0.1.0" date="2026-06-30">
<description>
<p>Initial release.</p>
+1 -1
View File
@@ -36,7 +36,7 @@ modules:
# For Flathub / release builds, pin to a tag AND commit:
- type: git
url: https://github.com/toservetheking/FlatKontrol.git
tag: v0.1.0
tag: v0.2.0
# commit: <fill in the exact commit SHA the tag points at>
#
# For local testing before the repo is pushed, replace the source above with:
+74 -2
View File
@@ -29,6 +29,33 @@ QString joinSorted(const QStringList &tokens)
return copy.join(QLatin1Char(';'));
}
// Split a filesystem token like "~/games:ro" into its access mode and path.
// A missing/unknown suffix means read-write (flatpak's default).
bool isFsMode(const QString &s)
{
return s == QLatin1String("ro") || s == QLatin1String("rw") || s == QLatin1String("create");
}
QString fsModeOf(const QString &token)
{
const int idx = token.lastIndexOf(QLatin1Char(':'));
if (idx > 0 && isFsMode(token.mid(idx + 1))) {
return token.mid(idx + 1);
}
return QStringLiteral("rw");
}
QString fsPathOf(const QString &token)
{
const int idx = token.lastIndexOf(QLatin1Char(':'));
if (idx > 0 && isFsMode(token.mid(idx + 1))) {
return token.left(idx);
}
return token;
}
QString fsToken(const QString &path, const QString &mode)
{
return (mode.isEmpty() || mode == QLatin1String("rw")) ? path : path + QLatin1Char(':') + mode;
}
// Normalise a KeyFile to a canonical string so two override sets can be
// compared regardless of group/key/token ordering.
QString normalize(const KeyFile &kf)
@@ -54,6 +81,23 @@ PermissionsController::PermissionsController(QObject *parent)
{
}
QVariantList PermissionsController::categories() const
{
QVariantList list;
for (const CategoryDef &cat : catalog()) {
if (cat.type == RowType::Portal && isGlobal()) {
continue; // portals are not applicable to the global override file
}
list.append(QVariantMap{
{QStringLiteral("id"), cat.id},
{QStringLiteral("title"), cat.title},
{QStringLiteral("description"), cat.description},
{QStringLiteral("type"), static_cast<int>(cat.type)},
});
}
return list;
}
bool PermissionsController::isFilesystemPreset(const QString &bareToken)
{
static const QSet<QString> presets = {
@@ -319,7 +363,8 @@ void PermissionsController::rebuild()
Row r;
r.catIndex = i;
r.kind = PathEntry;
r.primary = t;
r.primary = fsPathOf(t);
r.secondary = fsModeOf(t);
r.removable = true;
r.entryId = m_nextEntryId++;
m_rows.append(r);
@@ -730,7 +775,7 @@ KeyFile PermissionsController::buildOverrides() const
QSet<QString> desired;
for (const Row &r : m_rows) {
if (r.catIndex == i && !r.primary.trimmed().isEmpty()) {
desired.insert(r.primary.trimmed());
desired.insert(fsToken(r.primary.trimmed(), r.secondary));
}
}
// Port of filesystemsOther.updateFromProxyProperty (presets separated).
@@ -834,6 +879,33 @@ KeyFile PermissionsController::buildOverrides() const
kf.setValue(group, key, tokens.join(QLatin1Char(';')));
}
// Preserve any keys we do not model (e.g. newer flatpak options, or entries
// written by other tools) so saving never drops data. We claim the six
// Context keys and the whole Environment / bus-policy groups; everything else
// is copied through verbatim from what was on disk.
static const QSet<QString> claimedContextKeys = {
QStringLiteral("shared"),
QStringLiteral("sockets"),
QStringLiteral("devices"),
QStringLiteral("features"),
QStringLiteral("filesystems"),
QStringLiteral("persistent"),
};
static const QSet<QString> claimedGroups = {
QStringLiteral("Environment"),
QStringLiteral("Session Bus Policy"),
QStringLiteral("System Bus Policy"),
};
for (const QString &group : m_savedSnapshot.groups()) {
const bool wholeGroupClaimed = claimedGroups.contains(group);
for (const QString &key : m_savedSnapshot.keys(group)) {
const bool claimed = wholeGroupClaimed || (group == QStringLiteral("Context") && claimedContextKeys.contains(key));
if (!claimed) {
kf.setValue(group, key, m_savedSnapshot.value(group, key));
}
}
}
return kf;
}
+4
View File
@@ -11,6 +11,7 @@
#include <QQmlEngine>
#include <QSet>
#include <QString>
#include <QVariantList>
/**
* Owns the editable permission state for one application (or the global
@@ -34,6 +35,8 @@ class PermissionsController : public QAbstractListModel
Q_PROPERTY(bool isGlobal READ isGlobal NOTIFY appIdChanged)
Q_PROPERTY(bool modified READ modified NOTIFY modifiedChanged)
Q_PROPERTY(bool canUndo READ canUndo NOTIFY canUndoChanged)
// One entry per visible category: {id, title, description, type}.
Q_PROPERTY(QVariantList categories READ categories NOTIFY appIdChanged)
public:
enum RowKind {
@@ -119,6 +122,7 @@ public:
{
return m_canUndo;
}
QVariantList categories() const;
// --- Editing API, called from QML ---
Q_INVOKABLE void setToggleValue(int row, bool value);
+26 -4
View File
@@ -45,12 +45,37 @@ Kirigami.ApplicationWindow {
detailsDialog.open();
}
function requestSelect(appId: string): void {
if (appId === controller.appId) {
root.pageStack.currentIndex = 1;
return;
}
if (controller.modified) {
discardDialog.pendingAppId = appId;
discardDialog.open();
} else {
selectApp(appId);
}
}
function selectApp(appId: string): void {
controller.appId = appId;
// On narrow layouts, reveal the permissions column.
root.pageStack.currentIndex = 1;
}
Kirigami.PromptDialog {
id: discardDialog
property string pendingAppId
title: i18nc("@title", "Unsaved changes")
subtitle: i18n("You have unsaved changes. Discard them and switch applications?")
standardButtons: QQC2.Dialog.Discard | QQC2.Dialog.Cancel
onDiscarded: {
close();
root.selectApp(discardDialog.pendingAppId);
}
}
Component {
id: sidebarComponent
@@ -85,10 +110,7 @@ Kirigami.ApplicationWindow {
subtitle: appDelegate.isGlobal ? i18n("Default settings for all apps") : appDelegate.appId
}
onClicked: {
appList.currentIndex = index;
root.selectApp(appId);
}
onClicked: root.requestSelect(appId)
}
Kirigami.PlaceholderMessage {
+271 -257
View File
@@ -4,8 +4,11 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import QtQuick.Dialogs
import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.FlatKontrol
Kirigami.ScrollablePage {
@@ -17,20 +20,23 @@ Kirigami.ScrollablePage {
title: hasSelection ? controller.appName : i18nc("@title", "Permissions")
// --- shared bits ---------------------------------------------------------
leftPadding: 0
rightPadding: 0
topPadding: Kirigami.Units.gridUnit
bottomPadding: Kirigami.Units.gridUnit
// A small status marker used across delegates.
component StatusBadge: Kirigami.Icon {
property int badgeStatus: 0
visible: badgeStatus > 0
property int state: 0
visible: state > 0
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
source: badgeStatus === 2 ? "document-edit" : "globe"
source: state === 2 ? "document-edit-symbolic" : "globe-symbolic"
HoverHandler {
id: hover
id: badgeHover
}
QQC2.ToolTip.visible: hover.hovered
QQC2.ToolTip.text: badgeStatus === 2 ? i18n("Set by you") : i18n("Set by a global override")
QQC2.ToolTip.visible: badgeHover.hovered
QQC2.ToolTip.text: state === 2 ? i18n("Set by you") : i18n("Set by a global override")
}
actions: [
@@ -70,259 +76,267 @@ Kirigami.ScrollablePage {
}
}
// --- the list ------------------------------------------------------------
ListView {
id: permsView
model: page.controller
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: !page.hasSelection
icon.name: "preferences-desktop"
text: i18n("Select an application")
explanation: i18n("Choose an application to review and adjust its permissions.")
}
section.property: "categoryTitle"
section.delegate: Kirigami.ListSectionHeader {
required property string section
width: ListView.view.width
text: section
}
delegate: DelegateChooser {
role: "rowType"
// Toggle (share/sockets/devices/features/filesystem presets)
DelegateChoice {
roleValue: 0
delegate: QQC2.ItemDelegate {
id: toggleDelegate
required property int index
required property string label
required property string example
required property bool value
required property int status
width: ListView.view.width
hoverEnabled: true
onClicked: toggleSwitch.toggle()
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: toggleDelegate.label
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: toggleDelegate.example
visible: text.length > 0
opacity: 0.6
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
elide: Text.ElideRight
}
}
StatusBadge {
badgeStatus: toggleDelegate.status
}
QQC2.Switch {
id: toggleSwitch
checked: toggleDelegate.value
onToggled: page.controller.setToggleValue(toggleDelegate.index, checked)
}
}
}
}
// Filesystem path entry
DelegateChoice {
roleValue: 1
delegate: page.entryDelegate
}
// Persistent (relative path) entry
DelegateChoice {
roleValue: 2
delegate: page.entryDelegate
}
// Environment variable
DelegateChoice {
roleValue: 3
delegate: QQC2.ItemDelegate {
id: varDelegate
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: varDelegate.value
placeholderText: i18n("VARIABLE")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varDelegate.index, text)
}
QQC2.Label {
text: "="
}
QQC2.TextField {
text: varDelegate.secondary
placeholderText: i18n("value")
Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varDelegate.index, text)
}
StatusBadge {
badgeStatus: varDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: varDelegate.removable
onClicked: page.controller.removeEntry(varDelegate.index)
}
}
}
}
// D-Bus name policy
DelegateChoice {
roleValue: 4
delegate: QQC2.ItemDelegate {
id: busDelegate
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: busDelegate.value
placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busDelegate.index, text)
}
QQC2.ComboBox {
model: ["talk", "own"]
currentIndex: busDelegate.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busDelegate.index, currentValue)
}
StatusBadge {
badgeStatus: busDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: busDelegate.removable
onClicked: page.controller.removeEntry(busDelegate.index)
}
}
}
}
// Portal (tri-state)
DelegateChoice {
roleValue: 5
delegate: QQC2.ItemDelegate {
id: portalDelegate
required property int index
required property string label
required property string example
required property int value
required property bool supported
required property string unsupportedReason
width: ListView.view.width
hoverEnabled: true
background: null
readonly property var states: [2, 4, 3] // Unset, Allowed, Disallowed
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
ColumnLayout {
Layout.fillWidth: true
spacing: 0
QQC2.Label {
text: portalDelegate.label
Layout.fillWidth: true
elide: Text.ElideRight
}
QQC2.Label {
text: portalDelegate.supported ? portalDelegate.example : portalDelegate.unsupportedReason
visible: text.length > 0
opacity: 0.6
font: Kirigami.Theme.smallFont
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
QQC2.ComboBox {
enabled: portalDelegate.supported
model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")]
currentIndex: portalDelegate.value === 4 ? 1 : (portalDelegate.value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(portalDelegate.index, portalDelegate.states[currentIndex])
}
}
}
}
// "Add entry" affordance
DelegateChoice {
roleValue: 6
delegate: QQC2.ItemDelegate {
id: addDelegate
required property string categoryId
width: ListView.view.width
icon.name: "list-add"
text: i18nc("@action", "Add…")
onClicked: page.controller.addEntry(categoryId)
}
}
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: !page.hasSelection
icon.name: "preferences-desktop"
text: i18n("Select an application")
explanation: i18n("Choose an application to review and adjust its permissions.")
}
// Shared delegate for path / relative-path entries.
property Component entryDelegate: Component {
QQC2.ItemDelegate {
id: pathDelegate
required property int index
required property string value
required property string categoryId
required property int status
required property bool removable
width: ListView.view.width
hoverEnabled: true
background: null
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
visible: page.hasSelection
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: pathDelegate.value
placeholderText: pathDelegate.categoryId === "persistent" ? i18n("e.g. .thunderbird") : i18n("e.g. ~/games:ro")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(pathDelegate.index, text)
Repeater {
model: page.controller.categories
delegate: ColumnLayout {
id: section
required property var modelData
Layout.fillWidth: true
spacing: 0
FormCard.FormHeader {
title: section.modelData.title
}
StatusBadge {
badgeStatus: pathDelegate.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: pathDelegate.removable
onClicked: page.controller.removeEntry(pathDelegate.index)
FormCard.FormCard {
KItemModels.KSortFilterProxyModel {
id: catModel
sourceModel: page.controller
filterRoleName: "categoryId"
// No category id is a substring of another, so this is an exact match.
filterString: section.modelData.id
}
Repeater {
model: catModel
delegate: DelegateChooser {
role: "rowType"
// Toggle
DelegateChoice {
roleValue: 0
delegate: FormCard.FormSwitchDelegate {
id: toggleD
required property int index
required property string label
required property string example
required property bool value
required property int status
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
text: label
description: example
checked: value
icon.name: status === 2 ? "document-edit-symbolic" : (status === 1 ? "globe-symbolic" : "")
onToggled: page.controller.setToggleValue(sourceRow, checked)
}
}
// Filesystem path (with access mode + Browse)
DelegateChoice {
roleValue: 1
delegate: FormCard.AbstractFormDelegate {
id: fsD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
FolderDialog {
id: folderDialog
onAccepted: {
const p = decodeURIComponent(selectedFolder.toString().replace(/^file:\/\//, ""));
fsField.text = p;
page.controller.setEntryPrimary(fsD.sourceRow, p);
}
}
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
id: fsField
text: fsD.value
placeholderText: i18n("e.g. ~/Downloads")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(fsD.sourceRow, text)
}
QQC2.ComboBox {
readonly property var modes: ["ro", "rw", "create"]
model: [i18n("Read-only"), i18n("Read/write"), i18n("Create")]
currentIndex: Math.max(0, modes.indexOf(fsD.secondary))
onActivated: page.controller.setEntrySecondary(fsD.sourceRow, modes[currentIndex])
}
QQC2.ToolButton {
icon.name: "document-open-folder"
QQC2.ToolTip.text: i18n("Browse…")
QQC2.ToolTip.visible: hovered
onClicked: folderDialog.open()
}
StatusBadge {
state: fsD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: fsD.removable
onClicked: page.controller.removeEntry(fsD.sourceRow)
}
}
}
}
// Persistent (home-relative) path
DelegateChoice {
roleValue: 2
delegate: FormCard.AbstractFormDelegate {
id: relD
required property int index
required property string value
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: relD.value
placeholderText: i18n("e.g. .thunderbird")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(relD.sourceRow, text)
}
StatusBadge {
state: relD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: relD.removable
onClicked: page.controller.removeEntry(relD.sourceRow)
}
}
}
}
// Environment variable
DelegateChoice {
roleValue: 3
delegate: FormCard.AbstractFormDelegate {
id: varD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: varD.value
placeholderText: i18n("VARIABLE")
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
onTextEdited: page.controller.setEntryPrimary(varD.sourceRow, text)
}
QQC2.Label {
text: "="
}
QQC2.TextField {
text: varD.secondary
placeholderText: i18n("value")
Layout.fillWidth: true
onTextEdited: page.controller.setEntrySecondary(varD.sourceRow, text)
}
StatusBadge {
state: varD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: varD.removable
onClicked: page.controller.removeEntry(varD.sourceRow)
}
}
}
}
// D-Bus name policy
DelegateChoice {
roleValue: 4
delegate: FormCard.AbstractFormDelegate {
id: busD
required property int index
required property string value
required property string secondary
required property int status
required property bool removable
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
background: null
contentItem: RowLayout {
spacing: Kirigami.Units.smallSpacing
QQC2.TextField {
text: busD.value
placeholderText: i18n("e.g. org.freedesktop.Notifications")
Layout.fillWidth: true
onTextEdited: page.controller.setEntryPrimary(busD.sourceRow, text)
}
QQC2.ComboBox {
model: ["talk", "own"]
currentIndex: busD.secondary === "own" ? 1 : 0
onActivated: page.controller.setEntrySecondary(busD.sourceRow, currentValue)
}
StatusBadge {
state: busD.status
}
QQC2.ToolButton {
icon.name: "edit-delete-remove"
visible: busD.removable
onClicked: page.controller.removeEntry(busD.sourceRow)
}
}
}
}
// Portal (tri-state)
DelegateChoice {
roleValue: 5
delegate: FormCard.FormComboBoxDelegate {
id: portalD
required property int index
required property string label
required property string example
required property int value
required property bool supported
required property string unsupportedReason
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
readonly property var states: [2, 4, 3]
text: label
description: supported ? example : unsupportedReason
enabled: supported
model: [i18n("Unset"), i18n("Allowed"), i18n("Disallowed")]
currentIndex: value === 4 ? 1 : (value === 3 ? 2 : 0)
onActivated: page.controller.setPortalValue(sourceRow, states[currentIndex])
}
}
// "Add entry" affordance
DelegateChoice {
roleValue: 6
delegate: FormCard.FormButtonDelegate {
id: addD
required property string categoryId
text: i18nc("@action", "Add…")
icon.name: "list-add"
onClicked: page.controller.addEntry(categoryId)
}
}
}
}
}
}
}