Skip to content

Commit 11634ea

Browse files
feat(plugin-manager): add Plugin Manager with README viewer
Full-featured plugin manager panel with two-column layout: - Left: browsable plugin list with Installed/Available/Sources tabs - Right: block-based markdown README viewer with security hardening - Tag filtering, fuzzy search, install/uninstall/update controls - Remote README fetching from GitHub for non-installed plugins - Python markdown parser (markdown-it-py) with Qt fallback
1 parent 12639c6 commit 11634ea

13 files changed

Lines changed: 2323 additions & 0 deletions
Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
import QtQuick
2+
import QtQuick.Controls
3+
import QtQuick.Layouts
4+
import qs.Commons
5+
import qs.Services.Noctalia
6+
import qs.Services.UI
7+
import qs.Widgets
8+
9+
ColumnLayout {
10+
id: root
11+
spacing: Style.marginL
12+
Layout.fillWidth: true
13+
implicitWidth: 0
14+
15+
property var pluginApi: null
16+
property string pluginSearchText: ""
17+
property string selectedTag: ""
18+
property int tagsRefreshCounter: 0
19+
20+
property string selectedPluginId: ""
21+
signal pluginSelected(string pluginId, string sourceUrl)
22+
property int availablePluginsRefreshCounter: 0
23+
24+
// Pseudo tags for filtering
25+
readonly property var pseudoTags: ["official", "downloaded", "notDownloaded"]
26+
27+
readonly property var availableTags: {
28+
void (root.tagsRefreshCounter);
29+
var tags = {};
30+
var plugins = PluginService.availablePlugins || [];
31+
for (var i = 0; i < plugins.length; i++) {
32+
var pluginTags = plugins[i].tags || [];
33+
for (var j = 0; j < pluginTags.length; j++) {
34+
tags[pluginTags[j]] = true;
35+
}
36+
}
37+
return Object.keys(tags).sort();
38+
}
39+
40+
function stripAuthorEmail(author) {
41+
if (!author) return "";
42+
var lastBracket = author.lastIndexOf("<");
43+
if (lastBracket >= 0) {
44+
return author.substring(0, lastBracket).trim();
45+
}
46+
return author;
47+
}
48+
49+
// Timer to check for updates after refresh starts
50+
Timer {
51+
id: checkUpdatesTimer
52+
interval: 100
53+
onTriggered: {
54+
PluginService.checkForUpdates();
55+
}
56+
}
57+
58+
Component.onDestruction: {
59+
checkUpdatesTimer.stop();
60+
}
61+
62+
function installPlugin(pluginMetadata) {
63+
var title = pluginApi?.tr("panel.title") ?? ""
64+
var msg = pluginApi?.tr("panel.installing") ?? ""
65+
msg = msg.replace("{plugin}", pluginMetadata.name)
66+
ToastService.showNotice(title, msg);
67+
68+
PluginService.installPlugin(pluginMetadata, false, function (success, error, registeredKey) {
69+
if (success) {
70+
var successMsg = pluginApi?.tr("panel.install-success") ?? ""
71+
successMsg = successMsg.replace("{plugin}", pluginMetadata.name)
72+
ToastService.showNotice(title, successMsg);
73+
PluginService.enablePlugin(registeredKey);
74+
} else {
75+
var errorMsg = pluginApi?.tr("panel.install-error") ?? ""
76+
errorMsg = errorMsg.replace("{error}", error || (pluginApi?.tr("panel.unknown-error") ?? ""))
77+
ToastService.showError(title, errorMsg);
78+
}
79+
});
80+
}
81+
82+
// Listen to plugin service signals
83+
Connections {
84+
target: PluginService
85+
86+
function onAvailablePluginsUpdated() {
87+
root.tagsRefreshCounter++;
88+
root.availablePluginsRefreshCounter++;
89+
90+
Qt.callLater(function () {
91+
PluginService.checkForUpdates();
92+
});
93+
}
94+
}
95+
96+
// Tag filter chips — wrapped in Item to prevent Flow's large implicitWidth
97+
Item {
98+
Layout.fillWidth: true
99+
implicitHeight: tagFilter.implicitHeight
100+
clip: true
101+
102+
NTagFilter {
103+
id: tagFilter
104+
width: parent.width
105+
tags: root.pseudoTags.concat(root.availableTags)
106+
selectedTag: root.selectedTag
107+
onSelectedTagChanged: root.selectedTag = selectedTag
108+
label: pluginApi?.tr("panel.filter-tags-label")
109+
description: pluginApi?.tr("panel.filter-tags-description")
110+
expanded: true
111+
112+
formatTag: function (tag) {
113+
if (tag === "")
114+
return pluginApi?.tr("panel.filter-all")
115+
if (tag === "official")
116+
return pluginApi?.tr("panel.official")
117+
if (tag === "downloaded")
118+
return pluginApi?.tr("panel.filter-downloaded")
119+
if (tag === "notDownloaded")
120+
return pluginApi?.tr("panel.filter-not-downloaded")
121+
return tag;
122+
}
123+
}
124+
}
125+
126+
// Search input with refresh button
127+
RowLayout {
128+
Layout.fillWidth: true
129+
spacing: Style.marginM
130+
131+
NTextInput {
132+
placeholderText: I18n.tr("placeholders.search")
133+
inputIconName: "search"
134+
text: root.pluginSearchText
135+
onTextChanged: root.pluginSearchText = text
136+
Layout.fillWidth: true
137+
}
138+
139+
NIconButton {
140+
icon: "refresh"
141+
tooltipText: pluginApi?.tr("panel.refresh")
142+
baseSize: Style.baseWidgetSize * 0.9
143+
onClicked: {
144+
PluginService.refreshAvailablePlugins();
145+
checkUpdatesTimer.restart();
146+
}
147+
}
148+
}
149+
150+
// Available plugins list
151+
ColumnLayout {
152+
spacing: Style.marginM
153+
Layout.fillWidth: true
154+
155+
Repeater {
156+
id: availablePluginsRepeater
157+
158+
model: {
159+
void (root.availablePluginsRefreshCounter);
160+
161+
var all = PluginService.availablePlugins || [];
162+
var filtered = [];
163+
164+
for (var i = 0; i < all.length; i++) {
165+
var plugin = all[i];
166+
var downloaded = plugin.downloaded || false;
167+
var pluginTags = plugin.tags || [];
168+
169+
if (root.selectedTag === "") {
170+
filtered.push(plugin);
171+
} else if (root.selectedTag === "official") {
172+
if (plugin.official === true)
173+
filtered.push(plugin);
174+
} else if (root.selectedTag === "downloaded") {
175+
if (downloaded)
176+
filtered.push(plugin);
177+
} else if (root.selectedTag === "notDownloaded") {
178+
if (!downloaded)
179+
filtered.push(plugin);
180+
} else {
181+
if (pluginTags.indexOf(root.selectedTag) >= 0) {
182+
filtered.push(plugin);
183+
}
184+
}
185+
}
186+
187+
// Apply fuzzy search
188+
var query = root.pluginSearchText.trim();
189+
if (query !== "") {
190+
var results = FuzzySort.go(query, filtered, {
191+
"keys": ["name", "description"],
192+
"limit": 50
193+
});
194+
filtered = [];
195+
for (var j = 0; j < results.length; j++) {
196+
filtered.push(results[j].obj);
197+
}
198+
} else {
199+
// Sort by lastUpdated (most recent first)
200+
filtered.sort(function (a, b) {
201+
var dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
202+
var dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
203+
return dateB - dateA;
204+
});
205+
}
206+
207+
// Move hello-world plugin to the end
208+
var helloWorldIndex = -1;
209+
for (var h = 0; h < filtered.length; h++) {
210+
if (filtered[h].id === "hello-world") {
211+
helloWorldIndex = h;
212+
break;
213+
}
214+
}
215+
if (helloWorldIndex >= 0) {
216+
var helloWorld = filtered.splice(helloWorldIndex, 1)[0];
217+
filtered.push(helloWorld);
218+
}
219+
220+
return filtered;
221+
}
222+
223+
delegate: NBox {
224+
Layout.fillWidth: true
225+
Layout.leftMargin: Style.borderS
226+
Layout.rightMargin: Style.borderS
227+
implicitHeight: Math.round(contentColumn.implicitHeight + Style.margin2L)
228+
color: modelData.id === root.selectedPluginId ? Color.mHover : Color.mSurface
229+
230+
MouseArea {
231+
anchors.fill: parent
232+
propagateComposedEvents: true
233+
cursorShape: Qt.PointingHandCursor
234+
onClicked: mouse => {
235+
root.pluginSelected(modelData.id, modelData.source?.url || "")
236+
mouse.accepted = false
237+
}
238+
}
239+
240+
ColumnLayout {
241+
id: contentColumn
242+
anchors.fill: parent
243+
anchors.margins: Style.marginL
244+
spacing: Style.marginS
245+
246+
// Row 1: icon, name, badge
247+
RowLayout {
248+
spacing: Style.marginM
249+
Layout.fillWidth: true
250+
251+
NIcon {
252+
icon: "plugin"
253+
pointSize: Style.fontSizeL
254+
color: Color.mPrimary
255+
}
256+
257+
NText {
258+
text: modelData.name
259+
color: Color.mPrimary
260+
elide: Text.ElideRight
261+
Layout.fillWidth: true
262+
}
263+
264+
// Official badge
265+
Rectangle {
266+
visible: modelData.official === true
267+
color: Color.mSecondary
268+
radius: Style.radiusXS
269+
implicitWidth: officialBadgeRow.implicitWidth + Style.margin2S
270+
implicitHeight: officialBadgeRow.implicitHeight + Style.margin2XS
271+
272+
RowLayout {
273+
id: officialBadgeRow
274+
anchors.centerIn: parent
275+
spacing: Style.marginXS
276+
277+
NIcon {
278+
icon: "official-plugin"
279+
pointSize: Style.fontSizeXXS
280+
color: Color.mOnSecondary
281+
}
282+
283+
NText {
284+
text: pluginApi?.tr("panel.official")
285+
font.pointSize: Style.fontSizeXXS
286+
font.weight: Style.fontWeightMedium
287+
color: Color.mOnSecondary
288+
}
289+
}
290+
}
291+
}
292+
293+
// Row 2: action buttons
294+
RowLayout {
295+
spacing: Style.marginXS
296+
Layout.fillWidth: true
297+
298+
NIconButton {
299+
icon: "external-link"
300+
baseSize: Style.baseWidgetSize * 0.7
301+
tooltipText: pluginApi?.tr("panel.open-plugin-page")
302+
onClicked: {
303+
var sourceUrl = modelData.source?.url || "";
304+
Qt.openUrlExternally(sourceUrl && !PluginRegistry.isMainSource(sourceUrl) ? sourceUrl : "https://noctalia.dev/plugins/" + modelData.id + "/");
305+
}
306+
}
307+
308+
// Downloaded indicator
309+
NIcon {
310+
icon: "circle-check"
311+
pointSize: Style.baseWidgetSize * 0.5
312+
color: Color.mPrimary
313+
visible: modelData.downloaded === true
314+
}
315+
316+
// Install button
317+
NIconButton {
318+
visible: modelData.downloaded === false && !PluginService.installingPlugins[modelData.id]
319+
icon: "download"
320+
baseSize: Style.baseWidgetSize * 0.7
321+
tooltipText: pluginApi?.tr("panel.install")
322+
onClicked: installPlugin(modelData)
323+
}
324+
325+
// Installing spinner
326+
NBusyIndicator {
327+
visible: !modelData.downloaded && (PluginService.installingPlugins[modelData.id] === true)
328+
size: Style.baseWidgetSize * 0.5
329+
running: visible
330+
}
331+
}
332+
333+
// Description
334+
NText {
335+
visible: modelData.description
336+
text: modelData.description || ""
337+
font.pointSize: Style.fontSizeXS
338+
color: Color.mOnSurface
339+
wrapMode: Text.WordWrap
340+
elide: Text.ElideNone
341+
Layout.fillWidth: true
342+
}
343+
344+
// Details: version, author
345+
RowLayout {
346+
spacing: Style.marginS
347+
Layout.fillWidth: true
348+
349+
NText {
350+
text: "v" + modelData.version
351+
font.pointSize: Style.fontSizeXS
352+
color: Color.mOnSurfaceVariant
353+
}
354+
355+
NText {
356+
text: "\u2022"
357+
font.pointSize: Style.fontSizeXS
358+
color: Color.mOnSurfaceVariant
359+
}
360+
361+
NText {
362+
text: stripAuthorEmail(modelData.author)
363+
font.pointSize: Style.fontSizeXS
364+
color: Color.mOnSurfaceVariant
365+
}
366+
367+
Item {
368+
Layout.fillWidth: true
369+
}
370+
}
371+
372+
// Source name
373+
NText {
374+
visible: modelData.source ? true : false
375+
text: modelData.source ? modelData.source.name : ""
376+
font.pointSize: Style.fontSizeXS
377+
color: Color.mOnSurfaceVariant
378+
}
379+
380+
// Last updated
381+
NText {
382+
visible: !!modelData.lastUpdated
383+
text: modelData.lastUpdated ? Time.formatRelativeTime(new Date(modelData.lastUpdated)) : ""
384+
font.pointSize: Style.fontSizeXS
385+
color: Color.mOnSurfaceVariant
386+
}
387+
}
388+
}
389+
}
390+
391+
NLabel {
392+
visible: availablePluginsRepeater.count === 0
393+
label: pluginApi?.tr("panel.no-plugins-available")
394+
Layout.fillWidth: true
395+
}
396+
}
397+
}

0 commit comments

Comments
 (0)