Merge branch 'feat/plugin-feature' into feature/speed-dial

Adapt the speed dial's ActionRegistry to the collapsed
get_plugin_capability(PluginCapabilityId) overload, and restore the
script success/skipped status message the dialog lost when its
PluginScriptRunner refactor was superseded by ActionRegistry.
This commit is contained in:
SoftFever
2026-07-17 19:32:44 +08:00
58 changed files with 4375 additions and 671 deletions

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Plugin configuration</title>
<link rel="stylesheet" href="styles.css">
<!-- The shared dialog sheets, theme.css last so its host-injected variables win. -->
<link rel="stylesheet" type="text/css" href="../../include/global.css">
<link rel="stylesheet" type="text/css" href="../css/common.css">
<link rel="stylesheet" type="text/css" href="../css/theme.css">
</head>
<body>
<main class="page">
<header class="page-header">
<h1 id="pagePresetName" class="page-title"></h1>
</header>
<div id="configEmpty" class="detail-empty">This preset does not use any plugin capabilities</div>
<div id="configLayout" class="config-layout" hidden>
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
aria-label="Capabilities used by this preset"></div>
<div class="config-view">
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
<div id="configEditor" class="config-editor" hidden>
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
</div>
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML runs in
an opaque origin and reaches the host only through the injected window.orca bridge. -->
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
<div id="configFooter" class="config-view-footer" hidden>
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
<div class="config-actions">
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
title="Discard this preset's override and use the global configuration again">
Restore defaults
</button>
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
</div>
</div>
</div>
</div>
<footer id="statusBar" class="status-bar is-empty">
<span id="statusText" class="status-text"></span>
</footer>
</main>
<script src="index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,437 @@
// Capability rows the active preset uses, as PluginConfig::capabilities_payload emits them:
// {plugin_key, name, type, type_key, has_config_ui}.
let capabilities = [];
// The selected row's identity; plugin_key is part of it because this list spans plugins.
let selectedPluginKey = "";
let selectedCapabilityName = "";
let selectedCapabilityType = "";
let selectedHasPresetOverride = false;
let selectedReadOnly = false;
function SafeJsonParse(text) {
try {
return JSON.parse(text);
} catch (err) {
return null;
}
}
function SendWXMessage(message) {
if (window.wx && typeof window.wx.postMessage === "function")
window.wx.postMessage(message);
}
function SendMessage(command, payload = {}) {
const message = {
sequence_id: Math.round(Date.now() / 1000),
command: command
};
Object.keys(payload).forEach((key) => {
message[key] = payload[key];
});
SendWXMessage(JSON.stringify(message));
}
function HandleStudio(value) {
const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
if (!payload || typeof payload !== "object")
return;
if (payload.command === "list_capabilities") {
ApplyCapabilities(payload);
} else if (payload.command === "status_message") {
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
} else if (payload.command === "capability_config") {
ApplyCapabilityConfig(payload);
} else if (payload.command === "capability_config_saved") {
ApplyCapabilityConfigSaved(payload);
}
}
function ShowStatusMessage(message, level) {
const bar = document.getElementById("statusBar");
const text = document.getElementById("statusText");
if (!bar || !text)
return;
const normalizedLevel = ["success", "warn", "error", "info"].includes(level) ? level : "info";
text.textContent = message;
text.title = message;
bar.classList.remove("is-empty", "level-success", "level-warn", "level-error", "level-info");
bar.classList.add(`level-${normalizedLevel}`);
}
function ApplyCapabilities(payload) {
capabilities = Array.isArray(payload.data) ? payload.data : [];
const presetName = document.getElementById("pagePresetName");
if (presetName)
presetName.textContent = String(payload.preset_name || "");
RenderCapabilities();
}
function IsSameCapability(capability) {
return String(capability.plugin_key || "") === selectedPluginKey
&& String(capability.name || "") === selectedCapabilityName
&& String(capability.type_key || "") === selectedCapabilityType;
}
function RenderCapabilities() {
const empty = document.getElementById("configEmpty");
const layout = document.getElementById("configLayout");
const sidebar = document.getElementById("configSidebar");
if (!empty || !layout || !sidebar)
return;
if (capabilities.length === 0) {
empty.hidden = false;
layout.hidden = true;
sidebar.replaceChildren();
ClearCapabilityConfigView();
selectedPluginKey = "";
selectedCapabilityName = "";
selectedCapabilityType = "";
return;
}
empty.hidden = true;
layout.hidden = false;
// Keep the selection across a refresh if the capability is still there, else select the first.
if (!capabilities.some(IsSameCapability)) {
selectedPluginKey = String(capabilities[0].plugin_key || "");
selectedCapabilityName = String(capabilities[0].name || "");
selectedCapabilityType = String(capabilities[0].type_key || "");
ClearCapabilityConfigView();
RequestCapabilityConfig();
}
sidebar.replaceChildren();
for (const capability of capabilities) {
const item = document.createElement("button");
item.type = "button";
item.className = "config-cap";
item.dataset.pluginKey = String(capability.plugin_key || "");
item.dataset.capabilityName = String(capability.name || "");
item.dataset.capabilityType = String(capability.type_key || "");
item.setAttribute("role", "option");
const isSelected = IsSameCapability(capability);
item.classList.toggle("selected", isSelected);
item.setAttribute("aria-selected", isSelected ? "true" : "false");
const label = document.createElement("span");
label.className = "config-cap-name";
label.textContent = String(capability.name || "");
item.appendChild(label);
const type = document.createElement("span");
type.className = "config-cap-type";
type.textContent = String(capability.type || "");
item.appendChild(type);
sidebar.appendChild(item);
}
}
function OnConfigSidebarClick(event) {
const item = event.target.closest(".config-cap");
if (!item)
return;
const pluginKey = String(item.dataset.pluginKey || "");
const name = String(item.dataset.capabilityName || "");
const typeKey = String(item.dataset.capabilityType || "");
if (!name || (pluginKey === selectedPluginKey && name === selectedCapabilityName && typeKey === selectedCapabilityType))
return;
selectedPluginKey = pluginKey;
selectedCapabilityName = name;
selectedCapabilityType = typeKey;
// The native reply is async: clear now so the old config cannot appear under the new selection.
ClearCapabilityConfigView();
RequestCapabilityConfig();
RenderCapabilities();
}
// Config editor: the host's JSON editor, or the capability's own HTML UI in a sandboxed frame.
// Both edit the same stored config; the page renders what the native side sends.
// Replies are async: apply one only if it still matches the selected row (plugin_key included,
// since this list spans plugins), so a stale reply never lands under another capability.
function IsCurrentCapability(payload) {
return String(payload?.plugin_key || "") === selectedPluginKey
&& String(payload?.capability_name || "") === selectedCapabilityName
&& String(payload?.capability_type || "") === selectedCapabilityType;
}
function RequestCapabilityConfig() {
if (!selectedPluginKey || !selectedCapabilityName)
return;
SendMessage("get_capability_config", {
plugin_key: selectedPluginKey,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType
});
}
// Empties both editors and the footer, so nothing from the previous capability lingers while the
// next one is in flight.
function ClearCapabilityConfigView() {
const editor = document.getElementById("configEditor");
const custom = document.getElementById("configCustom");
const text = document.getElementById("configText");
const error = document.getElementById("configError");
const footer = document.getElementById("configFooter");
if (editor)
editor.hidden = true;
if (custom) {
custom.hidden = true;
custom.removeAttribute("srcdoc");
}
if (text)
text.value = "";
if (error) {
error.hidden = true;
error.textContent = "";
}
if (footer)
footer.hidden = true;
selectedHasPresetOverride = false;
selectedReadOnly = false;
SetConfigValidation("");
}
// A read-only capability cannot be saved, and there is nothing to restore until the preset overrides
// the global configuration.
function UpdateConfigActions(payload) {
selectedHasPresetOverride = payload?.has_preset_override === true;
selectedReadOnly = payload?.read_only === true;
const save = document.getElementById("configSaveBtn");
const restore = document.getElementById("configRestoreBtn");
if (save)
save.disabled = selectedReadOnly;
if (restore)
restore.disabled = selectedReadOnly || !selectedHasPresetOverride;
}
function ApplyCapabilityConfig(payload) {
if (!IsCurrentCapability(payload))
return;
const editor = document.getElementById("configEditor");
const custom = document.getElementById("configCustom");
const text = document.getElementById("configText");
const error = document.getElementById("configError");
const message = String(payload?.error || "");
if (error) {
error.textContent = message;
error.hidden = message === "";
}
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
const html = String(payload?.custom_html || "");
UpdateConfigActions(payload);
// The footer belongs to the JSON editor. A custom UI owns its whole surface, including whatever
// save/restore controls it wants, and reaches the host through the window.orca bridge.
const footer = document.getElementById("configFooter");
if (footer)
footer.hidden = html !== "";
if (html) {
if (custom) {
custom.hidden = false;
custom.srcdoc = BuildCustomConfigDocument(html, config);
}
if (editor)
editor.hidden = true;
return;
}
// Default editor: any reason a custom UI is unavailable already arrived in payload.error.
if (custom) {
custom.hidden = true;
custom.removeAttribute("srcdoc");
}
if (editor)
editor.hidden = false;
if (text)
text.value = JSON.stringify(config, null, 2);
SetConfigValidation("");
}
function SetConfigValidation(message) {
const node = document.getElementById("configValidation");
const save = document.getElementById("configSaveBtn");
if (node) {
node.textContent = message;
node.classList.toggle("invalid", message !== "");
}
// Invalid JSON is never saved: Save is the only way to persist. The native side re-validates.
if (save)
save.disabled = selectedReadOnly || message !== "";
}
function ValidateConfigText() {
const text = document.getElementById("configText");
if (!text)
return false;
try {
JSON.parse(text.value);
SetConfigValidation("");
return true;
} catch (err) {
SetConfigValidation(String(err?.message || "Invalid JSON"));
return false;
}
}
function SaveCapabilityConfig() {
if (!selectedPluginKey || !selectedCapabilityName)
return;
const text = document.getElementById("configText");
if (!text)
return;
if (!ValidateConfigText())
return;
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
SendMessage("save_capability_config", {
plugin_key: selectedPluginKey,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType,
config: text.value
});
}
// "Restore defaults" here drops the preset's override, so the capability falls back to the global
// configuration. The native side confirms, then re-sends the config that is now effective.
function RestoreCapabilityConfig() {
if (!selectedPluginKey || !selectedCapabilityName || !selectedHasPresetOverride)
return;
SendMessage("remove_preset_override", {
plugin_key: selectedPluginKey,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType
});
}
function ApplyCapabilityConfigSaved(payload) {
if (!IsCurrentCapability(payload))
return;
const error = document.getElementById("configError");
const message = String(payload?.error || "");
if (error) {
error.textContent = message;
error.hidden = message === "";
}
if (payload?.ok !== true)
return;
// Reload from what was persisted, not from what was typed.
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
const custom = document.getElementById("configCustom");
const text = document.getElementById("configText");
if (custom && !custom.hidden && custom.contentWindow)
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
else if (text)
text.value = JSON.stringify(config, null, 2);
SetConfigValidation("");
}
// The whole host surface a custom config UI gets: read the config, save one, drop the preset's
// override, and be told when either lands. The frame is sandboxed into an opaque origin, so this
// bridge is its only channel.
function BuildCustomConfigDocument(html, config) {
// Inlined into a <script>: a stored "</script>" would close the tag early, so escape "<" — the
// literal stays valid JSON.
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
const bridge = `<script>
(function () {
var handlers = [];
var current = ${seed};
window.orca = {
getConfig: function () { return current; },
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
onConfig: function (cb) {
if (typeof cb !== "function") return;
handlers.push(cb);
try { cb(current); } catch (e) {}
}
};
window.addEventListener("message", function (event) {
if (!event.data || event.data.__orca !== "config") return;
current = event.data.config || {};
handlers.forEach(function (handler) {
try { handler(current); } catch (e) {}
});
});
})();
<\/script>`;
return bridge + html;
}
function OnCustomConfigMessage(event) {
const custom = document.getElementById("configCustom");
// Only the frame we created, and only while it is actually showing.
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
return;
const data = event.data;
if (!data || !selectedPluginKey || !selectedCapabilityName)
return;
if (data.__orca === "save") {
SendMessage("save_capability_config", {
plugin_key: selectedPluginKey,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType,
config: data.config === undefined ? {} : data.config
});
return;
}
if (data.__orca === "restore")
RestoreCapabilityConfig();
}
document.addEventListener("DOMContentLoaded", () => {
const sidebar = document.getElementById("configSidebar");
if (sidebar)
sidebar.addEventListener("click", OnConfigSidebarClick);
const saveBtn = document.getElementById("configSaveBtn");
if (saveBtn)
saveBtn.addEventListener("click", SaveCapabilityConfig);
const restoreBtn = document.getElementById("configRestoreBtn");
if (restoreBtn)
restoreBtn.addEventListener("click", RestoreCapabilityConfig);
const text = document.getElementById("configText");
if (text)
text.addEventListener("input", ValidateConfigText);
// The custom UI is sandboxed into an opaque origin, so postMessage is its only channel.
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
// sandboxed), and ignores anything else.
window.addEventListener("message", OnCustomConfigMessage);
SendMessage("request_capabilities");
});

View File

@@ -0,0 +1,251 @@
/* Buttons (ButtonStyleRegular/ButtonStyleConfirm/ButtonTypeChoice), scrollbars (.thin-scroll) and
the host-injected theme contract (--bg, --text, --border, --panel, --muted, --plugin-status-*,
...) all come from the shared sheets linked in index.html (global.css, common.css, theme.css —
the same three every resources/web/dialog/* page links). This file only carries what is unique
to this page: the fixed-size reset those shared sheets assume, the new page chrome, and the
config-related rules ported from PluginsDialog/styles.css (its Config tab uses the same classes).
*/
/* common.css hardcodes body to the PluginsDialog-era fixed 820x660 with overflow:hidden; this
dialog is resizable/maximizable, so neutralize that the same way PluginsDialog/styles.css does. */
html,
body {
width: 100% !important;
height: 100%;
max-width: none !important;
max-height: none !important;
margin: 0;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
.page {
display: flex;
flex-direction: column;
height: 100vh;
padding: 16px;
box-sizing: border-box;
gap: 12px;
}
.page-header { flex: 0 0 auto; }
.page-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
/* ---- Ported from resources/web/dialog/PluginsDialog/styles.css (Config tab) ---- */
.detail-empty {
padding: 18px 8px;
color: var(--muted);
}
.detail-empty[hidden] {
display: none;
}
.config-layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
height: 100%;
min-height: 0;
padding: 6px;
box-sizing: border-box;
flex: 1 1 auto;
}
.config-layout[hidden] {
display: none;
}
.config-sidebar {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
border-right: 1px solid var(--border-soft);
}
.config-cap {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.config-cap:hover {
background: var(--row-hover);
}
.config-cap.selected {
background: var(--row-selected);
border-color: var(--row-selected-outline);
}
.config-cap-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-cap-type {
color: var(--muted);
font-size: 11px;
}
.config-view {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
min-width: 0;
}
.config-error {
padding: 6px 8px;
border-radius: 4px;
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
font-size: 12px;
}
.config-error[hidden] {
display: none;
}
.config-editor {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.config-editor[hidden] {
display: none;
}
.config-textarea {
flex: 1;
min-height: 0;
padding: 8px;
box-sizing: border-box;
/* common.css applies `user-select: none` to *, so without this the user could type into the
editor but not select, drag or copy what they had typed. */
-webkit-user-select: text;
user-select: text;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
resize: none;
white-space: pre;
overflow: auto;
}
.config-textarea:focus {
outline: none;
border-color: var(--main-color);
}
.config-custom {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.config-custom[hidden] {
display: none;
}
.config-view-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.config-view-footer[hidden] {
display: none;
}
.config-actions {
display: flex;
align-items: center;
gap: 8px;
}
.config-actions > button[hidden] {
display: none;
}
.config-validation {
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-validation.invalid {
color: var(--plugin-status-danger);
}
/* Footer status bar: single-line, fixed-height strip mirroring PluginsDialog's. */
.status-bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 6px 14px;
box-sizing: border-box;
border-top: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font-size: 13px;
}
.status-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.status-bar.level-success .status-text {
color: var(--plugin-status-ok);
}
.status-bar.level-error .status-text {
color: var(--plugin-status-danger);
}
.status-bar.level-warn .status-text {
color: var(--plugin-status-warn);
}

View File

@@ -81,12 +81,19 @@
<div id="pluginList" class="body thin-scroll"></div>
</section>
<!-- Drag to redistribute the dialog's height between the list and the details pane; the hit
area is the whole strip, the visible divider is the line drawn inside it. -->
<div id="paneSplitter" class="pane-splitter" role="separator" aria-orientation="horizontal"
aria-label="Resize the plugin list" title="Drag to resize; double-click to reset"></div>
<section class="pane details-pane">
<div class="detail-tabs" role="tablist" aria-label="Plugin details">
<button id="pluginInfoTab" class="detail-tab active" type="button" role="tab"
aria-selected="true" aria-controls="pluginInfoPanel" data-tab="plugin-info">Plugin Info</button>
<button id="descriptionTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="descriptionPanel" data-tab="description">Description</button>
<button id="configTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="configPanel" data-tab="config">Config</button>
<button id="changelogTab" class="detail-tab" type="button" role="tab" tabindex="-1"
aria-selected="false" aria-controls="changelogPanel" data-tab="changelog">Changelog</button>
<button id="diagnosticsTab" class="detail-tab" type="button" role="tab" tabindex="-1"
@@ -139,6 +146,39 @@
<div id="detailDescription" class="detail-description">No description available</div>
</section>
<section id="configPanel" class="detail-tab-panel" role="tabpanel" tabindex="0"
aria-labelledby="configTab" data-panel="config" hidden>
<div id="configEmpty" class="detail-empty">Select a plugin to configure its capabilities</div>
<div id="configLayout" class="config-layout" hidden>
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
aria-label="Configurable capabilities"></div>
<div class="config-view">
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
<div id="configEditor" class="config-editor" hidden>
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
</div>
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML
runs in an opaque origin and reaches the host only through the injected
window.orca bridge. -->
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
<!-- JSON-editor chrome only: a custom UI renders its own save/restore controls and
drives them through the window.orca bridge. -->
<div id="configFooter" class="config-view-footer" hidden>
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
<div class="config-actions">
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
title="Discard the settings saved for this capability and restore the plugin's defaults">
Restore defaults
</button>
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
</div>
</div>
</div>
</div>
</section>
<section id="changelogPanel" class="detail-tab-panel thin-scroll" role="tabpanel" tabindex="0"
aria-labelledby="changelogTab" data-panel="changelog" hidden>
<table id="changelogTable" class="detail-table changelog-table" aria-label="Plugin changelog">

View File

@@ -12,21 +12,35 @@ const pluginInstallActions = {
let expandedPluginIds = new Set();
// why: transient per-search override on top of expandedPluginIds. A search
// auto-expands rows whose capabilities match, display-only. This lets a
// triangle click during search collapse/reopen such a row without touching
// the base (id -> bool).
// why: transient per-search override (id -> bool) on top of expandedPluginIds. A search auto-expands
// matching rows, so a triangle click during search must not touch the base expand state.
let searchExpandOverride = new Map();
let selectedPluginId = "";
let contextPluginId = "";
let activeDetailTab = "plugin-info";
let selectedInstallAction = "explore";
// Config tab: the capability whose config is shown. Name and type together address it natively.
let selectedCapabilityName = "";
let selectedCapabilityType = "";
// The plugin that selection belongs to. Capability names are only unique within a plugin, so
// without the owner, selecting another plugin could keep the selection and show the wrong config.
let configPluginId = "";
let pluginList = null;
let ctxMenu = null;
let exploreMenu = null;
let exploreMenuButton = null;
// Split pane. The ratio is the list's share of the content height, so it survives a dialog resize.
const SPLIT_STORAGE_KEY = "orca.plugins.split_ratio";
const SPLIT_DEFAULT_RATIO = 0.62;
const SPLIT_MIN_LIST_PX = 180;
const SPLIT_MIN_DETAILS_PX = 160;
let contentPane = null;
let paneSplitter = null;
let splitRatio = SPLIT_DEFAULT_RATIO;
function OnInit() {
pluginList = document.getElementById("pluginList");
ctxMenu = document.getElementById("ctxMenu");
@@ -60,6 +74,23 @@ function OnInit() {
pluginList?.addEventListener("contextmenu", OnPluginContextMenu);
ctxMenu?.addEventListener("click", OnContextMenuClick);
InitPaneSplitter();
document.getElementById("configSidebar")?.addEventListener("click", OnConfigSidebarClick);
document.getElementById("configSaveBtn")?.addEventListener("click", SaveCapabilityConfig);
document.getElementById("configRestoreBtn")?.addEventListener("click", RestoreCapabilityConfig);
const configText = document.getElementById("configText");
// why: common.js cancels every key at the document level (returnValue=false) to block webview
// shortcuts, which also swallows typing; don't bubble to it, so the textarea stays editable.
// Same treatment as the search field (see plugin-search.js).
configText?.addEventListener("keydown", (event) => event.stopPropagation());
configText?.addEventListener("input", ValidateConfigText);
// The custom UI is sandboxed into an opaque origin, so postMessage is its only channel.
// OnCustomConfigMessage matches on the frame's contentWindow, not the origin ("null" when
// sandboxed), and ignores anything else.
window.addEventListener("message", OnCustomConfigMessage);
document.addEventListener("click", (event) => {
if (!event.target.closest(".ctx"))
HideContextMenu();
@@ -78,6 +109,87 @@ function OnInit() {
RequestPlugins();
}
function InitPaneSplitter() {
contentPane = document.querySelector(".content");
paneSplitter = document.getElementById("paneSplitter");
if (!contentPane || !paneSplitter)
return;
ApplySplitRatio(ReadStoredSplitRatio());
paneSplitter.addEventListener("pointerdown", OnSplitterPointerDown);
paneSplitter.addEventListener("dblclick", () => {
ApplySplitRatio(SPLIT_DEFAULT_RATIO);
StoreSplitRatio(splitRatio);
});
// A resized dialog changes what the ratio is a ratio *of*, so re-clamp it against the new height
// rather than letting a pane fall below its minimum.
window.addEventListener("resize", () => ApplySplitRatio(splitRatio));
}
// Clamped so neither pane drops below its minimum. When the dialog is too short to honor both, the
// panes just split what there is.
function ApplySplitRatio(ratio) {
const available = contentPane.clientHeight;
const sash = paneSplitter.offsetHeight;
let next = Number.isFinite(ratio) ? ratio : SPLIT_DEFAULT_RATIO;
if (available > 0) {
const min = SPLIT_MIN_LIST_PX / available;
const max = (available - sash - SPLIT_MIN_DETAILS_PX) / available;
next = min > max ? 0.5 : Math.min(Math.max(next, min), max);
}
splitRatio = next;
contentPane.style.setProperty("--plugin-list-height", `${(next * 100).toFixed(2)}%`);
}
function OnSplitterPointerDown(event) {
if (event.button !== 0)
return;
const bounds = contentPane.getBoundingClientRect();
// Offset of the grab point inside the strip, so the splitter does not jump under the cursor.
const grabOffset = event.clientY - paneSplitter.getBoundingClientRect().top;
const onMove = (moveEvent) => {
ApplySplitRatio((moveEvent.clientY - bounds.top - grabOffset) / bounds.height);
};
const onUp = (upEvent) => {
paneSplitter.releasePointerCapture(upEvent.pointerId);
paneSplitter.removeEventListener("pointermove", onMove);
paneSplitter.removeEventListener("pointerup", onUp);
paneSplitter.classList.remove("dragging");
document.body.classList.remove("pane-resizing");
StoreSplitRatio(splitRatio);
};
// Captured, so the drag keeps tracking once the pointer leaves the strip (which it does at once).
paneSplitter.setPointerCapture(event.pointerId);
paneSplitter.addEventListener("pointermove", onMove);
paneSplitter.addEventListener("pointerup", onUp);
paneSplitter.classList.add("dragging");
document.body.classList.add("pane-resizing");
event.preventDefault();
}
function ReadStoredSplitRatio() {
try {
const stored = Number.parseFloat(window.localStorage.getItem(SPLIT_STORAGE_KEY));
return Number.isFinite(stored) ? stored : SPLIT_DEFAULT_RATIO;
} catch (err) {
return SPLIT_DEFAULT_RATIO; // storage can be unavailable in the webview; the default still works
}
}
function StoreSplitRatio(ratio) {
try {
window.localStorage.setItem(SPLIT_STORAGE_KEY, String(ratio));
} catch (err) {
// Persisting the position is a nicety, never a reason to break the drag.
}
}
function NormalizeInstallAction(action) {
const normalized = String(action || "");
return pluginInstallActions[normalized] ? normalized : "explore";
@@ -227,11 +339,14 @@ function HandleStudio(value) {
ApplyPlugins(payload.data || []);
} else if (payload.command === "status_message") {
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
} else if (payload.command === "capability_config") {
ApplyCapabilityConfig(payload);
} else if (payload.command === "capability_config_saved") {
ApplyCapabilityConfigSaved(payload);
}
}
// Renders the latest plugin/capability operation result in the footer status bar. The result
// persists until the next operation replaces it; the native side already localizes the text.
// Footer status bar for the latest operation result; the native side already localizes the text.
function ShowStatusMessage(message, level) {
const bar = document.getElementById("statusBar");
const text = document.getElementById("statusText");
@@ -305,7 +420,6 @@ function SyncPluginListHeaderGutter() {
}
// why: paint matched-character ranges as <mark> without an innerHTML build
// note: if no ranges -> return the plain text node
function ApplyHighlight(container, text, ranges) {
if (!ranges || !ranges.length) {
container.appendChild(document.createTextNode(text));
@@ -341,7 +455,7 @@ function RenderPlugins() {
}
// why: stable filter over the existing C++ sort order - no scoring, no reorder. The empty query
// short-circuits (searching=false), leaving every existing render path untouched.
// short-circuits, leaving every existing render path untouched.
const searching = typeof PluginSearchActive === "function" && PluginSearchActive();
let shown = 0;
@@ -353,10 +467,8 @@ function RenderPlugins() {
continue;
shown++;
// why: transient override wins. Otherwise while searching start collapsed and auto-expand only
// capability matches (the persistent expand state is ignored so unrelated caps don't clutter
// results); when not searching use the persistent state. The base is never written while
// searching, so clearing the search restores exactly what the user had.
// why: the transient override wins; while searching, auto-expand only capability matches. The
// base is never written while searching, so clearing it restores exactly what the user had.
const open = searchExpandOverride.has(pluginKey)
? searchExpandOverride.get(pluginKey)
: (searching ? match.hasCapMatch : expandedPluginIds.has(pluginKey));
@@ -427,7 +539,6 @@ function GetLatestVersion(plugin) {
return String(plugin?.latest_version || plugin?.version || "");
}
// Primary version shown in the row: the installed version once installed, otherwise the latest available.
function GetDisplayVersion(plugin) {
const installed = GetInstalledVersion(plugin);
if (IsPluginInstalled(plugin) && installed)
@@ -476,9 +587,8 @@ function HasMixedCapabilityState(plugin) {
String(capability?.type_key || "")
);
const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
return hasEnabled && hasDisabled;
return toggleableCapabilities.length > 0 && hasDisabled;
}
function IsPluginLoading(plugin) {
@@ -543,7 +653,6 @@ function LabelCell(plugin, isExpanded = false, capabilityCount = 0, nameRanges =
const labelCell = document.createElement("span");
labelCell.className = "label-cell";
// Add hyperlink
const hasCloudLink = plugin.source === "mine" || plugin.source === "subscribed";
const pluginLabelText = plugin.label || plugin.name || plugin.plugin_id || "";
const canExpand = capabilityCount > 0;
@@ -785,6 +894,345 @@ function RenderDetails() {
ApplyDetailUpdateBadge(detailUpdateBadge, plugin);
if (detailUpdateBtn)
ApplyDetailUpdateButton(detailUpdateBtn, plugin);
RenderConfig(plugin);
}
// Config tab: the sidebar lists the selected plugin's configurable capabilities; the view shows the
// host's JSON editor or, when the capability ships one, its own HTML UI in a sandboxed frame. Both
// edit the same stored config; the page renders what the native side sends.
// Rows built natively by PluginConfig::capabilities_payload. Only loaded, addressable capabilities
// appear: the descriptor-only rows of an inactive plugin carry no name, so nothing to address.
function GetConfigurableCapabilities(plugin) {
return Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : [];
}
function RenderConfig(plugin) {
const empty = document.getElementById("configEmpty");
const layout = document.getElementById("configLayout");
const sidebar = document.getElementById("configSidebar");
if (!empty || !layout || !sidebar)
return;
const capabilities = plugin ? GetConfigurableCapabilities(plugin) : [];
const pluginKey = String(plugin?.plugin_key || "");
// A different plugin: drop the selection rather than trusting the name to mean the same here.
if (pluginKey !== configPluginId) {
configPluginId = pluginKey;
selectedCapabilityName = "";
selectedCapabilityType = "";
ClearCapabilityConfigView();
}
if (!plugin || capabilities.length === 0) {
// Capabilities only exist once the plugin is activated: say that, rather than claiming it has
// none to configure.
empty.textContent = !plugin
? "Select a plugin to configure its capabilities"
: (IsPluginLoading(plugin)
? "Loading the plugin…"
: (GetStatus(plugin) === "Activated"
? "This plugin exposes no capabilities"
: "Activate this plugin to configure its capabilities"));
empty.hidden = false;
layout.hidden = true;
sidebar.replaceChildren();
ClearCapabilityConfigView();
selectedCapabilityName = "";
selectedCapabilityType = "";
return;
}
empty.hidden = true;
layout.hidden = false;
// Keep the selection across a refresh if the capability is still there, else select the first.
const stillPresent = capabilities.some((capability) =>
capability.name === selectedCapabilityName && String(capability.type_key || "") === selectedCapabilityType);
if (!stillPresent) {
selectedCapabilityName = String(capabilities[0].name || "");
selectedCapabilityType = String(capabilities[0].type_key || "");
ClearCapabilityConfigView();
RequestCapabilityConfig();
}
sidebar.replaceChildren();
for (const capability of capabilities) {
const name = String(capability.name || "");
const typeKey = String(capability.type_key || "");
const item = document.createElement("button");
item.type = "button";
item.className = "config-cap";
item.dataset.capabilityName = name;
item.dataset.capabilityType = typeKey;
item.setAttribute("role", "option");
const isSelected = name === selectedCapabilityName && typeKey === selectedCapabilityType;
item.classList.toggle("selected", isSelected);
item.setAttribute("aria-selected", isSelected ? "true" : "false");
const label = document.createElement("span");
label.className = "config-cap-name";
label.textContent = name;
item.appendChild(label);
const type = document.createElement("span");
type.className = "config-cap-type";
type.textContent = String(capability.type || "");
item.appendChild(type);
sidebar.appendChild(item);
}
}
function OnConfigSidebarClick(event) {
const item = event.target.closest(".config-cap");
if (!item)
return;
const name = String(item.dataset.capabilityName || "");
const typeKey = String(item.dataset.capabilityType || "");
if (!name || (name === selectedCapabilityName && typeKey === selectedCapabilityType))
return;
selectedCapabilityName = name;
selectedCapabilityType = typeKey;
// The native reply is async: clear now so the old config cannot appear under the new selection.
ClearCapabilityConfigView();
RequestCapabilityConfig();
RenderConfig(pluginsById.get(selectedPluginId));
}
function RequestCapabilityConfig() {
if (!selectedPluginId || !selectedCapabilityName)
return;
SendMessage("get_capability_config", {
plugin_key: selectedPluginId,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType
});
}
// Empties both editors and the footer, so nothing from the previous capability lingers while the
// next one is in flight.
function ClearCapabilityConfigView() {
const editor = document.getElementById("configEditor");
const custom = document.getElementById("configCustom");
const text = document.getElementById("configText");
const error = document.getElementById("configError");
const footer = document.getElementById("configFooter");
if (editor)
editor.hidden = true;
if (custom) {
custom.hidden = true;
custom.removeAttribute("srcdoc");
}
if (text)
text.value = "";
if (error) {
error.hidden = true;
error.textContent = "";
}
if (footer)
footer.hidden = true;
SetConfigValidation("");
}
// Replies are async: one for a capability the user has navigated away from is dropped, never
// rendered into the current view.
function IsCurrentCapability(payload) {
return String(payload?.plugin_key || "") === selectedPluginId &&
String(payload?.capability_name || "") === selectedCapabilityName;
}
function ApplyCapabilityConfig(payload) {
if (!IsCurrentCapability(payload))
return;
const editor = document.getElementById("configEditor");
const custom = document.getElementById("configCustom");
const text = document.getElementById("configText");
const error = document.getElementById("configError");
const message = String(payload?.error || "");
if (error) {
error.textContent = message;
error.hidden = message === "";
}
const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {};
const html = String(payload?.custom_html || "");
// The footer belongs to the JSON editor. A custom UI owns its whole surface, including whatever
// save/restore controls it wants, and reaches the host through the window.orca bridge.
const footer = document.getElementById("configFooter");
if (footer)
footer.hidden = html !== "";
if (html) {
if (custom) {
custom.hidden = false;
custom.srcdoc = BuildCustomConfigDocument(html, config);
}
if (editor)
editor.hidden = true;
return;
}
// Default editor: any reason a custom UI is unavailable already arrived in payload.error.
if (custom) {
custom.hidden = true;
custom.removeAttribute("srcdoc");
}
if (editor)
editor.hidden = false;
if (text)
text.value = JSON.stringify(config, null, 2);
SetConfigValidation("");
}
function SetConfigValidation(message) {
const node = document.getElementById("configValidation");
const save = document.getElementById("configSaveBtn");
if (node) {
node.textContent = message;
node.classList.toggle("invalid", message !== "");
}
// Invalid JSON is never saved: Save is the only way to persist. The native side re-validates.
if (save)
save.disabled = message !== "";
}
function ValidateConfigText() {
const text = document.getElementById("configText");
if (!text)
return false;
try {
JSON.parse(text.value);
SetConfigValidation("");
return true;
} catch (err) {
SetConfigValidation(String(err?.message || "Invalid JSON"));
return false;
}
}
function SaveCapabilityConfig() {
const text = document.getElementById("configText");
if (!text || !selectedPluginId || !selectedCapabilityName)
return;
if (!ValidateConfigText())
return;
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
SendMessage("save_capability_config", {
plugin_key: selectedPluginId,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType,
config: text.value
});
}
// Writes the capability's own get_default_config() over whatever is stored — the host does not know
// what a plugin considers default. The native side confirms first, then replies as if it were a save.
function RestoreCapabilityConfig() {
if (!selectedPluginId || !selectedCapabilityName)
return;
SendMessage("restore_capability_config", {
plugin_key: selectedPluginId,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType
});
}
function ApplyCapabilityConfigSaved(payload) {
if (!IsCurrentCapability(payload))
return;
const error = document.getElementById("configError");
const message = String(payload?.error || "");
if (error) {
error.textContent = message;
error.hidden = message === "";
}
if (payload?.ok !== true)
return;
// Reload from what was persisted, not from what was typed.
const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {};
const custom = document.getElementById("configCustom");
const text = document.getElementById("configText");
if (custom && !custom.hidden && custom.contentWindow)
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
else if (text)
text.value = JSON.stringify(config, null, 2);
SetConfigValidation("");
}
// The whole host surface a custom config UI gets: read the config, save one, restore the plugin's
// defaults, and be told when either lands. The frame is sandboxed into an opaque origin, so this
// bridge is its only channel.
function BuildCustomConfigDocument(html, config) {
// Inlined into a <script>: a stored "</script>" would close the tag early, so escape "<" — the
// literal stays valid JSON.
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
const bridge = `<script>
(function () {
var handlers = [];
var current = ${seed};
window.orca = {
getConfig: function () { return current; },
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
restoreDefaults: function () { parent.postMessage({ __orca: "restore" }, "*"); },
onConfig: function (cb) {
if (typeof cb !== "function") return;
handlers.push(cb);
try { cb(current); } catch (e) {}
}
};
window.addEventListener("message", function (event) {
if (!event.data || event.data.__orca !== "config") return;
current = event.data.config || {};
handlers.forEach(function (handler) {
try { handler(current); } catch (e) {}
});
});
})();
<\/script>`;
return bridge + html;
}
function OnCustomConfigMessage(event) {
const custom = document.getElementById("configCustom");
// Only the frame we created, and only while it is actually showing.
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
return;
const data = event.data;
if (!data || !selectedPluginId || !selectedCapabilityName)
return;
if (data.__orca === "save") {
SendMessage("save_capability_config", {
plugin_key: selectedPluginId,
capability_name: selectedCapabilityName,
capability_type: selectedCapabilityType,
config: data.config === undefined ? {} : data.config
});
return;
}
if (data.__orca === "restore")
RestoreCapabilityConfig();
}
function RenderThumbnail(plugin) {
@@ -809,8 +1257,8 @@ function RenderDescription(plugin) {
node.replaceChildren();
// Descriptions come only from the plugin's Python header (local/installed plugins). A cloud plugin
// that is not installed yet has no header, so show a link to view it on OrcaCloud instead.
// Descriptions come from the plugin's Python header; a cloud plugin that is not installed yet has
// no header, so link to OrcaCloud instead.
const description = String(plugin?.description || "").trim();
if (description && description !== "No description.") {
node.textContent = description;
@@ -881,8 +1329,8 @@ function SetText(id, text) {
function ApplyDetailUpdateBadge(node, plugin) {
node.className = "version-update-badge";
// The "update_available" state is represented by the actionable Update button next to the
// version, so the detail panel only shows the passive badge for the unauthorized warning.
// "update_available" is already the actionable Update button, so the badge only warns about
// unauthorized.
const updateStatus = plugin ? GetUpdateStatus(plugin) : "normal";
if (updateStatus === "unauthorized") {
node.hidden = false;
@@ -982,9 +1430,8 @@ function OnPluginListClick(event) {
const pluginKey = String(block.dataset.pluginKey || "");
selectedPluginId = pluginKey;
// why: during a search the triangle writes to the transient override (read from the on-screen open
// state), so an auto-expanded row collapses without touching the saved layout. With no search
// active, toggle the persistent base exactly as before.
// why: during a search the triangle writes to the transient override (read from the on-screen
// open state), so an auto-expanded row collapses without touching the saved layout.
if (typeof PluginSearchActive === "function" && PluginSearchActive())
searchExpandOverride.set(pluginKey, !block.classList.contains("expanded"));
else if (expandedPluginIds.has(pluginKey))

View File

@@ -151,11 +151,13 @@ body {
margin-right: auto;
}
/* Split pane: the list keeps --plugin-list-height of the dialog's content height (set by the
splitter drag, see InitPaneSplitter), the details pane takes what is left. Both panes shrink
before the layout overflows, so a short dialog degrades instead of clipping. */
.content {
min-height: 0;
display: grid;
grid-template-rows: minmax(220px, 1fr) 280px;
gap: 8px;
display: flex;
flex-direction: column;
}
.pane {
@@ -166,17 +168,60 @@ body {
display: grid;
}
/* The pane minimums live in JS (SPLIT_MIN_LIST_PX / SPLIT_MIN_DETAILS_PX), which clamps the ratio
against the current height: as CSS min-heights they could not both be honored in a very short
dialog and the panes would overflow instead of shrinking. */
.plugin-list-pane {
flex: 0 1 auto;
height: var(--plugin-list-height, 62%);
grid-template-rows: auto minmax(0, 1fr);
}
/* basis 0, so the details pane takes the space the list leaves instead of letting its own content
height push back and shrink the list below --plugin-list-height. */
.details-pane {
flex: 1 1 0;
grid-template-rows: auto minmax(0, 1fr);
padding: 12px;
box-sizing: border-box;
gap: 10px;
}
/* 9px of grab area for a 2px divider: the strip also supplies the gap between the panes. */
.pane-splitter {
flex: 0 0 auto;
position: relative;
height: 9px;
cursor: ns-resize;
touch-action: none;
}
.pane-splitter::after {
content: "";
position: absolute;
top: 50%;
right: 0;
left: 0;
height: 2px;
transform: translateY(-50%);
border-radius: 1px;
background: transparent;
transition: background-color 0.12s ease;
}
.pane-splitter:hover::after {
background: var(--border-strong);
}
.pane-splitter.dragging::after {
background: var(--row-selected-outline);
}
/* Keep the resize cursor for the whole drag, wherever the pointer travels. */
body.pane-resizing {
cursor: ns-resize;
}
.hdr {
font-weight: 600;
font-size: 12px;
@@ -1084,6 +1129,174 @@ body {
display: none;
}
/* Config tab: fixed-width capability sidebar + the capability's configuration view. */
.config-layout {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
height: 100%;
min-height: 0;
padding: 6px;
box-sizing: border-box;
}
.config-layout[hidden] {
display: none;
}
.config-sidebar {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
border-right: 1px solid var(--border-soft);
}
.config-cap {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.config-cap:hover {
background: var(--row-hover);
}
.config-cap.selected {
background: var(--row-selected);
border-color: var(--row-selected-outline);
}
.config-cap-name {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-cap-type {
color: var(--muted);
font-size: 11px;
}
.config-view {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
min-width: 0;
}
.config-error {
padding: 6px 8px;
border-radius: 4px;
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
font-size: 12px;
}
.config-error[hidden] {
display: none;
}
.config-editor {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.config-editor[hidden] {
display: none;
}
.config-textarea {
flex: 1;
min-height: 0;
padding: 8px;
box-sizing: border-box;
/* common.css applies `user-select: none` to *, so without this the user could type into the
editor but not select, drag or copy what they had typed. (What blocks typing is the global
onkeydown guard in common.js — see the keydown handler in index.js.) */
-webkit-user-select: text;
user-select: text;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
resize: none;
white-space: pre;
overflow: auto;
}
.config-textarea:focus {
outline: none;
border-color: var(--main-color);
}
.config-view-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.config-view-footer[hidden] {
display: none;
}
/* Restore sits immediately left of Save, both pinned right; the validation message takes the
remaining space on the left. */
.config-actions {
display: flex;
align-items: center;
gap: 8px;
}
.config-actions > button[hidden] {
display: none;
}
.config-validation {
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-validation.invalid {
color: var(--plugin-status-danger);
}
.config-custom {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.config-custom[hidden] {
display: none;
}
.detail-table {
width: 100%;
border-collapse: collapse;

View File

@@ -68,6 +68,7 @@
--col-sep: #4a4a51;
--row-hover: #2b3340;
--row-selected: #244945;
--plugin-link-text: #5eead4;
--plugin-status-danger: #ff7b72;
--plugin-status-ok: #37c871;
--plugin-status-warn: #f0b45a;