Add tray UI: main.qml + ShareItem.qml

PlasmoidItem with a system-tray icon (active when a share is mounted) and a
popup grouping shares per host. ExpandableListItem rows toggle mount/unmount
and open the mounted folder; empty-state placeholder links to config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-15 14:24:42 +02:00
parent 43088a2316
commit 47046665ed
3 changed files with 349 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import org.kde.plasma.extras as PlasmaExtras
import org.kde.kirigami as Kirigami
/*
* One share row in the popup: title = share name, primary button toggles
* mount/unmount, context menu opens the mounted folder. Purely presentational —
* it emits signals; main.qml owns the mount logic.
*/
PlasmaExtras.ExpandableListItem {
id: item
property string shareName: ""
property string hostLabel: ""
property string comment: ""
property string mountpoint: ""
property bool mounted: false
property bool busy: false
signal mountToggled()
signal openRequested()
icon: "folder-network"
iconEmblem: mounted ? "emblem-mounted" : ""
title: shareName
subtitle: {
if (busy)
return mounted ? i18n("Unmounting…") : i18n("Mounting…");
if (mounted)
return mountpoint;
if (comment.length > 0)
return comment;
return i18n("Not mounted");
}
isBusy: busy
enabled: !busy
defaultActionButtonAction: QQC2.Action {
text: item.mounted ? i18n("Unmount") : i18n("Mount")
icon.name: item.mounted ? "media-eject" : "drive-harddisk"
onTriggered: item.mountToggled()
}
contextualActions: [
QQC2.Action {
text: i18n("Open in File Manager")
icon.name: "document-open-folder"
enabled: item.mounted
onTriggered: item.openRequested()
}
]
}
+284
View File
@@ -0,0 +1,284 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.plasma.plasmoid
import org.kde.plasma.core as PlasmaCore
import org.kde.plasma.components as PlasmaComponents3
import org.kde.plasma.extras as PlasmaExtras
import org.kde.plasma.plasma5support as Plasma5Support
import org.kde.kirigami as Kirigami
import "../code/nas.js" as Nas
PlasmoidItem {
id: root
// --- state ------------------------------------------------------------
property string homeDir: ""
property var mountedSet: ({}) // mountpoint -> true
property var hostShares: ({}) // hostId -> [{name, comment}]
property bool scanning: false
property bool anyMounted: false
readonly property var cfg: Plasmoid.configuration
Plasmoid.icon: "folder-network"
Plasmoid.status: anyMounted ? PlasmaCore.Types.ActiveStatus : PlasmaCore.Types.PassiveStatus
switchWidth: Kirigami.Units.gridUnit * 12
switchHeight: Kirigami.Units.gridUnit * 12
// --- helpers ----------------------------------------------------------
function settingsObj() {
return {
home: homeDir,
defaultMountRoot: cfg.defaultMountRoot,
smbVersion: cfg.smbVersion,
mountOptions: cfg.mountOptions,
fileMode: cfg.fileMode,
dirMode: cfg.dirMode
};
}
function hostById(id) {
var hosts = Nas.parseHosts(cfg.hosts);
for (var i = 0; i < hosts.length; ++i)
if (hosts[i].id === id) return hosts[i];
return null;
}
function rebuildModel() {
var hosts = Nas.parseHosts(cfg.hosts);
var s = settingsObj();
var mounted = false;
sharesModel.clear();
for (var i = 0; i < hosts.length; ++i) {
var h = hosts[i];
sharesModel.append({
isHeader: true, hostId: h.id,
hostLabel: (h.label && h.label.length ? h.label : h.host),
shareName: "", comment: "", mountpoint: "", mounted: false, busy: false
});
var shares = hostShares[h.id] || [];
for (var j = 0; j < shares.length; ++j) {
var mp = Nas.mountpointFor(s.defaultMountRoot, s.home, h, shares[j].name);
var isMounted = mountedSet[mp] === true;
if (isMounted) mounted = true;
sharesModel.append({
isHeader: false, hostId: h.id,
hostLabel: (h.label && h.label.length ? h.label : h.host),
shareName: shares[j].name, comment: shares[j].comment || "",
mountpoint: mp, mounted: isMounted, busy: false
});
}
}
anyMounted = mounted;
}
function refreshMounts(then) {
exe.run(Nas.listMountedCmd(), function (code, out, err) {
mountedSet = Nas.parseMounted(out);
rebuildModel();
if (then) then();
});
}
function scanShares() {
var hosts = Nas.parseHosts(cfg.hosts);
if (hosts.length === 0) { rebuildModel(); return; }
scanning = true;
var pending = hosts.length;
hosts.forEach(function (h) {
exe.run(Nas.enumerateCmd(h), function (code, out, err) {
hostShares[h.id] = Nas.parseShares(out);
if (--pending === 0) scanning = false;
rebuildModel();
});
});
}
function refreshAll() {
refreshMounts(function () { scanShares(); });
}
function toggleShare(index) {
var row = sharesModel.get(index);
if (!row || row.isHeader) return;
var host = hostById(row.hostId);
if (!host) return;
sharesModel.setProperty(index, "busy", true);
if (row.mounted) {
exe.run(Nas.unmountCmd(row.mountpoint), function () { refreshMounts(); });
} else {
var m = Nas.mountCmd(settingsObj(), host, row.shareName);
exe.run(m.cmd, function () { refreshMounts(); });
}
}
function openShare(index) {
var row = sharesModel.get(index);
if (row && row.mountpoint.length > 0)
exe.run(Nas.openCmd(row.mountpoint), function () {});
}
function openConfig() {
var a = Plasmoid.internalAction("configure");
if (a) a.trigger();
}
// --- data sources -----------------------------------------------------
ListModel { id: sharesModel }
Plasma5Support.DataSource {
id: exe
engine: "executable"
property var cbs: ({})
property int nonce: 0
onNewData: function (source, data) {
var cb = cbs[source];
disconnectSource(source);
if (cb) {
delete cbs[source];
cb(data["exit code"], data["stdout"], data["stderr"]);
}
}
function run(cmd, cb) {
var key = cmd + " tok" + (nonce++);
cbs[key] = cb;
connectSource(key);
}
}
Component.onCompleted: {
exe.run(Nas.homeCmd(), function (code, out) {
homeDir = (out || "").trim();
rebuildModel();
});
}
onExpandedChanged: {
if (expanded && homeDir.length > 0)
refreshAll();
}
// --- UI ---------------------------------------------------------------
fullRepresentation: PlasmaExtras.Representation {
id: rep
Layout.minimumWidth: Kirigami.Units.gridUnit * 19
Layout.minimumHeight: Kirigami.Units.gridUnit * 22
Layout.preferredWidth: Kirigami.Units.gridUnit * 22
Layout.preferredHeight: Kirigami.Units.gridUnit * 26
collapseMarginsHint: true
header: PlasmaExtras.PlasmoidHeading {
RowLayout {
anchors.fill: parent
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
Layout.fillWidth: true
level: 1
text: i18n("Network Shares")
elide: Text.ElideRight
}
PlasmaComponents3.ToolButton {
icon.name: "view-refresh"
display: QQC2.AbstractButton.IconOnly
text: i18n("Refresh")
enabled: !root.scanning
onClicked: root.refreshAll()
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
}
PlasmaComponents3.ScrollView {
id: scroll
anchors.fill: parent
contentWidth: availableWidth
ListView {
id: shareList
model: sharesModel
currentIndex: -1
reuseItems: true
delegate: Column {
id: rowRoot
width: shareList.width
required property int index
required property bool isHeader
required property string hostLabel
required property string shareName
required property string comment
required property string mountpoint
required property bool mounted
required property bool busy
// Host group header
PlasmaExtras.Heading {
visible: rowRoot.isHeader
width: parent.width
level: 3
type: Kirigami.Heading.Type.Primary
text: rowRoot.hostLabel
elide: Text.ElideRight
padding: Kirigami.Units.smallSpacing
leftPadding: Kirigami.Units.smallSpacing * 2
}
// Share row
ShareItem {
visible: !rowRoot.isHeader
width: parent.width
shareName: rowRoot.shareName
hostLabel: rowRoot.hostLabel
comment: rowRoot.comment
mountpoint: rowRoot.mountpoint
mounted: rowRoot.mounted
busy: rowRoot.busy
onMountToggled: root.toggleShare(rowRoot.index)
onOpenRequested: root.openShare(rowRoot.index)
}
}
PlasmaExtras.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: sharesModel.count === 0 && !root.scanning
iconName: "network-server"
text: i18n("No NAS configured")
explanation: i18n("Add a Synology or SMB host to browse and mount its shared folders.")
helpfulAction: QQC2.Action {
text: i18n("Add NAS…")
icon.name: "list-add"
onTriggered: root.openConfig()
}
}
}
}
footer: PlasmaExtras.PlasmoidHeading {
position: PlasmaExtras.PlasmoidHeading.Position.Footer
RowLayout {
anchors.fill: parent
PlasmaComponents3.BusyIndicator {
visible: root.scanning
running: root.scanning
implicitWidth: Kirigami.Units.iconSizes.small
implicitHeight: Kirigami.Units.iconSizes.small
}
Item { Layout.fillWidth: true }
PlasmaComponents3.Button {
text: i18n("Add NAS…")
icon.name: "list-add"
onClicked: root.openConfig()
}
}
}
}
}