From d13854857e6998d7968e331acaf7849afba471af Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Thu, 21 May 2026 16:35:26 -0400 Subject: [PATCH] Add MultiSelect --- shell/Ui/MultiSelect.qml | 615 ++++++++++++++++++ shell/Ui/qmldir | 1 + shell/plugins/settings/SettingsPanel.qml | 7 + .../components/DynamicSettingsForm.qml | 71 ++ shell/shell.qml | 12 +- 5 files changed, 704 insertions(+), 2 deletions(-) create mode 100644 shell/Ui/MultiSelect.qml diff --git a/shell/Ui/MultiSelect.qml b/shell/Ui/MultiSelect.qml new file mode 100644 index 00000000..8869158f --- /dev/null +++ b/shell/Ui/MultiSelect.qml @@ -0,0 +1,615 @@ +import QtQuick +import QtQuick.Controls as QQC +import QtQuick.Window +import Quickshell.Io +import qs.Commons + +// Searchable multi-select dropdown. Trigger shape matches Dropdown / +// SearchableDropdown; the popup shows a search field, an optional refresh +// button, and a checkbox list. Click rows to toggle. Use when callers +// need to pick zero or more items from a (possibly long, possibly +// dynamic) list. +// +// Options are either: +// - static `options`: string[] or [{ value, label, description? }] +// - dynamic `optionsCommand`: argv array. The command's stdout is +// parsed as JSON when it trims to start with `[`, otherwise as +// one option per non-empty newline. Re-runs whenever the popup opens +// and via the refresh button. +// +// `values` is the persisted selection — always an array of strings. +// Emits `changed(values)` whenever the selection mutates. +Item { + id: root + + property string label: "" + property var values: [] + property var options: [] + property var optionsCommand: [] + property string optionsCommandCwd: "" + property string placeholderText: "Search..." + property string emptyText: "No options" + property string noSelectionText: "None selected" + property string triggerLabel: "" + property bool showLabel: true + + property color foreground: Color.popups.text + property color background: Color.popups.background + property color popupBorder: Color.popups.border + property color accent: Color.accent + property string fontFamily: Style.font.family + property int rowHeight: Style.spacing.controlHeight + property int popupRowHeight: Style.spacing.popupRowHeight + property int popupMinHeight: Style.spacing.searchablePopupMinHeight + + property bool hasCursor: false + + readonly property bool popupOpen: popup.opened + function open() { popup.open() } + function close() { popup.close() } + function toggle() { popup.opened ? popup.close() : popup.open() } + + signal changed(var values) + signal hovered(bool isHovered) + + // Loaded options after merging static + dynamic. Always normalized + // into the [{ value, label, description }] shape for delegate use. + property var resolvedOptions: [] + property bool loadingOptions: false + property string optionsError: "" + + function normalizeOption(o) { + if (o && typeof o === "object") { + return { + value: String(o.value), + label: String(o.label !== undefined ? o.label : o.value), + description: o.description ? String(o.description) : "" + } + } + var s = String(o) + return { value: s, label: s, description: "" } + } + + // QML schema arrays sometimes arrive as JSValue lists that fail + // `Array.isArray`. arrayFrom accepts anything array-like (`.length` + // numeric) and returns a real JS array so the rest of the component can + // rely on standard array operations. + function arrayFrom(v) { + if (!v || typeof v.length !== "number" || typeof v === "string") return [] + var out = [] + for (var i = 0; i < v.length; i++) out.push(v[i]) + return out + } + + function normalizeAll(arr) { + var out = [] + var src = arrayFrom(arr) + var seen = ({}) + for (var i = 0; i < src.length; i++) { + var n = normalizeOption(src[i]) + if (!n.value) continue + if (seen[n.value]) continue + seen[n.value] = true + out.push(n) + } + return out + } + + function valueSet() { + var set = ({}) + var arr = arrayFrom(values) + for (var i = 0; i < arr.length; i++) set[String(arr[i])] = true + return set + } + + function isSelected(value) { + var set = valueSet() + return !!set[String(value)] + } + + function toggleValue(value) { + var v = String(value) + var arr = arrayFrom(values) + var idx = arr.indexOf(v) + if (idx === -1) arr.push(v) + else arr.splice(idx, 1) + root.values = arr + root.changed(arr) + } + + function selectionLabel() { + var arr = arrayFrom(values) + if (arr.length === 0) return "" + var labels = [] + var byValue = ({}) + for (var i = 0; i < resolvedOptions.length; i++) + byValue[resolvedOptions[i].value] = resolvedOptions[i].label + for (var j = 0; j < arr.length; j++) + labels.push(byValue[String(arr[j])] || String(arr[j])) + if (labels.length <= 3) return labels.join(", ") + return arr.length + " selected" + } + + property var filtered: resolvedOptions + function recomputeFiltered() { + var q = searchField.text.toLowerCase() + if (!q) { filtered = resolvedOptions; return } + var out = [] + for (var i = 0; i < resolvedOptions.length; i++) { + var o = resolvedOptions[i] + if (o.label.toLowerCase().indexOf(q) !== -1 + || o.description.toLowerCase().indexOf(q) !== -1 + || o.value.toLowerCase().indexOf(q) !== -1) out.push(o) + } + filtered = out + } + + function rebuildFromStatic() { + resolvedOptions = normalizeAll(options) + recomputeFiltered() + } + + // Parse stdout from a dynamic optionsCommand into `{ options, error }`. + // Output starting with `[` is parsed strictly as JSON — a malformed array + // surfaces as an error rather than silently falling back to newline + // parsing, which would render the broken text as a literal option label. + // Output not starting with `[` is treated as one value per non-empty line. + function parseCommandOutput(text) { + var raw = String(text || "").trim() + if (raw === "") return { options: [], error: "" } + if (raw.charAt(0) === "[") { + try { + var parsed = JSON.parse(raw) + return { options: parsed, error: "" } + } catch (e) { + return { options: [], error: "Options command emitted invalid JSON" } + } + } + var out = [] + var lines = raw.split(/\r?\n/) + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim() + if (line !== "") out.push(line) + } + return { options: out, error: "" } + } + + // Monotonic request id so stale stdout/exit signals from a previous + // refresh can't clobber the resolvedOptions of a newer refresh, and + // a runaway command can be detected after a timeout. + property int refreshSeq: 0 + readonly property int refreshTimeoutMs: 6000 + + function refresh() { + var cmd = arrayFrom(optionsCommand) + if (cmd.length === 0) { + rebuildFromStatic() + return + } + refreshSeq++ + loadingOptions = true + optionsError = "" + optionsProcess.command = cmd + optionsProcess.workingDirectory = optionsCommandCwd + optionsProcess.running = false + optionsProcess.running = true + refreshTimeoutTimer.restart() + } + + Timer { + id: refreshTimeoutTimer + interval: root.refreshTimeoutMs + repeat: false + onTriggered: { + if (!root.loadingOptions) return + optionsProcess.running = false + root.loadingOptions = false + root.optionsError = "Options command timed out" + } + } + + onOptionsChanged: if (arrayFrom(optionsCommand).length === 0) rebuildFromStatic() + onOptionsCommandChanged: refresh() + Component.onCompleted: refresh() + + Process { + id: optionsProcess + running: false + command: [] + + property int seq: 0 + + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + if (optionsProcess.seq !== root.refreshSeq) return + var result = root.parseCommandOutput(text) + if (result.error) { + root.optionsError = result.error + root.resolvedOptions = root.normalizeAll(root.options) + } else { + var combined = root.arrayFrom(root.options) + for (var j = 0; j < result.options.length; j++) combined.push(result.options[j]) + root.resolvedOptions = root.normalizeAll(combined) + } + root.recomputeFiltered() + root.loadingOptions = false + refreshTimeoutTimer.stop() + } + } + + onRunningChanged: if (running) seq = root.refreshSeq + + onExited: function(exitCode, exitStatus) { + if (seq !== root.refreshSeq) return + refreshTimeoutTimer.stop() + if (exitCode !== 0) { + root.loadingOptions = false + root.optionsError = "Options command exited " + exitCode + } + } + } + + implicitWidth: Style.spacing.searchableDropdownWidth + implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight + + Column { + anchors.fill: parent + spacing: Style.spacing.labelGap + + Text { + visible: root.showLabel && root.label !== "" + text: root.label + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + + Rectangle { + id: trigger + width: parent.width + height: root.rowHeight + radius: Style.cornerRadius + + readonly property bool _focused: trigger.activeFocus + readonly property bool _hot: triggerHover.hovered || root.hasCursor + + color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent) + border.color: Style.controlBorder(trigger._focused, trigger._hot, root.foreground, root.accent) + border.width: Style.controlBorderWidth(trigger._focused, trigger._hot) + + activeFocusOnTab: true + + HoverHandler { + id: triggerHover + onHoveredChanged: root.hovered(hovered) + } + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space || event.key === Qt.Key_Down) { + popup.opened ? popup.close() : popup.open() + event.accepted = true + } else if (event.key === Qt.Key_Escape && popup.opened) { + popup.close(); event.accepted = true + } + } + + Text { + anchors.left: parent.left + anchors.right: chevron.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.spacing.controlPaddingX + anchors.rightMargin: Style.spacing.md + text: root.selectionLabel() || root.triggerLabel || root.noSelectionText + color: root.selectionLabel() ? root.foreground : Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + } + + Text { + id: chevron + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: Style.spacing.controlGap + text: "󰅀" + color: Qt.darker(root.foreground, 1.2) + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + trigger.forceActiveFocus() + popup.opened ? popup.close() : popup.open() + } + } + + QQC.Popup { + id: popup + // Reparent to the window's content item so the popup is free of any + // clipping ancestor (e.g. the bar settings dialog Flickable). Position + // and available height are recomputed on open and any time the + // trigger's geometry changes, since a binding on mapToItem alone + // won't reliably re-evaluate when ancestors scroll or resize. + parent: trigger.Window.window ? trigger.Window.window.contentItem : trigger + property real _anchorX: 0 + property real _anchorY: 0 + property real _availableBelow: 0 + readonly property real _windowHeight: parent ? parent.height : 0 + readonly property real _idealContent: resultList.contentHeight + Style.space(50) + readonly property real _maxRowsHeight: root.popupRowHeight * 6 + 5 * Style.spacing.labelGap + Style.space(50) + + function reposition() { + if (!parent) return + var p = trigger.mapToItem(parent, 0, trigger.height + Style.spacing.xxs) + _anchorX = p.x + _anchorY = p.y + _availableBelow = Math.max(0, _windowHeight - _anchorY - Style.space(12)) + } + + x: _anchorX + y: _anchorY + width: trigger.width + // Clamp to whatever fits below the trigger; don't force popupMinHeight + // when there isn't room, otherwise the popup overflows the window. + implicitHeight: Math.min(_availableBelow, _idealContent, _maxRowsHeight) + padding: Style.spacing.hairline + focus: true + + Connections { + target: trigger + function onXChanged() { popup.reposition() } + function onYChanged() { popup.reposition() } + function onWidthChanged() { popup.reposition() } + function onHeightChanged() { popup.reposition() } + } + + background: Rectangle { + color: root.background + border.color: root.popupBorder + border.width: Style.normalBorderWidth + radius: Style.cornerRadius + } + + onOpened: { + reposition() + searchField.text = "" + root.refresh() + root.recomputeFiltered() + Qt.callLater(function() { searchField.forceActiveFocus() }) + } + onClosed: searchField.text = "" + + contentItem: Column { + spacing: 0 + + Item { + id: searchHeader + width: parent.width + height: root.popupRowHeight + Style.spacing.controlPaddingX + + Row { + anchors.fill: parent + anchors.margins: Style.spacing.md + spacing: Style.spacing.rowGap + + TextField { + id: searchField + width: parent.width - refreshButton.width - parent.spacing + height: parent.height + placeholderText: root.placeholderText + foreground: root.foreground + accent: root.accent + font.family: root.fontFamily + font.pixelSize: Style.font.body + + onTextChanged: { + root.recomputeFiltered() + if (resultList.count > 0) resultList.currentIndex = 0 + } + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + popup.close(); event.accepted = true + } else if (event.key === Qt.Key_Down) { + if (resultList.count > 0) { + resultList.currentIndex = 0 + resultList.forceActiveFocus() + } + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (resultList.count > 0) { + resultList.currentIndex = 0 + resultList.toggleCurrent() + } + event.accepted = true + } + } + } + + Rectangle { + id: refreshButton + visible: root.arrayFrom(root.optionsCommand).length > 0 + enabled: !root.loadingOptions + width: parent.height + height: parent.height + radius: Style.cornerRadius + color: refreshHover.hovered + ? Style.hoverFillFor(root.foreground, root.accent) + : Style.normalFillFor(root.foreground, root.accent) + border.color: refreshHover.hovered + ? Style.hoverBorderFor(root.foreground, root.accent) + : Style.normalBorderFor(root.foreground, root.accent) + border.width: refreshHover.hovered ? Style.hoverBorderWidth : Style.normalBorderWidth + + Text { + anchors.centerIn: parent + text: root.loadingOptions ? "󰦖" : "󰑐" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + + RotationAnimator on rotation { + running: root.loadingOptions + from: 0; to: 360 + duration: 800 + loops: Animation.Infinite + } + } + + HoverHandler { id: refreshHover } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.refresh() + } + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Util.alpha(root.foreground, 0.10) + } + + Item { + width: parent.width + height: popup.height - searchHeader.height - Style.spacing.xxs - 1 + + Text { + anchors.centerIn: parent + visible: resultList.count === 0 + text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText) + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: Style.font.body + } + + ListView { + id: resultList + anchors.fill: parent + spacing: Style.spacing.labelGap + clip: true + boundsBehavior: Flickable.StopAtBounds + model: root.filtered + currentIndex: -1 + keyNavigationEnabled: false + + function toggleCurrent() { + if (currentIndex < 0 || currentIndex >= root.filtered.length) return + root.toggleValue(root.filtered[currentIndex].value) + } + + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + popup.close(); event.accepted = true + } else if (event.key === Qt.Key_Down || event.text === "j") { + if (resultList.currentIndex >= resultList.count - 1) { + event.accepted = true; return + } + resultList.currentIndex = resultList.currentIndex + 1 + event.accepted = true + } else if (event.key === Qt.Key_Up || event.text === "k") { + if (resultList.currentIndex <= 0) { + searchField.forceActiveFocus() + event.accepted = true; return + } + resultList.currentIndex = resultList.currentIndex - 1 + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space) { + resultList.toggleCurrent(); event.accepted = true + } + } + + delegate: Rectangle { + required property var modelData + required property int index + + readonly property bool selected: root.isSelected(modelData.value) + + width: resultList.width + height: Math.max(root.popupRowHeight, rowContent.implicitHeight + Style.spacing.rowPaddingX) + color: index === resultList.currentIndex + ? Style.hoverFillFor(root.foreground, root.accent) + : "transparent" + + Row { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.spacing.controlPaddingX + anchors.rightMargin: Style.spacing.controlPaddingX + spacing: Style.spacing.rowGap + + Rectangle { + id: checkbox + width: Style.space(16) + height: Style.space(16) + radius: Math.max(2, Style.cornerRadius / 2) + anchors.verticalCenter: parent.verticalCenter + color: selected ? Style.selectedFillFor(root.foreground, root.accent) : "transparent" + border.color: selected + ? Style.selectedBorderFor(root.foreground, root.accent) + : Style.normalBorderFor(root.foreground, root.accent) + border.width: selected ? Style.selectedBorderWidth : Style.normalBorderWidth + + Text { + anchors.centerIn: parent + visible: selected + text: "✓" + color: Style.selectedStateColor(root.foreground, root.accent) + font.family: root.fontFamily + font.pixelSize: Math.round(checkbox.height * 0.85) + font.bold: true + } + } + + Column { + width: parent.width - checkbox.width - parent.spacing + anchors.verticalCenter: parent.verticalCenter + spacing: Style.spacing.xxs + + Text { + text: modelData.label + color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + elide: Text.ElideRight + width: parent.width + } + Text { + visible: text !== "" + text: modelData.description + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + width: parent.width + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPositionChanged: resultList.currentIndex = parent.index + onClicked: root.toggleValue(modelData.value) + } + } + } + } + } + } + } + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 69940561..a386a4c3 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -7,6 +7,7 @@ ButtonGroup 1.0 ButtonGroup.qml CursorSurface 1.0 CursorSurface.qml Dropdown 1.0 Dropdown.qml KeyboardPanel 1.0 KeyboardPanel.qml +MultiSelect 1.0 MultiSelect.qml NumberField 1.0 NumberField.qml Panel 1.0 Panel.qml PanelActionButton 1.0 PanelActionButton.qml diff --git a/shell/plugins/settings/SettingsPanel.qml b/shell/plugins/settings/SettingsPanel.qml index bae6721a..aa302e1e 100644 --- a/shell/plugins/settings/SettingsPanel.qml +++ b/shell/plugins/settings/SettingsPanel.qml @@ -327,12 +327,18 @@ Item { allowMultiple: meta.allowMultiple === true, settingsForm: meta.settingsForm || "", schema: Array.isArray(meta.schema) ? meta.schema : [], + sourceDir: manifest.__sourceDir || "", source: "plugin" } } return {} } + function widgetSourceDir(id) { + var meta = widgetMetadata(id) + return meta && meta.sourceDir ? String(meta.sourceDir) : "" + } + function widgetName(id) { var rev = catalogRevision var meta = widgetMetadata(id) @@ -1133,6 +1139,7 @@ Item { id: dynamicSettingsComponent Cmp.DynamicSettingsForm { schema: root.widgetSchema(entry.id || "") + pluginSourceDir: root.widgetSourceDir(entry.id || "") foreground: root.foreground fontFamily: root.fontFamily } diff --git a/shell/plugins/settings/components/DynamicSettingsForm.qml b/shell/plugins/settings/components/DynamicSettingsForm.qml index a9e13b85..4419d994 100644 --- a/shell/plugins/settings/components/DynamicSettingsForm.qml +++ b/shell/plugins/settings/components/DynamicSettingsForm.qml @@ -13,6 +13,9 @@ Column { property color foreground: Color.popups.text property color accent: Color.accent property string fontFamily: Style.font.family + // Resolved on disk path of the plugin that owns this form, used to + // resolve relative argv entries in a multiselect's `optionsCommand`. + property string pluginSourceDir: "" spacing: Style.spacing.xl width: parent ? parent.width : 0 @@ -26,10 +29,47 @@ Column { case "integer": case "number": return field.min !== undefined ? field.min : 0 case "enum": return field.options && field.options.length > 0 ? field.options[0] : "" + case "multiselect": return [] default: return "" } } + // Resolve a multiselect's optionsCommand argv against the plugin's source + // directory. The first entry is treated as a path relative to the plugin + // dir unless it is absolute or begins with ".". Subsequent entries pass + // through unchanged. + // Resolve a multiselect's optionsCommand argv against the plugin's source + // directory. The first argv entry must be a relative path inside the + // plugin dir (no leading `/`, no `..` segments, no empty segments); + // anything else is rejected and the field falls back to whatever static + // `options` it has. Subsequent argv entries pass through unchanged. We + // avoid `Array.isArray` because schema-supplied arrays sometimes arrive + // as QML JSValue lists that don't satisfy it; iterating by `.length` + // works for both real arrays and JSValue lists. + function resolveOptionsCommand(field) { + var oc = field ? field.optionsCommand : undefined + if (!oc || typeof oc.length !== "number" || oc.length === 0) return [] + var argv = [] + for (var i = 0; i < oc.length; i++) argv.push(String(oc[i])) + if (!pluginSourceDir) return [] + var head = argv[0] + if (head.length === 0 || head.charAt(0) === "/") { + console.warn("DynamicSettingsForm: optionsCommand must be a path relative to the plugin dir, got: " + head) + return [] + } + var rel = head.replace(/^\.\//, "") + var segments = rel.split("/") + for (var s = 0; s < segments.length; s++) { + if (segments[s] === ".." || segments[s] === "") { + console.warn("DynamicSettingsForm: optionsCommand may not contain '..' or empty segments: " + head) + return [] + } + } + var dir = pluginSourceDir.replace(/\/$/, "") + argv[0] = dir + "/" + rel + return argv + } + Repeater { model: root.schema @@ -68,6 +108,7 @@ Column { case "enum": return enumField case "integer": return integerField case "number": return numberField + case "multiselect": return multiselectField default: return stringField } } @@ -168,6 +209,36 @@ Column { } } + Component { + id: multiselectField + + Ui.MultiSelect { + property string fieldKey: "" + property var field: ({}) + + width: parent.width + foreground: root.foreground + accent: root.accent + fontFamily: root.fontFamily + showLabel: false + // MultiSelect's arrayFrom() tolerates JSValue-style schema arrays, so + // we can hand it `field.options` directly without an Array.isArray + // guard that would silently drop JSValue lists. + options: field ? (field.options || []) : [] + optionsCommand: root.resolveOptionsCommand(field) + optionsCommandCwd: root.pluginSourceDir + placeholderText: field && field.placeholderText ? String(field.placeholderText) : "Search..." + emptyText: field && field.emptyText ? String(field.emptyText) : "No options" + noSelectionText: field && field.noSelectionText ? String(field.noSelectionText) : "None selected" + values: { + var v = root.currentValue(field) + return v ? v : [] + } + + onChanged: function(arr) { if (fieldKey) root.fieldChanged(fieldKey, arr) } + } + } + Component { id: numberField diff --git a/shell/shell.qml b/shell/shell.qml index 428eba55..21efcf9c 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -520,8 +520,6 @@ ShellRoot { console.warn("Plugin " + manifest.id + " has no barWidget entry point") continue } - if (existing && existing.url === url && shell.barWidgetRegistry.has(registryKey)) continue - var meta = manifest.barWidget || {} meta = { displayName: meta.displayName || manifest.name, @@ -531,9 +529,19 @@ ShellRoot { defaults: meta.defaults || {}, schema: meta.schema || [], pluginId: manifest.id, + sourceDir: manifest.__sourceDir || "", source: "plugin" } + // If the component URL is unchanged, just refresh the metadata in + // place. We can't skip this even when the URL matches: manifests can + // change schema, defaults, or sourceDir between rescans, and the + // settings panel reads metadata from the registry. + if (existing && existing.url === url && shell.barWidgetRegistry.has(registryKey)) { + shell.barWidgetRegistry.register(registryKey, existing.component, meta) + continue + } + loadPluginWidget(registryKey, url, meta) }