diff --git a/rspack.config.js b/rspack.config.js index a30e31154b..9efbf30197 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -144,6 +144,7 @@ module.exports = (env, options) => { boot: './src/boot.js', main: './src/main.js', console: './src/lib/console.js', + consoleWorker: './src/lib/consoleWorker.js', searchInFilesWorker: './src/sidebarApps/searchInFiles/worker.js', searchIndexWorker: './src/sidebarApps/searchInFiles/indexWorker.js', htmlLspWorker: './src/cm/lsp/workers/html.worker.ts', diff --git a/src/components/virtualList/index.js b/src/components/virtualList/index.js index bf7b7db4b1..c1ff5bafcf 100644 --- a/src/components/virtualList/index.js +++ b/src/components/virtualList/index.js @@ -1,6 +1,8 @@ import "./style.scss"; import tag from "html-tag-js"; +export { default as VariableVirtualList } from "./variable"; + /** * @typedef {object} VirtualListOptions * @property {number} [itemHeight=30] - Height of each item in pixels diff --git a/src/components/virtualList/style.scss b/src/components/virtualList/style.scss index 9af4e0df05..3ef86ce953 100644 --- a/src/components/virtualList/style.scss +++ b/src/components/virtualList/style.scss @@ -7,3 +7,13 @@ position: relative; min-width: 100%; } + +.variable-virtual-spacer { + width: 100%; + pointer-events: none; +} + +.variable-virtual-items { + position: relative; + min-width: 100%; +} diff --git a/src/components/virtualList/variable.js b/src/components/virtualList/variable.js new file mode 100644 index 0000000000..e2cf193c6d --- /dev/null +++ b/src/components/virtualList/variable.js @@ -0,0 +1,411 @@ +const DEFAULT_ESTIMATED_HEIGHT = 52; +const DEFAULT_OVERSCAN = 360; +const BOTTOM_THRESHOLD = 48; + +/** + * Variable-height virtual list for retained HTMLElement items. + * + * Every item stays in the model, while only the visible window and a small pixel + * overscan are mounted. ResizeObserver keeps changing item heights measured + * without losing the reader's scroll anchor. + */ +export default class VariableVirtualList { + constructor( + container, + { + estimatedHeight = DEFAULT_ESTIMATED_HEIGHT, + overscan = DEFAULT_OVERSCAN, + activeOverscan = overscan * 4, + maxOverscan = overscan * 10, + footer = null, + } = {}, + ) { + this.container = container; + this.estimatedHeight = estimatedHeight; + this.overscan = overscan; + this.activeOverscan = Math.max(overscan, activeOverscan); + this.maxOverscan = Math.max(this.activeOverscan, maxOverscan); + this.dynamicOverscan = overscan; + this.footer = footer; + this.footerHeight = 0; + this.viewportHeight = this.container.clientHeight; + this.items = []; + this.itemByElement = new WeakMap(); + this.offsets = [0]; + this.offsetsDirty = false; + this.renderedRange = { start: 0, end: 0 }; + this.frame = null; + this.stickToBottom = true; + this.lastScrollTop = this.container.scrollTop; + this.lastScrollTime = this.now(); + this.touching = false; + this.releaseTimer = null; + this.destroyed = false; + + this.topSpacer = document.createElement("div"); + this.topSpacer.className = "variable-virtual-spacer"; + this.itemContainer = document.createElement("div"); + this.itemContainer.className = "variable-virtual-items"; + this.bottomSpacer = document.createElement("div"); + this.bottomSpacer.className = "variable-virtual-spacer"; + this.container.append( + this.topSpacer, + this.itemContainer, + this.bottomSpacer, + ); + if (this.footer) this.container.append(this.footer); + + this.onScroll = () => { + this.stickToBottom = this.isNearBottom(); + const now = this.now(); + const elapsed = Math.max(8, now - this.lastScrollTime); + const distance = Math.abs(this.container.scrollTop - this.lastScrollTop); + const projectedDistance = distance * (32 / elapsed); + this.dynamicOverscan = Math.min( + this.maxOverscan, + this.activeOverscan + projectedDistance * 2, + ); + this.lastScrollTop = this.container.scrollTop; + this.lastScrollTime = now; + // Scroll rendering is intentionally synchronous. Android WebView can move + // the compositor several rows before the next animation frame. + this.render(); + this.scheduleOverscanRelease(); + }; + this.onTouchStart = () => { + this.touching = true; + this.clearOverscanRelease(); + this.dynamicOverscan = this.activeOverscan; + // Pre-paint the fling guard before compositor scrolling begins. + this.render(); + }; + this.onTouchEnd = () => { + this.touching = false; + this.scheduleOverscanRelease(); + }; + this.container.addEventListener("scroll", this.onScroll, { passive: true }); + this.container.addEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + this.container.addEventListener("touchend", this.onTouchEnd, { + passive: true, + }); + this.container.addEventListener("touchcancel", this.onTouchEnd, { + passive: true, + }); + + this.resizeObserver = + typeof ResizeObserver === "function" + ? new ResizeObserver((entries) => this.onResize(entries)) + : null; + this.resizeObserver?.observe(this.container); + } + + now() { + return ( + this.container.ownerDocument.defaultView?.performance?.now?.() ?? + Date.now() + ); + } + + scheduleOverscanRelease() { + this.clearOverscanRelease(); + this.releaseTimer = this.container.ownerDocument.defaultView?.setTimeout( + () => { + this.releaseTimer = null; + if (this.destroyed || this.touching) return; + this.dynamicOverscan = this.overscan; + this.render(); + }, + 500, + ); + } + + clearOverscanRelease() { + if (this.releaseTimer === null) return; + this.container.ownerDocument.defaultView?.clearTimeout(this.releaseTimer); + this.releaseTimer = null; + } + + get length() { + return this.items.length; + } + + get mountedCount() { + return this.itemContainer.childElementCount; + } + + append(element) { + const wasNearBottom = this.isNearBottom(); + const item = { + element, + height: this.estimatedHeight, + index: this.items.length, + }; + this.items.push(item); + this.itemByElement.set(element, item); + this.offsetsDirty = true; + if (wasNearBottom || this.items.length === 1) this.stickToBottom = true; + this.scheduleRender(); + } + + clear() { + this.items = []; + this.itemByElement = new WeakMap(); + this.offsets = [0]; + this.offsetsDirty = false; + this.renderedRange = { start: 0, end: 0 }; + this.stickToBottom = true; + this.dynamicOverscan = this.overscan; + this.topSpacer.style.height = "0px"; + this.bottomSpacer.style.height = "0px"; + this.itemContainer.replaceChildren(); + this.container.scrollTop = 0; + this.footerHeight = this.getFooterHeight(); + this.observeResizeTargets(); + } + + invalidate() { + this.scheduleRender(); + } + + scrollToBottom() { + this.stickToBottom = true; + this.scheduleRender(); + } + + scheduleRender() { + if (this.frame !== null || this.destroyed) return; + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.render(); + }); + } + + rebuildOffsets() { + if (!this.offsetsDirty) return; + + const offsets = new Array(this.items.length + 1); + offsets[0] = 0; + for (let index = 0; index < this.items.length; index++) { + offsets[index + 1] = offsets[index] + this.items[index].height; + } + this.offsets = offsets; + this.offsetsDirty = false; + } + + findIndexAt(offset) { + let low = 0; + let high = this.items.length; + while (low < high) { + const middle = Math.floor((low + high + 1) / 2); + if (this.offsets[middle] <= offset) low = middle; + else high = middle - 1; + } + return Math.min(low, Math.max(0, this.items.length - 1)); + } + + getFooterHeight() { + if (!this.footer) return 0; + return Math.ceil( + this.footer.getBoundingClientRect().height || + this.footer.offsetHeight || + 0, + ); + } + + observeResizeTargets() { + if (!this.resizeObserver) return; + this.resizeObserver.disconnect(); + this.resizeObserver.observe(this.container); + for (const element of this.itemContainer.children) { + this.resizeObserver.observe(element); + } + if (this.footer) this.resizeObserver.observe(this.footer); + } + + render() { + if (this.destroyed) return; + this.rebuildOffsets(); + if (!this.items.length) { + this.footerHeight = this.getFooterHeight(); + this.itemContainer.replaceChildren(); + this.topSpacer.style.height = "0px"; + this.bottomSpacer.style.height = "0px"; + this.renderedRange = { start: 0, end: 0 }; + this.observeResizeTargets(); + return; + } + + const viewportHeight = Math.max( + 1, + this.container.clientHeight || window.innerHeight, + ); + this.viewportHeight = viewportHeight; + const totalHeight = this.offsets[this.items.length]; + this.footerHeight = this.getFooterHeight(); + const targetScrollTop = this.stickToBottom + ? Math.max(0, totalHeight + this.footerHeight - viewportHeight) + : this.container.scrollTop; + const start = this.findIndexAt( + Math.max(0, targetScrollTop - this.dynamicOverscan), + ); + const end = Math.min( + this.items.length, + this.findIndexAt( + targetScrollTop + viewportHeight + this.dynamicOverscan, + ) + 1, + ); + + this.updateMountedRange(start, end); + this.observeResizeTargets(); + this.topSpacer.style.height = `${this.offsets[start]}px`; + this.bottomSpacer.style.height = `${Math.max( + 0, + totalHeight - this.offsets[end], + )}px`; + this.renderedRange = { start, end }; + + if (this.stickToBottom) { + this.container.scrollTop = Math.max( + 0, + this.container.scrollHeight - viewportHeight, + ); + } + if (!this.resizeObserver) this.measureMountedItems(); + } + + updateMountedRange(start, end) { + let oldStart = this.renderedRange.start; + let oldEnd = this.renderedRange.end; + const rangesOverlap = oldStart < end && start < oldEnd; + + if (!this.itemContainer.childElementCount || !rangesOverlap) { + const fragment = document.createDocumentFragment(); + for (let index = start; index < end; index++) { + fragment.append(this.items[index].element); + } + this.itemContainer.replaceChildren(fragment); + return; + } + + while (oldStart < start) { + this.itemContainer.firstElementChild?.remove(); + oldStart++; + } + while (oldEnd > end) { + this.itemContainer.lastElementChild?.remove(); + oldEnd--; + } + + if (start < oldStart) { + const leading = document.createDocumentFragment(); + for (let index = start; index < oldStart; index++) { + leading.append(this.items[index].element); + } + this.itemContainer.insertBefore(leading, this.itemContainer.firstChild); + } + if (end > oldEnd) { + const trailing = document.createDocumentFragment(); + for (let index = oldEnd; index < end; index++) { + trailing.append(this.items[index].element); + } + this.itemContainer.append(trailing); + } + } + + measureMountedItems() { + for (const element of this.itemContainer.children) { + const item = this.itemByElement.get(element); + if (item) this.updateHeight(item, element.getBoundingClientRect().height); + } + } + + onResize(entries) { + const wasNearBottom = this.stickToBottom || this.isNearBottom(); + let footerChanged = false; + let viewportChanged = false; + for (const entry of entries) { + if (entry.target === this.container) { + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : entry.borderBoxSize; + const height = Math.ceil( + borderBox?.blockSize || + entry.contentRect?.height || + this.container.clientHeight, + ); + viewportChanged = Math.abs(height - this.viewportHeight) >= 1; + this.viewportHeight = height; + continue; + } + if (entry.target === this.footer) { + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : entry.borderBoxSize; + const height = Math.ceil( + borderBox?.blockSize || + entry.contentRect?.height || + this.getFooterHeight(), + ); + footerChanged = Math.abs(height - this.footerHeight) >= 1; + this.footerHeight = height; + continue; + } + const item = this.itemByElement.get(entry.target); + if (!item) continue; + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : entry.borderBoxSize; + this.updateHeight( + item, + borderBox?.blockSize || + entry.contentRect?.height || + entry.target.offsetHeight, + ); + } + if (wasNearBottom) this.stickToBottom = true; + if (viewportChanged || (wasNearBottom && footerChanged)) { + this.scheduleRender(); + } + } + + updateHeight(item, measuredHeight) { + const height = Math.max(1, Math.ceil(measuredHeight || 0)); + if (Math.abs(height - item.height) < 1) return; + + const delta = height - item.height; + item.height = height; + this.offsetsDirty = true; + if (!this.stickToBottom && item.index < this.renderedRange.start) { + this.container.scrollTop += delta; + } + this.scheduleRender(); + } + + isNearBottom() { + if (!this.items.length) return true; + return ( + this.container.scrollHeight - + this.container.scrollTop - + this.container.clientHeight <= + BOTTOM_THRESHOLD + ); + } + + destroy() { + this.destroyed = true; + if (this.frame !== null) cancelAnimationFrame(this.frame); + this.frame = null; + this.clearOverscanRelease(); + this.resizeObserver?.disconnect(); + this.container.removeEventListener("scroll", this.onScroll); + this.container.removeEventListener("touchstart", this.onTouchStart); + this.container.removeEventListener("touchend", this.onTouchEnd); + this.container.removeEventListener("touchcancel", this.onTouchEnd); + this.topSpacer.remove(); + this.itemContainer.remove(); + this.bottomSpacer.remove(); + this.items = []; + } +} diff --git a/src/lib/console.js b/src/lib/console.js index 3e10948585..8d399ea315 100644 --- a/src/lib/console.js +++ b/src/lib/console.js @@ -1,24 +1,102 @@ import "core-js/stable"; import "html-tag-js/dist/polyfill"; import { parse } from "acorn"; +import { VariableVirtualList } from "components/virtualList"; import css from "styles/console.m.scss"; import loadPolyFill from "utils/polyfill"; +import ConsoleExecutor, { + applyConsoleViewport, + executeConsoleCommand, + resolveConsoleExecutionContext, +} from "./consoleRuntime"; (function () { loadPolyFill.apply(window); let consoleVisible = false; + let isFocused = false; + let isExecuting = false; + let viewportFrame = null; + let viewportObserver = null; + const isStandaloneConsole = sessionStorage.getItem("__mode") === "console"; const originalConsole = console; const $input = tag("textarea", { id: "__c-input", + attr: { + rows: "1", + placeholder: "Run JavaScript…", + "aria-label": "JavaScript console input", + spellcheck: "false", + autocomplete: "off", + autocapitalize: "off", + }, + oninput: resizeInput, + onfocus() { + isFocused = true; + virtualMessages.scrollToBottom(); + }, onblur() { setTimeout(() => { isFocused = false; }, 0); }, }); + const $stopExecution = tag("button", { + className: "__c-action __c-action-danger", + textContent: "Stop", + attr: { + type: "button", + "aria-label": "Stop JavaScript execution", + }, + onclick() { + executor.cancel(); + }, + }); + $stopExecution.hidden = true; + const $executionContext = tag("select", { + className: "__c-context", + attr: { + "aria-label": "JavaScript execution context", + title: "Choose isolated Worker execution or live page access", + }, + children: [ + tag("option", { + textContent: "Worker", + attr: { value: "worker" }, + }), + tag("option", { + textContent: "Page (unsafe)", + attr: { value: "page" }, + }), + ], + onchange() { + if (this.value !== "page") return; + log( + "warn", + {}, + "Page mode can access window and document, but it runs on the preview thread and cannot stop infinite code.", + ); + }, + }); + const $output = tag("c-output", { + attr: { + role: "region", + "aria-label": "JavaScript console", + }, + }); const $inputContainer = tag("c-input", { - child: $input, + children: [ + $input, + tag("c-input-actions", { + children: [ + ...(isStandaloneConsole ? [] : [$executionContext]), + $stopExecution, + ], + }), + ], + }); + const virtualMessages = new VariableVirtualList($output, { + footer: $inputContainer, }); const $toggler = tag("c-toggler", { style: { @@ -46,9 +124,13 @@ import loadPolyFill from "utils/polyfill"; }, }); const $console = tag("c-console", { - child: $inputContainer, + child: $output, + attr: { + role: "dialog", + "aria-label": "JavaScript console", + }, onclick(e) { - const el = e.target; + const el = e.target.closest?.("[action]") || e.target; const action = el.getAttribute("action"); switch (action) { @@ -64,9 +146,22 @@ import loadPolyFill from "utils/polyfill"; } }, }); + if (isStandaloneConsole) { + $console.setAttribute("standalone", ""); + document.documentElement.setAttribute("console-only", ""); + } const counter = {}; const timers = {}; - let isFocused = false; + const executor = new ConsoleExecutor({ + workerUrl: window.__consoleWorkerScript || "build/consoleWorker.js", + onConsole(message) { + if (message.action === "clear") { + window.console.clear(); + return; + } + log(message.level, {}, ...message.args); + }, + }); if (!window.__objs) window.__objs = {}; @@ -77,13 +172,12 @@ import loadPolyFill from "utils/polyfill"; window.addEventListener("error", onError); assignCustomConsole(); - if (sessionStorage.getItem("__mode") === "console") { + if (isStandaloneConsole) { showConsole(); return; } tag.get("html").append($toggler); - $console.setAttribute("title", "Console"); sessionStorage.setItem("__console_available", true); document.addEventListener("showconsole", showConsole); document.addEventListener("hideconsole", hideConsole); @@ -105,10 +199,7 @@ import loadPolyFill from "utils/polyfill"; } }, clear() { - originalConsole.clear(); - if (isFocused) $input.focus(); - $console.textContent = ""; - $console.appendChild($inputContainer); + clearConsole(); }, count(hash = "default") { originalConsole.count(hash); @@ -207,35 +298,104 @@ import loadPolyFill from "utils/polyfill"; function showConsole() { tag.get("html").append($console); $input.addEventListener("keydown", onCodeInput); + if (!isStandaloneConsole) { + bindViewportListeners(); + updateConsoleViewport(); + } + virtualMessages.invalidate(); } function hideConsole() { $console.remove(); $input.removeEventListener("keydown", onCodeInput); + unbindViewportListeners(); + } + + function bindViewportListeners() { + window.addEventListener("resize", updateConsoleViewport); + window.visualViewport?.addEventListener("resize", updateConsoleViewport); + window.visualViewport?.addEventListener("scroll", updateConsoleViewport); + if (typeof ResizeObserver === "function") { + viewportObserver = new ResizeObserver(updateConsoleViewport); + viewportObserver.observe(document.documentElement); + } + } + + function unbindViewportListeners() { + window.removeEventListener("resize", updateConsoleViewport); + window.visualViewport?.removeEventListener("resize", updateConsoleViewport); + window.visualViewport?.removeEventListener("scroll", updateConsoleViewport); + viewportObserver?.disconnect(); + viewportObserver = null; + if (viewportFrame !== null) cancelAnimationFrame(viewportFrame); + viewportFrame = null; + } + + function updateConsoleViewport() { + if (viewportFrame !== null) cancelAnimationFrame(viewportFrame); + viewportFrame = requestAnimationFrame(() => { + viewportFrame = null; + applyConsoleViewport($console); + virtualMessages.invalidate(); + }); } function onCodeInput(e) { - const key = e.key; isFocused = true; - if (key === "Enter") { - const regex = /[\[|{\(\)\}\]]/g; - let code = this.value.trim(); - let isOdd = (code.length - code.replace(regex, "").length) % 2; - - if (!code || isOdd) return; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - log("code", {}, code); - $input.value = ""; - const res = execute(code); - if (res.type === "error") { - log("error", getStack(new Error()), res.value); - } else { - log("log", getStack(new Error()), res.value); - } + if (e.key !== "Enter" || e.shiftKey) return; + + const code = $input.value.trim(); + const brackets = /[\[|{\(\)\}\]]/g; + const isIncomplete = + (code.length - code.replace(brackets, "").length) % 2 !== 0; + if (!code || isIncomplete) return; + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + runInputCode(); + } + + async function runInputCode() { + const code = $input.value.trim(); + if (!code) return; + if (isExecuting) { + log("warn", {}, "Another console command is still running."); + return; + } + + log("code", {}, code); + $input.value = ""; + resizeInput(); + setExecutionState(true); + const res = await executeCommand(code); + setExecutionState(false); + + if (res.type === "error") { + log("error", getStack(new Error()), res.value); + } else { + log("log", getStack(new Error()), res.value); } + $input.focus(); + } + + function setExecutionState(running) { + isExecuting = running; + $console.toggleAttribute("running", running); + $stopExecution.hidden = !running || $executionContext.value !== "worker"; + $input.disabled = running; + $executionContext.disabled = running; + } + + function resizeInput() { + $input.style.height = "0px"; + $input.style.height = `${Math.min(120, Math.max(38, $input.scrollHeight))}px`; + } + + function clearConsole() { + originalConsole.clear(); + virtualMessages.clear(); + if (isFocused) $input.focus(); } function getBody(obj, ...keys) { @@ -454,7 +614,7 @@ import loadPolyFill from "utils/polyfill"; let $msg; if (mode === "code") { $msg = tag("c-code"); - $msg.textContent = arg.length > 50 ? arg.substring(0, 50) + "..." : arg; + $msg.textContent = arg; $msg.setAttribute("data-code", arg); $msg.setAttribute("action", "use code"); } else { @@ -486,11 +646,7 @@ import loadPolyFill from "utils/polyfill"; $messages.appendChild($stack); } - $console.insertBefore($messages, $inputContainer); - - while ($console.childElementCount > 100) { - $console.firstElementChild.remove(); - } + virtualMessages.append($messages); } /** @@ -634,6 +790,18 @@ import loadPolyFill from "utils/polyfill"; }; } + function executeCommand(code) { + return executeConsoleCommand({ + context: resolveConsoleExecutionContext( + isStandaloneConsole, + $executionContext.value, + ), + code, + workerExecutor: executor, + pageExecutor: execute, + }); + } + function execute(code) { let res = null; try { diff --git a/src/lib/consoleRuntime.js b/src/lib/consoleRuntime.js new file mode 100644 index 0000000000..383ddd0946 --- /dev/null +++ b/src/lib/consoleRuntime.js @@ -0,0 +1,186 @@ +export const DEFAULT_CONSOLE_TIMEOUT = 30000; + +/** + * Runs console commands in a worker so user code can never block the page UI. + */ +export default class ConsoleExecutor { + constructor({ + workerUrl, + timeout = DEFAULT_CONSOLE_TIMEOUT, + onConsole = () => {}, + WorkerClass = globalThis.Worker, + } = {}) { + this.workerUrl = workerUrl; + this.timeout = timeout; + this.onConsole = onConsole; + this.WorkerClass = WorkerClass; + this.worker = null; + this.pending = null; + this.nextId = 0; + } + + execute(code) { + if (this.pending) { + return Promise.resolve({ + type: "error", + value: new Error("Another console command is still running."), + }); + } + + if (!this.WorkerClass) { + return Promise.resolve({ + type: "error", + value: new Error( + "This WebView does not support isolated console execution.", + ), + }); + } + + let worker; + try { + worker = this.getWorker(); + } catch (error) { + return Promise.resolve({ type: "error", value: error }); + } + const id = ++this.nextId; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.finish(id, { + type: "error", + value: new Error( + `Execution stopped after ${this.timeout / 1000} seconds.`, + ), + }); + this.resetWorker(); + }, this.timeout); + + this.pending = { id, resolve, timer }; + try { + worker.postMessage({ id, code }); + } catch (error) { + this.finish(id, { type: "error", value: error }); + this.resetWorker(); + } + }); + } + + getWorker() { + if (this.worker) return this.worker; + + const worker = new this.WorkerClass(this.workerUrl); + worker.onmessage = ({ data }) => this.handleMessage(data); + worker.onerror = (event) => { + const error = new Error(event.message || "Console worker failed."); + this.finish(this.pending?.id, { type: "error", value: error }); + this.resetWorker(); + }; + this.worker = worker; + return worker; + } + + handleMessage(message) { + if (message.type === "console") { + this.onConsole(message); + return; + } + + if (message.type === "result") { + this.finish(message.id, { type: "result", value: message.value }); + return; + } + + if (message.type === "error") { + const error = new Error( + message.error?.message || "Console command failed.", + ); + error.name = message.error?.name || "Error"; + if (message.error?.stack) error.stack = message.error.stack; + this.finish(message.id, { type: "error", value: error }); + } + } + + finish(id, result) { + if (!this.pending || this.pending.id !== id) return; + + const { resolve, timer } = this.pending; + this.pending = null; + clearTimeout(timer); + resolve(result); + } + + resetWorker() { + this.worker?.terminate(); + this.worker = null; + } + + cancel() { + if (!this.pending) return false; + + this.finish(this.pending.id, { + type: "error", + value: new Error("Execution stopped."), + }); + this.resetWorker(); + return true; + } + + destroy() { + if (this.pending) { + this.finish(this.pending.id, { + type: "error", + value: new Error("Console execution was cancelled."), + }); + } + this.resetWorker(); + } +} + +/** + * Returns the actually visible browser rectangle. On mobile this shrinks with + * the software keyboard even when the layout viewport remains full height. + */ +export function getConsoleViewportRect(windowObject = window) { + const viewport = windowObject.visualViewport; + const documentHeight = windowObject.document?.documentElement?.clientHeight; + const visibleHeights = [ + viewport?.height, + windowObject.innerHeight, + documentHeight, + ].filter((height) => Number.isFinite(height) && height > 0); + return { + height: Math.round(Math.min(...visibleHeights)), + width: Math.round(viewport?.width || windowObject.innerWidth), + top: Math.round(viewport?.offsetTop || 0), + left: Math.round(viewport?.offsetLeft || 0), + }; +} + +export function applyConsoleViewport(element, windowObject = window) { + const { height, width, top, left } = getConsoleViewportRect(windowObject); + element.style.setProperty("--console-viewport-height", `${height}px`); + element.style.setProperty("--console-viewport-width", `${width}px`); + element.style.setProperty("--console-viewport-top", `${top}px`); + element.style.setProperty("--console-viewport-left", `${left}px`); +} + +export function resolveConsoleExecutionContext( + isStandaloneConsole, + selectedContext, +) { + return isStandaloneConsole ? "worker" : selectedContext; +} + +/** + * Routes a console command to the isolated worker or the live preview page. + * Page execution is deliberately opt-in because it runs on the preview thread. + */ +export function executeConsoleCommand({ + context, + code, + workerExecutor, + pageExecutor, +}) { + if (context === "page") return Promise.resolve(pageExecutor(code)); + return workerExecutor.execute(code); +} diff --git a/src/lib/consoleWorker.js b/src/lib/consoleWorker.js new file mode 100644 index 0000000000..c5d911fe8c --- /dev/null +++ b/src/lib/consoleWorker.js @@ -0,0 +1,168 @@ +const MAX_MESSAGES_PER_SECOND = 100; +const MESSAGE_WINDOW_MS = 1000; +const MAX_PROPERTIES = 100; +const MAX_DEPTH = 4; +const MAX_SNAPSHOT_VALUES = 500; + +let activeCommandId = 0; +let messageWindowStartedAt = 0; +let emittedInWindow = 0; +let suppressedInWindow = 0; +const counts = new Map(); +const timers = new Map(); + +function snapshot( + value, + depth = 0, + seen = new WeakSet(), + budget = { remaining: MAX_SNAPSHOT_VALUES }, +) { + const type = typeof value; + + if ( + value === null || + ["boolean", "number", "string", "undefined"].includes(type) + ) { + return value; + } + if (type === "bigint") return `${value}n`; + if (type === "symbol") return value.toString(); + if (type === "function") + return `[Function${value.name ? `: ${value.name}` : ""}]`; + if (budget.remaining-- <= 0) return "[Truncated]"; + if (value instanceof Error) return snapshotError(value); + if (value instanceof Date) return value.toISOString(); + if (value instanceof RegExp) return value.toString(); + if (value instanceof Promise) return "Promise { }"; + if (depth >= MAX_DEPTH) return `[${value.constructor?.name || "Object"}]`; + if (seen.has(value)) return "[Circular]"; + + seen.add(value); + if (Array.isArray(value)) { + const result = value + .slice(0, MAX_PROPERTIES) + .map((item) => snapshot(item, depth + 1, seen, budget)); + if (value.length > MAX_PROPERTIES) result.push("…"); + return result; + } + + const result = {}; + let keys; + try { + keys = Reflect.ownKeys(value).slice(0, MAX_PROPERTIES); + } catch (error) { + return `[Uninspectable: ${error.message}]`; + } + + for (const key of keys) { + const outputKey = typeof key === "symbol" ? key.toString() : key; + try { + result[outputKey] = snapshot(value[key], depth + 1, seen, budget); + } catch (error) { + result[outputKey] = `[Thrown: ${error.message}]`; + } + } + if (Reflect.ownKeys(value).length > MAX_PROPERTIES) result["…"] = "…"; + return result; +} + +function snapshotError(error) { + return { + name: error?.name || "Error", + message: error?.message || String(error), + stack: error?.stack || "", + }; +} + +function discardSuppressedMessages() { + suppressedInWindow = 0; +} + +function emitMessage(message) { + const now = performance.now(); + if (now - messageWindowStartedAt >= MESSAGE_WINDOW_MS) { + discardSuppressedMessages(); + messageWindowStartedAt = now; + emittedInWindow = 0; + } + + if (emittedInWindow >= MAX_MESSAGES_PER_SECOND) { + suppressedInWindow++; + return; + } + + emittedInWindow++; + self.postMessage({ type: "console", id: activeCommandId, ...message }); +} + +function emit(level, args) { + emitMessage({ + level, + args: args.map((value) => snapshot(value)), + }); +} + +self.console = { + assert(condition, ...args) { + if (!condition) emit("error", args.length ? args : ["Assertion failed"]); + }, + clear() { + emitMessage({ action: "clear" }); + }, + count(label = "default") { + const value = (counts.get(label) || 0) + 1; + counts.set(label, value); + emit("log", [`${label}: ${value}`]); + }, + countReset(label = "default") { + counts.delete(label); + }, + debug: (...args) => emit("log", args), + dir: (...args) => emit("log", args), + dirxml: (...args) => emit("log", args), + error: (...args) => emit("error", args), + group: (...args) => emit("log", args), + groupCollapsed: (...args) => emit("log", args), + groupEnd() {}, + info: (...args) => emit("info", args), + log: (...args) => emit("log", args), + table: (...args) => emit("table", args), + time(label = "default") { + timers.set(label, performance.now()); + }, + timeEnd(label = "default") { + if (!timers.has(label)) return emit("warn", [`No such label: ${label}`]); + emit("log", [ + `${label}: ${(performance.now() - timers.get(label)).toFixed(2)}ms`, + ]); + timers.delete(label); + }, + timeLog(label = "default") { + if (!timers.has(label)) return emit("warn", [`No such label: ${label}`]); + emit("log", [ + `${label}: ${(performance.now() - timers.get(label)).toFixed(2)}ms`, + ]); + }, + trace(...args) { + emit("trace", [...args, new Error().stack]); + }, + warn: (...args) => emit("warn", args), +}; + +self.onmessage = ({ data: { id, code } }) => { + activeCommandId = id; + messageWindowStartedAt = performance.now(); + emittedInWindow = 0; + suppressedInWindow = 0; + + try { + // Intentional: this local REPL runs the command in its isolated worker. + // codeql[js/code-injection] + const value = (0, eval)(code); + discardSuppressedMessages(); + self.postMessage({ type: "result", id, value: snapshot(value) }); + } catch (error) { + discardSuppressedMessages(); + self.postMessage({ type: "error", id, error: snapshotError(error) }); + } +}; diff --git a/src/lib/run.js b/src/lib/run.js index 7978c4b6a7..a92a085c52 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -72,6 +72,7 @@ async function run( let EXECUTING_SCRIPT = uuid + "_script.js"; const MIMETYPE_HTML = mimeType.lookup("html"); const CONSOLE_SCRIPT = uuid + "_console.js"; + const CONSOLE_WORKER_SCRIPT = uuid + "_console_worker.js"; const MARKDOWN_STYLE = uuid + "_md.css"; const queue = []; @@ -205,6 +206,11 @@ async function run( sendFileContent(url, reqId, "application/javascript"); break; + case CONSOLE_WORKER_SCRIPT: + url = `${ASSETS_DIRECTORY}/build/consoleWorker.js`; + sendFileContent(url, reqId, "application/javascript"); + break; + case EXECUTING_SCRIPT: { const text = getDocText(activeFile?.session?.doc); sendText(text, reqId, "application/javascript"); @@ -228,6 +234,7 @@ async function run( sendText( mustache.render($_console, { CONSOLE_SCRIPT, + CONSOLE_WORKER_SCRIPT, EXECUTING_SCRIPT, }), reqId, @@ -384,7 +391,8 @@ async function run( * @param {string} id */ function sendHTML(text, id) { - const js = ` + const js = ` + + - \ No newline at end of file + diff --git a/tests/unit/consoleExecutionContext.test.js b/tests/unit/consoleExecutionContext.test.js new file mode 100644 index 0000000000..ab92a29872 --- /dev/null +++ b/tests/unit/consoleExecutionContext.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + executeConsoleCommand, + resolveConsoleExecutionContext, +} from "lib/consoleRuntime"; + +describe("console execution context", () => { + it("forces standalone menu consoles to use the worker", () => { + expect(resolveConsoleExecutionContext(true, "page")).toBe("worker"); + expect(resolveConsoleExecutionContext(false, "page")).toBe("page"); + }); + + it("uses isolated worker execution by default", async () => { + const calls = []; + const result = await executeConsoleCommand({ + context: "worker", + code: "document.title", + workerExecutor: { + execute(code) { + calls.push(["worker", code]); + return Promise.resolve({ type: "result", value: "isolated" }); + }, + }, + pageExecutor(code) { + calls.push(["page", code]); + }, + }); + + expect(calls).toEqual([["worker", "document.title"]]); + expect(result.value).toBe("isolated"); + }); + + it("restores live page execution when explicitly selected", async () => { + const calls = []; + const result = await executeConsoleCommand({ + context: "page", + code: 'document.querySelector("main")', + workerExecutor: { + execute(code) { + calls.push(["worker", code]); + }, + }, + pageExecutor(code) { + calls.push(["page", code]); + return { type: "result", value: "live page" }; + }, + }); + + expect(calls).toEqual([["page", 'document.querySelector("main")']]); + expect(result.value).toBe("live page"); + }); +}); diff --git a/tests/unit/consoleExecutor.test.js b/tests/unit/consoleExecutor.test.js new file mode 100644 index 0000000000..7dcd45e645 --- /dev/null +++ b/tests/unit/consoleExecutor.test.js @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ConsoleExecutor from "lib/consoleRuntime"; + +class FakeWorker { + static instances = []; + + constructor(url) { + this.url = url; + this.terminate = vi.fn(); + this.postMessage = vi.fn(); + FakeWorker.instances.push(this); + } + + respond(data) { + this.onmessage({ data }); + } +} + +describe("ConsoleExecutor", () => { + beforeEach(() => { + FakeWorker.instances = []; + vi.useRealTimers(); + }); + + it("returns a worker result and reuses the isolated context", async () => { + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + }); + + const firstResult = executor.execute("1 + 1"); + const worker = FakeWorker.instances[0]; + expect(worker.url).toBe("/console-worker.js"); + expect(worker.postMessage).toHaveBeenCalledWith({ id: 1, code: "1 + 1" }); + + worker.respond({ type: "result", id: 1, value: 2 }); + await expect(firstResult).resolves.toEqual({ type: "result", value: 2 }); + + const secondResult = executor.execute("3 + 4"); + expect(FakeWorker.instances).toHaveLength(1); + worker.respond({ type: "result", id: 2, value: 7 }); + await expect(secondResult).resolves.toEqual({ type: "result", value: 7 }); + }); + + it("forwards console messages from the worker", async () => { + const onConsole = vi.fn(); + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + onConsole, + }); + + const result = executor.execute("console.log('hello')"); + const worker = FakeWorker.instances[0]; + const message = { + type: "console", + id: 1, + level: "log", + args: ["hello"], + }; + worker.respond(message); + worker.respond({ type: "result", id: 1 }); + + expect(onConsole).toHaveBeenCalledWith(message); + await expect(result).resolves.toEqual({ type: "result", value: undefined }); + }); + + it("terminates and replaces a worker when execution times out", async () => { + vi.useFakeTimers(); + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + timeout: 50, + }); + + const timedOutResult = executor.execute("while (true) {}"); + const blockedWorker = FakeWorker.instances[0]; + await vi.advanceTimersByTimeAsync(50); + + const result = await timedOutResult; + expect(result.type).toBe("error"); + expect(result.value.message).toBe("Execution stopped after 0.05 seconds."); + expect(blockedWorker.terminate).toHaveBeenCalledOnce(); + + const nextResult = executor.execute("42"); + expect(FakeWorker.instances).toHaveLength(2); + FakeWorker.instances[1].respond({ type: "result", id: 2, value: 42 }); + await expect(nextResult).resolves.toEqual({ type: "result", value: 42 }); + }); + + it("allows a running command to be stopped immediately", async () => { + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + }); + + const stoppedResult = executor.execute("while (true) {}"); + const blockedWorker = FakeWorker.instances[0]; + + expect(executor.cancel()).toBe(true); + await expect(stoppedResult).resolves.toMatchObject({ + type: "error", + value: { message: "Execution stopped." }, + }); + expect(blockedWorker.terminate).toHaveBeenCalledOnce(); + expect(executor.cancel()).toBe(false); + }); + + it("turns worker errors into console errors", async () => { + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + }); + + const result = executor.execute("missingName"); + FakeWorker.instances[0].respond({ + type: "error", + id: 1, + error: { + name: "ReferenceError", + message: "missingName is not defined", + stack: "worker stack", + }, + }); + + const errorResult = await result; + expect(errorResult.type).toBe("error"); + expect(errorResult.value).toMatchObject({ + name: "ReferenceError", + message: "missingName is not defined", + stack: "worker stack", + }); + }); +}); diff --git a/tests/unit/consoleViewport.test.js b/tests/unit/consoleViewport.test.js new file mode 100644 index 0000000000..218a5bad69 --- /dev/null +++ b/tests/unit/consoleViewport.test.js @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { + applyConsoleViewport, + getConsoleViewportRect, +} from "lib/consoleRuntime"; + +describe("console visual viewport", () => { + it("uses the visual viewport when the mobile keyboard shrinks the screen", () => { + const windowObject = { + innerHeight: 800, + innerWidth: 400, + visualViewport: { + height: 476.4, + width: 400, + offsetTop: 12.2, + offsetLeft: 0, + }, + }; + + expect(getConsoleViewportRect(windowObject)).toEqual({ + height: 476, + width: 400, + top: 12, + left: 0, + }); + + const element = document.createElement("div"); + applyConsoleViewport(element, windowObject); + expect(element.style.getPropertyValue("--console-viewport-height")).toBe( + "476px", + ); + }); + + it("falls back to the layout viewport", () => { + expect( + getConsoleViewportRect({ innerHeight: 720, innerWidth: 360 }), + ).toEqual({ height: 720, width: 360, top: 0, left: 0 }); + }); +}); diff --git a/tests/unit/variableVirtualList.test.js b/tests/unit/variableVirtualList.test.js new file mode 100644 index 0000000000..ed1e0c70d7 --- /dev/null +++ b/tests/unit/variableVirtualList.test.js @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import VariableVirtualList from "components/virtualList/variable"; + +function createList() { + const container = document.createElement("div"); + Object.defineProperty(container, "clientHeight", { + configurable: true, + value: 200, + }); + document.body.append(container); + return { container, list: new VariableVirtualList(container) }; +} + +describe("VariableVirtualList", () => { + it("retains all items while mounting only the viewport window", () => { + const { container, list } = createList(); + const elements = Array.from({ length: 1000 }, (_, index) => { + const element = document.createElement("div"); + element.textContent = `message-${index}`; + list.append(element); + return element; + }); + + list.render(); + + expect(list.length).toBe(1000); + expect(list.mountedCount).toBeLessThan(40); + expect(elements[999].isConnected).toBe(true); + expect(elements[0].isConnected).toBe(false); + + list.stickToBottom = false; + container.scrollTop = 0; + list.render(); + expect(elements[0].isConnected).toBe(true); + expect(elements[999].isConnected).toBe(false); + + list.destroy(); + container.remove(); + }); + + it("updates variable measurements and clears the retained model", () => { + const { container, list } = createList(); + const element = document.createElement("div"); + list.append(element); + list.render(); + + list.updateHeight(list.items[0], 96); + list.rebuildOffsets(); + expect(list.offsets).toEqual([0, 96]); + + list.clear(); + expect(list.length).toBe(0); + expect(list.mountedCount).toBe(0); + expect(container.scrollTop).toBe(0); + + list.destroy(); + container.remove(); + }); + + it("preserves an inline footer outside the virtualized rows", () => { + const container = document.createElement("div"); + Object.defineProperty(container, "clientHeight", { + configurable: true, + value: 200, + }); + document.body.append(container); + const footer = document.createElement("form"); + footer.textContent = "REPL prompt"; + const list = new VariableVirtualList(container, { footer }); + + list.append(document.createElement("div")); + list.render(); + expect(container.lastElementChild).toBe(footer); + + list.clear(); + expect(container.lastElementChild).toBe(footer); + expect(footer.isConnected).toBe(true); + + list.destroy(); + container.remove(); + }); + + it("includes the footer height when pinning and rendering the bottom", () => { + const container = document.createElement("div"); + Object.defineProperties(container, { + clientHeight: { configurable: true, value: 200 }, + scrollHeight: { configurable: true, value: 5304 }, + }); + document.body.append(container); + const footer = document.createElement("form"); + footer.getBoundingClientRect = () => ({ height: 104 }); + const list = new VariableVirtualList(container, { + footer, + overscan: 0, + }); + for (let index = 0; index < 100; index++) { + list.append(document.createElement("div")); + } + + list.render(); + + expect(list.renderedRange.start).toBe(98); + expect(list.renderedRange.end).toBe(100); + expect(container.scrollTop).toBe(5104); + expect(container.lastElementChild).toBe(footer); + + list.destroy(); + container.remove(); + }); + + it("keeps a pinned footer visible when the viewport height changes", () => { + let viewportHeight = 200; + const container = document.createElement("div"); + Object.defineProperties(container, { + clientHeight: { + configurable: true, + get: () => viewportHeight, + }, + scrollHeight: { configurable: true, value: 5304 }, + }); + document.body.append(container); + const footer = document.createElement("form"); + footer.getBoundingClientRect = () => ({ height: 104 }); + const list = new VariableVirtualList(container, { footer }); + for (let index = 0; index < 100; index++) { + list.append(document.createElement("div")); + } + list.render(); + expect(container.scrollTop).toBe(5104); + + viewportHeight = 100; + list.onResize([ + { + target: container, + contentRect: { height: viewportHeight }, + }, + ]); + list.render(); + + expect(list.stickToBottom).toBe(true); + expect(container.scrollTop).toBe(5204); + + list.destroy(); + container.remove(); + }); + + it("pre-paints a larger guard before touch momentum starts", () => { + const { container, list } = createList(); + for (let index = 0; index < 1000; index++) { + list.append(document.createElement("div")); + } + list.stickToBottom = false; + container.scrollTop = 10000; + list.render(); + const idleCount = list.mountedCount; + + container.dispatchEvent(new Event("touchstart")); + + expect(list.dynamicOverscan).toBe(list.activeOverscan); + expect(list.mountedCount).toBeGreaterThan(idleCount); + + list.destroy(); + container.remove(); + }); + + it("keeps overlapping rows mounted during incremental scrolling", () => { + const { container, list } = createList(); + Object.defineProperty(container, "scrollHeight", { + configurable: true, + value: 10400, + }); + const elements = Array.from({ length: 200 }, () => { + const element = document.createElement("div"); + list.append(element); + return element; + }); + list.stickToBottom = false; + container.scrollTop = 520; + list.render(); + const retained = elements[10]; + expect(retained.isConnected).toBe(true); + + container.scrollTop = 624; + container.dispatchEvent(new Event("scroll")); + + expect(retained.isConnected).toBe(true); + expect(list.dynamicOverscan).toBeGreaterThan(list.overscan); + + list.destroy(); + container.remove(); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 8767eaa8f6..2fbead728b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -110,6 +110,7 @@ module.exports = (env, options) => { entry: { main: './src/main.js', console: './src/lib/console.js', + consoleWorker: './src/lib/consoleWorker.js', searchInFilesWorker: './src/sidebarApps/searchInFiles/worker.js', searchIndexWorker: './src/sidebarApps/searchInFiles/indexWorker.js', },