From 47046665edff15ccad7a863fde1c3731d4463070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Wed, 15 Jul 2026 14:24:42 +0200 Subject: [PATCH] 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) --- package/contents/code/nas.js | 10 ++ package/contents/ui/ShareItem.qml | 55 ++++++ package/contents/ui/main.qml | 284 ++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 package/contents/ui/ShareItem.qml create mode 100644 package/contents/ui/main.qml diff --git a/package/contents/code/nas.js b/package/contents/code/nas.js index 23b4350..2c3e948 100644 --- a/package/contents/code/nas.js +++ b/package/contents/code/nas.js @@ -122,6 +122,16 @@ function unmountCmd(mountpoint) { return shCmd("exec pkexec " + HELPER + " unmount \"$1\"", [mountpoint]); } +// Resolve the user's home directory. +function homeCmd() { + return shCmd("printf %s \"$HOME\"", []); +} + +// Open a path in the default file manager. +function openCmd(path) { + return shCmd("exec xdg-open \"$1\"", [path]); +} + // Save a password to KWallet (used by the config page). kwallet-query reads the // secret from stdin; we feed it via printf from a positional param. The value // is in this process's argv for the brief write (unavoidable with a CLI-only diff --git a/package/contents/ui/ShareItem.qml b/package/contents/ui/ShareItem.qml new file mode 100644 index 0000000..115a7d8 --- /dev/null +++ b/package/contents/ui/ShareItem.qml @@ -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() + } + ] +} diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml new file mode 100644 index 0000000..b5949d8 --- /dev/null +++ b/package/contents/ui/main.qml @@ -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() + } + } + } + } +}