mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-10 02:37:04 +00:00
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:
53
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
53
resources/web/dialog/PluginsConfigDialog/index.html
Normal 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>
|
||||
437
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
437
resources/web/dialog/PluginsConfigDialog/index.js
Normal 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");
|
||||
});
|
||||
251
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
251
resources/web/dialog/PluginsConfigDialog/styles.css
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user