|
// retoor <retoor@molodetz.nl>
|
|
|
|
const MAX_RESULT_CHARS = 8000;
|
|
const NAV_DELAY_MS = 150;
|
|
|
|
export default class DeviiClient {
|
|
constructor() {
|
|
this._highlights = [];
|
|
this._reposition = () => this._repositionHighlights();
|
|
}
|
|
|
|
async execute(action, args = {}) {
|
|
try {
|
|
switch (action) {
|
|
case "get_page_context":
|
|
return this._context();
|
|
case "run_js":
|
|
return await this._runJs(String(args.code || ""));
|
|
case "highlight_element":
|
|
return this._highlight(String(args.selector || ""), args.label);
|
|
case "clear_highlights":
|
|
return this._clearHighlights();
|
|
case "show_toast":
|
|
return this._toast(String(args.text || ""), Number(args.duration_ms) || 4000);
|
|
case "scroll_to_element":
|
|
return this._scrollTo(String(args.selector || ""));
|
|
case "navigate_to":
|
|
return this._navigate(String(args.url || ""));
|
|
case "reload_page":
|
|
return this._reload();
|
|
case "open_terminal":
|
|
return this._openTerminal(args);
|
|
default:
|
|
return { error: `unknown client action '${action}'` };
|
|
}
|
|
} catch (error) {
|
|
return { error: String(error && error.message ? error.message : error) };
|
|
}
|
|
}
|
|
|
|
_context() {
|
|
const headings = Array.from(document.querySelectorAll("h1, h2, h3"))
|
|
.map((h) => h.textContent.trim())
|
|
.filter(Boolean)
|
|
.slice(0, 25);
|
|
const selection = String(window.getSelection ? window.getSelection().toString() : "").slice(0, 2000);
|
|
const docs = window.DEVPLACE_DOCS || {};
|
|
let loggedIn = !!docs.loggedIn;
|
|
let username = docs.username || "";
|
|
if (!username) {
|
|
const nameEl = document.querySelector(".topnav-user-name");
|
|
if (nameEl) {
|
|
username = nameEl.textContent.trim();
|
|
loggedIn = true;
|
|
}
|
|
}
|
|
const pageTypeEl = document.querySelector("[data-page-type]");
|
|
const pageType = pageTypeEl ? pageTypeEl.getAttribute("data-page-type") : location.pathname;
|
|
return {
|
|
url: location.href,
|
|
path: location.pathname + location.search,
|
|
pageType,
|
|
title: document.title,
|
|
referrer: document.referrer || null,
|
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY) },
|
|
selection,
|
|
headings,
|
|
loggedIn,
|
|
username: username || null,
|
|
};
|
|
}
|
|
|
|
async _runJs(code) {
|
|
if (!code.trim()) return { error: "no code provided" };
|
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
const fn = new AsyncFunction(code);
|
|
const value = await fn();
|
|
return this._serialize(value);
|
|
}
|
|
|
|
_serialize(value) {
|
|
if (value === undefined) return null;
|
|
let out;
|
|
try {
|
|
out = JSON.parse(JSON.stringify(value));
|
|
} catch (error) {
|
|
out = String(value);
|
|
}
|
|
const text = typeof out === "string" ? out : JSON.stringify(out);
|
|
if (text && text.length > MAX_RESULT_CHARS) {
|
|
const base = typeof out === "string" ? out : text;
|
|
return base.slice(0, MAX_RESULT_CHARS) + " …[truncated]";
|
|
}
|
|
return out;
|
|
}
|
|
|
|
_resolve(selector) {
|
|
let element = null;
|
|
try {
|
|
element = document.querySelector(selector);
|
|
} catch (error) {
|
|
element = null;
|
|
}
|
|
if (!element) element = this._findByText(selector);
|
|
if (!element) throw new Error(`no element matches '${selector}'`);
|
|
return element;
|
|
}
|
|
|
|
_findByText(text) {
|
|
const needle = String(text).trim();
|
|
if (!needle) return null;
|
|
const candidates = document.querySelectorAll(
|
|
"h1, h2, h3, h4, h5, h6, a, button, [role='button'], label, summary, li, th, td, p, span",
|
|
);
|
|
let partial = null;
|
|
for (const el of candidates) {
|
|
if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") continue;
|
|
const content = (el.textContent || "").trim();
|
|
if (!content) continue;
|
|
if (content === needle) return el;
|
|
if (!partial && content.includes(needle) && content.length <= needle.length + 60) {
|
|
partial = el;
|
|
}
|
|
}
|
|
return partial;
|
|
}
|
|
|
|
_scrollTo(selector) {
|
|
const element = this._resolve(selector);
|
|
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
|
|
return `scrolled to ${selector}`;
|
|
}
|
|
|
|
_highlight(selector, label) {
|
|
const element = this._resolve(selector);
|
|
element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
|
|
const box = document.createElement("div");
|
|
box.className = "devii-hl-box";
|
|
const callout = label ? document.createElement("div") : null;
|
|
if (callout) {
|
|
callout.className = "devii-hl-callout";
|
|
callout.textContent = String(label);
|
|
}
|
|
document.body.appendChild(box);
|
|
if (callout) document.body.appendChild(callout);
|
|
const entry = { element, box, callout };
|
|
this._highlights.push(entry);
|
|
if (this._highlights.length === 1) {
|
|
window.addEventListener("scroll", this._reposition, true);
|
|
window.addEventListener("resize", this._reposition);
|
|
}
|
|
this._place(entry);
|
|
return `highlighted ${selector}`;
|
|
}
|
|
|
|
_place(entry) {
|
|
const rect = entry.element.getBoundingClientRect();
|
|
const pad = 4;
|
|
entry.box.style.left = `${rect.left - pad}px`;
|
|
entry.box.style.top = `${rect.top - pad}px`;
|
|
entry.box.style.width = `${rect.width + pad * 2}px`;
|
|
entry.box.style.height = `${rect.height + pad * 2}px`;
|
|
if (entry.callout) {
|
|
const below = rect.bottom + 8;
|
|
entry.callout.style.left = `${Math.max(8, rect.left)}px`;
|
|
entry.callout.style.top = `${below}px`;
|
|
}
|
|
}
|
|
|
|
_repositionHighlights() {
|
|
this._highlights.forEach((entry) => this._place(entry));
|
|
}
|
|
|
|
_clearHighlights() {
|
|
const count = this._highlights.length;
|
|
this._highlights.forEach((entry) => {
|
|
entry.box.remove();
|
|
if (entry.callout) entry.callout.remove();
|
|
});
|
|
this._highlights = [];
|
|
window.removeEventListener("scroll", this._reposition, true);
|
|
window.removeEventListener("resize", this._reposition);
|
|
return `cleared ${count} highlight(s)`;
|
|
}
|
|
|
|
_toast(text, duration) {
|
|
if (!text) return { error: "no text provided" };
|
|
const toast = document.createElement("div");
|
|
toast.className = "devii-toast";
|
|
toast.textContent = text;
|
|
document.body.appendChild(toast);
|
|
window.requestAnimationFrame(() => toast.classList.add("devii-toast-show"));
|
|
window.setTimeout(() => {
|
|
toast.classList.remove("devii-toast-show");
|
|
window.setTimeout(() => toast.remove(), 250);
|
|
}, Math.max(1000, duration));
|
|
return "toast shown";
|
|
}
|
|
|
|
_navigate(url) {
|
|
if (!url) return { error: "no url provided" };
|
|
window.setTimeout(() => window.location.assign(url), NAV_DELAY_MS);
|
|
return `navigating to ${url}`;
|
|
}
|
|
|
|
_reload() {
|
|
window.setTimeout(() => window.location.reload(), NAV_DELAY_MS);
|
|
return "reloading";
|
|
}
|
|
|
|
_openTerminal(args) {
|
|
const projectSlug = String(args.project_slug || "").trim();
|
|
const instance = String(args.instance || "").trim();
|
|
const label = String(args.label || instance || "").trim();
|
|
if (!projectSlug || !instance) return { error: "project_slug and instance are required" };
|
|
if (!globalThis.app || !globalThis.app.containerTerminals) {
|
|
return { error: "terminal manager unavailable" };
|
|
}
|
|
globalThis.app.containerTerminals.open(projectSlug, instance, label);
|
|
return { opened: true, instance };
|
|
}
|
|
}
|