From 4ab0bc7b4e9753f0439bad214c59954f5e3e176c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 4 May 2026 12:27:07 +0200 Subject: [PATCH] feat: gaps in tailwind migration closed --- assets/js/app.js | 1428 +------------- assets/js/bridges/document_commands.js | 19 + assets/js/bridges/menu_runtime.js | 109 +- assets/js/constants.js | 4 + assets/js/hooks/app_shell.js | 226 +++ assets/js/hooks/chat_surface.js | 139 ++ assets/js/hooks/index.js | 19 +- assets/js/hooks/menu_editor_tree.js | 134 ++ assets/js/hooks/monaco_diff_editor.js | 129 ++ assets/js/hooks/monaco_editor.js | 238 +++ assets/js/hooks/section_scroll.js | 31 + assets/js/hooks/sidebar_interactions.js | 24 + assets/js/monaco/languages.js | 145 ++ assets/js/monaco/services.js | 89 +- assets/js/monaco/theme.js | 62 + assets/js/utils/color.js | 46 + assets/js/utils/dom.js | 34 + assets/js/utils/layout.js | 43 + assets/js/utils/script_loader.js | 33 + assets/js/utils/shortcuts.js | 30 + priv/static/assets/app.css | 780 +++++--- priv/static/assets/app.js | 2391 ++++++++++++----------- test/bds/desktop/shell_live_test.exs | 4 +- test/bds/ui/shell_test.exs | 46 +- 24 files changed, 3198 insertions(+), 3005 deletions(-) create mode 100644 assets/js/bridges/document_commands.js create mode 100644 assets/js/constants.js create mode 100644 assets/js/hooks/app_shell.js create mode 100644 assets/js/hooks/chat_surface.js create mode 100644 assets/js/hooks/menu_editor_tree.js create mode 100644 assets/js/hooks/monaco_diff_editor.js create mode 100644 assets/js/hooks/monaco_editor.js create mode 100644 assets/js/hooks/section_scroll.js create mode 100644 assets/js/hooks/sidebar_interactions.js create mode 100644 assets/js/monaco/languages.js create mode 100644 assets/js/monaco/theme.js create mode 100644 assets/js/utils/color.js create mode 100644 assets/js/utils/dom.js create mode 100644 assets/js/utils/layout.js create mode 100644 assets/js/utils/script_loader.js create mode 100644 assets/js/utils/shortcuts.js diff --git a/assets/js/app.js b/assets/js/app.js index 201e82e..c13e620 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,1438 +1,16 @@ import { Socket } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; import "phoenix_html"; -import { syncTitlebarOverlayInsets } from "./bridges/titlebar_overlay.js"; -import { createMenuRuntimeCommandRunner } from "./bridges/menu_runtime.js"; -import { createHooks } from "./hooks/index.js"; -import { createMonacoServices } from "./monaco/services.js"; +import { Hooks } from "./hooks/index.js"; document.addEventListener("DOMContentLoaded", () => { const csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); - const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; - const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; - const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language"; - const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-"; - - const parseShortcutConfig = (value) => { - if (!value) { - return []; - } - - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch (_error) { - return []; - } - }; - - const parseJsonObject = (value) => { - if (!value) { - return null; - } - - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; - } catch (_error) { - return null; - } - }; - - const setMediaThumbnailLoaded = (image, loaded) => { - const thumbnail = image?.closest(".media-thumbnail"); - - if (!thumbnail) { - return; - } - - if (loaded) { - thumbnail.classList.add("is-loaded"); - } else { - thumbnail.classList.remove("is-loaded"); - } - }; - - const syncMediaThumbnailState = (root) => { - root.querySelectorAll(".media-thumbnail-image").forEach((image) => { - setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0)); - }); - }; - - const normalizeShortcutKey = (key) => String(key || "").toLowerCase(); - - const shortcutTargetIsEditable = (event) => { - const tag = event.target?.tagName || null; - return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag); - }; - - const shortcutMatchesEvent = (shortcut, event) => { - const primary = event.metaKey || event.ctrlKey; - - return ( - normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) && - primary === Boolean(shortcut.primary) && - event.shiftKey === Boolean(shortcut.shift) && - event.altKey === Boolean(shortcut.alt) - ); - }; - - const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); - - const readStoredSize = (key, fallback, min, max) => { - const raw = window.localStorage.getItem(key); - - if (!raw) { - return fallback; - } - - const parsed = Number.parseInt(raw, 10); - - if (Number.isNaN(parsed)) { - return fallback; - } - - return clamp(parsed, min, max); - }; - - const shellWidth = (selector) => { - const shell = document.querySelector(selector); - - if (!shell) { - return 0; - } - - const width = Number.parseInt(shell.style.width || "0", 10); - return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width; - }; - - const setShellWidth = (selector, width) => { - const shell = document.querySelector(selector); - - if (shell) { - shell.style.width = `${width}px`; - shell.classList.remove("is-hidden"); - } - }; - - const persistWidth = (target, width) => { - const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY; - window.localStorage.setItem(key, String(width)); - }; - - let monacoLoaderPromise; - let liquidLanguageRegistered = false; - let markdownWithMacrosRegistered = false; - let monacoThemeSignature = null; - const monacoEditors = new Map(); - - const activeMonacoEditor = () => { - for (const editor of monacoEditors.values()) { - if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) { - return editor; - } - } - - return null; - }; - - const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => { - if (!editor) { - return false; - } - - const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null; - - if (action && typeof action.run === "function") { - action.run(); - return true; - } - - if (typeof editor.trigger === "function") { - editor.trigger("bds-menu", triggerId, null); - return true; - } - - return false; - }; - - const runDocumentCommand = (command) => { - if (typeof document.execCommand !== "function") { - return false; - } - - try { - return document.execCommand(command); - } catch (_error) { - return false; - } - }; - - const applyAppZoom = (nextZoom) => { - const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2); - window.__bdsAppZoom = zoom; - document.documentElement.style.zoom = String(zoom); - }; - - const menuRuntimeCommandRunner = createMenuRuntimeCommandRunner({ - activeMonacoEditor, - runMonacoEditorAction, - runDocumentCommand, - applyAppZoom - }); - - const cssVar = (name, fallback) => { - const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - return value || fallback; - }; - - const parseRgbColor = (value) => { - if (!value) { - return null; - } - - const hex = value.match(/^#([0-9a-f]{6})$/i); - - if (hex) { - return { - r: Number.parseInt(hex[1].slice(0, 2), 16), - g: Number.parseInt(hex[1].slice(2, 4), 16), - b: Number.parseInt(hex[1].slice(4, 6), 16) - }; - } - - const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); - - if (!rgb) { - return null; - } - - return { - r: Number.parseInt(rgb[1], 10), - g: Number.parseInt(rgb[2], 10), - b: Number.parseInt(rgb[3], 10) - }; - }; - - const normalizeMonacoColor = (value, fallback) => { - const rgb = parseRgbColor(value); - - if (!rgb) { - return fallback; - } - - return `#${[rgb.r, rgb.g, rgb.b] - .map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0")) - .join("")}`; - }; - - const loadScript = (src) => - new Promise((resolve, reject) => { - const existing = document.querySelector(`script[src="${src}"]`); - - if (existing) { - if (existing.dataset.loaded === "true") { - resolve(); - return; - } - - existing.addEventListener("load", () => resolve(), { once: true }); - existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { - once: true - }); - return; - } - - const script = document.createElement("script"); - script.src = src; - script.async = true; - script.addEventListener( - "load", - () => { - script.dataset.loaded = "true"; - resolve(); - }, - { once: true } - ); - script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { - once: true - }); - document.head.appendChild(script); - }); - - const diffModelPath = (filePath, side) => { - const normalized = String(filePath || "working-tree").replace(/^\/+/, ""); - return `inmemory://model/git-diff/${side}/${normalized}`; - }; - - const registerLiquidLanguage = (monaco) => { - if (liquidLanguageRegistered) { - return; - } - - monaco.languages.register({ id: "liquid" }); - monaco.languages.setLanguageConfiguration("liquid", { - comments: { - blockComment: ["{% comment %}", "{% endcomment %}"] - }, - brackets: [ - ["{", "}"], - ["[", "]"], - ["(", ")"] - ], - autoClosingPairs: [ - { open: "{", close: "}" }, - { open: "[", close: "]" }, - { open: "(", close: ")" }, - { open: '"', close: '"' }, - { open: "'", close: "'" } - ], - surroundingPairs: [ - { open: "{", close: "}" }, - { open: "[", close: "]" }, - { open: "(", close: ")" }, - { open: '"', close: '"' }, - { open: "'", close: "'" } - ] - }); - - monaco.languages.setMonarchTokensProvider("liquid", { - defaultToken: "", - tokenizer: { - root: [ - [/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }], - [/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }], - [/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }], - [/<=!]=?|\.|:/, "operator"], - [/[a-zA-Z_][\w.-]*/, "identifier"], - [/[,:()[\]]/, "delimiter"] - ], - liquidComment: [ - [/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }], - [/./, "comment.block"] - ], - htmlComment: [ - [/-->/, { token: "comment", next: "@pop" }], - [/./, "comment"] - ], - htmlTag: [ - [/\/>/, { token: "delimiter.html", next: "@pop" }], - [/>/, { token: "delimiter.html", next: "@pop" }], - [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], - [/[\w:-]+/, "attribute.name"], - [/=/, "delimiter"] - ], - scriptTag: [ - [/>/, { token: "delimiter.html", next: "@pop" }], - [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], - [/[\w:-]+/, "attribute.name"], - [/=/, "delimiter"] - ], - styleTag: [ - [/>/, { token: "delimiter.html", next: "@pop" }], - [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], - [/[\w:-]+/, "attribute.name"], - [/=/, "delimiter"] - ] - } - }); - - liquidLanguageRegistered = true; - }; - - const registerMarkdownWithMacrosLanguage = (monaco) => { - if (markdownWithMacrosRegistered) { - return; - } - - monaco.languages.register({ id: "markdown-with-macros" }); - monaco.languages.setMonarchTokensProvider("markdown-with-macros", { - defaultToken: "", - tokenPostfix: ".md", - tokenizer: { - root: [ - [/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }], - [/^#{1,6}\s.*$/, "keyword.header"], - [/^\s*>+/, "string.quote"], - [/^\s*[-+*]\s/, "keyword"], - [/^\s*\d+\.\s/, "keyword"], - [/^\s*```\w*/, { token: "string.code", next: "@codeblock" }], - [/\*\*[^*]+\*\*/, "strong"], - [/\*[^*]+\*/, "emphasis"], - [/__[^_]+__/, "strong"], - [/_[^_]+_/, "emphasis"], - [/`[^`]+`/, "variable"], - [/!?\[[^\]]*\]\([^)]*\)/, "string.link"], - [/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"] - ], - macroParams: [ - [/\]\]/, { token: "keyword.macro", next: "@root" }], - [/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"], - [/=/, "delimiter"], - [/"[^"]*"/, "string"], - [/\s+/, "white"], - [/[^\]"=\s]+/, "attribute.value"] - ], - codeblock: [ - [/^\s*```\s*$/, { token: "string.code", next: "@root" }], - [/.*$/, "variable.source"] - ] - } - }); - - markdownWithMacrosRegistered = true; - }; - - const ensureMonacoTheme = (monaco) => { - const background = normalizeMonacoColor( - cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")), - "#1e1e1e" - ); - const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4"); - const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585"); - const activeLineNumber = normalizeMonacoColor( - cssVar("--vscode-editorLineNumber-activeForeground", foreground), - foreground - ); - const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78"); - const inactiveSelection = normalizeMonacoColor( - cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"), - "#3a3d41" - ); - const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground); - const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c"); - const lineHighlight = normalizeMonacoColor( - cssVar("--vscode-editor-lineHighlightBackground", background), - background - ); - const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|"); - - if (signature === monacoThemeSignature) { - monaco.editor.setTheme("bds-theme"); - return; - } - - monaco.editor.defineTheme("bds-theme", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" }, - { token: "attribute.name", foreground: "9CDCFE" }, - { token: "attribute.value", foreground: "CE9178" } - ], - colors: { - "editor.background": background, - "editor.foreground": foreground, - "editor.lineHighlightBackground": lineHighlight, - "editorCursor.foreground": cursor, - "editor.selectionBackground": selection, - "editor.inactiveSelectionBackground": inactiveSelection, - "editorLineNumber.foreground": lineNumber, - "editorLineNumber.activeForeground": activeLineNumber, - "editorIndentGuide.background1": border, - "editorIndentGuide.activeBackground1": foreground, - "editorWidget.border": border, - "editorGutter.background": background, - "focusBorder": border, - "input.border": border - } - }); - - monacoThemeSignature = signature; - monaco.editor.setTheme("bds-theme"); - }; - - const loadMonaco = () => { - if (window.monaco?.editor) { - ensureMonacoTheme(window.monaco); - registerLiquidLanguage(window.monaco); - registerMarkdownWithMacrosLanguage(window.monaco); - return Promise.resolve(window.monaco); - } - - if (monacoLoaderPromise) { - return monacoLoaderPromise; - } - - monacoLoaderPromise = loadScript("/monaco/vs/loader.js") - .then( - () => - new Promise((resolve, reject) => { - window.require.config({ paths: { vs: "/monaco/vs" } }); - window.require(["vs/editor/editor.main"], () => { - ensureMonacoTheme(window.monaco); - registerLiquidLanguage(window.monaco); - registerMarkdownWithMacrosLanguage(window.monaco); - resolve(window.monaco); - }, reject); - }) - ) - .catch((error) => { - monacoLoaderPromise = null; - throw error; - }); - - return monacoLoaderPromise; - }; - - const monacoServices = createMonacoServices({ loadMonaco, ensureMonacoTheme }); - - const Hooks = { - AppShell: { - mounted() { - this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts); - this.currentProjectId = this.el.dataset.projectId || ""; - this.syncStoredLayout(); - this.syncStoredUiLanguage(); - this.destroyOverlaySync = syncTitlebarOverlayInsets(); - - this.workbenchStorageKey = (projectId) => - projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null; - - this.restoreStoredWorkbenchSession = () => { - const projectId = this.el.dataset.projectId || ""; - const storageKey = this.workbenchStorageKey(projectId); - - if (!storageKey) { - return false; - } - - const session = parseJsonObject(window.localStorage.getItem(storageKey)); - - if (!session) { - return false; - } - - this.pushEvent("restore_workbench_session", { session }); - return true; - }; - - this.persistWorkbenchSession = () => { - const projectId = this.el.dataset.projectId || ""; - const storageKey = this.workbenchStorageKey(projectId); - const session = this.el.dataset.workbenchSession; - - if (!storageKey || !session) { - return; - } - - window.localStorage.setItem(storageKey, session); - }; - - this.handleMouseDown = (event) => { - const handle = event.target.closest("[data-role='resize-handle']"); - - if (!handle || !this.el.contains(handle)) { - return; - } - - event.preventDefault(); - - const target = handle.dataset.resize; - const startX = event.clientX; - const startWidth = - target === "assistant" - ? shellWidth("[data-testid='assistant-shell']") - : shellWidth("[data-testid='sidebar-shell']"); - - const min = target === "assistant" ? 280 : 200; - const max = target === "assistant" ? 640 : 500; - const invert = target === "assistant"; - - const onMouseMove = (moveEvent) => { - const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX; - const width = clamp(startWidth + delta, min, max); - const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']"; - - setShellWidth(selector, width); - persistWidth(target, width); - }; - - const onMouseUp = (upEvent) => { - const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX; - const width = clamp(startWidth + delta, min, max); - - persistWidth(target, width); - this.pushEvent("resize_panel", { target, width }); - - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - }; - - this.el.addEventListener("mousedown", this.handleMouseDown); - - this.handleNativeMenuAction = (event) => { - const action = event.detail?.action; - const ackId = event.detail?.ackId; - - if (action) { - this.pushEvent("native_menu_action", { action }, () => { - if (ackId) { - window.dispatchEvent( - new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } }) - ); - } - }); - } - }; - - this.handleChange = (event) => { - const select = event.target.closest(".status-bar-language-select"); - - if (select && this.el.contains(select)) { - window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value); - } - }; - - this.handleShortcutKeyDown = (event) => { - if (shortcutTargetIsEditable(event)) { - return; - } - - const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event)); - - if (!shortcut) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - this.pushEvent("shortcut", { - key: normalizeShortcutKey(event.key), - meta: event.metaKey, - ctrl: event.ctrlKey, - alt: event.altKey, - shift: event.shiftKey, - tag: event.target?.tagName || null, - contentEditable: event.target?.isContentEditable || false - }); - }; - - this.handleThumbnailLoad = (event) => { - if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { - setMediaThumbnailLoaded(event.target, true); - } - }; - - this.handleThumbnailError = (event) => { - if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { - setMediaThumbnailLoaded(event.target, false); - } - }; - - this.handleEvent("menu-runtime-command", ({ action }) => { - if (action) { - menuRuntimeCommandRunner(String(action)); - } - }); - - window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); - window.addEventListener("keydown", this.handleShortcutKeyDown, true); - this.el.addEventListener("load", this.handleThumbnailLoad, true); - this.el.addEventListener("error", this.handleThumbnailError, true); - this.el.addEventListener("change", this.handleChange); - syncMediaThumbnailState(this.el); - this.restoreStoredWorkbenchSession(); - }, - - updated() { - const nextProjectId = this.el.dataset.projectId || ""; - - if (nextProjectId !== this.currentProjectId) { - this.currentProjectId = nextProjectId; - - if (this.restoreStoredWorkbenchSession()) { - return; - } - } - - syncMediaThumbnailState(this.el); - this.persistWorkbenchSession(); - }, - - destroyed() { - this.el.removeEventListener("mousedown", this.handleMouseDown); - this.el.removeEventListener("load", this.handleThumbnailLoad, true); - this.el.removeEventListener("error", this.handleThumbnailError, true); - this.el.removeEventListener("change", this.handleChange); - window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction); - window.removeEventListener("keydown", this.handleShortcutKeyDown, true); - if (this.destroyOverlaySync) { - this.destroyOverlaySync(); - } - }, - - syncStoredLayout() { - this.pushEvent("sync_layout", { - sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500), - assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640) - }); - }, - - syncStoredUiLanguage() { - const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY); - - if (stored) { - this.pushEvent("sync_ui_language", { language: stored }); - } - } - }, - - SidebarInteractions: { - mounted() { - this.handleDblClick = (event) => { - const button = event.target.closest("[data-testid='sidebar-open-item']"); - - if (!button || !this.el.contains(button)) { - return; - } - - this.pushEvent("pin_sidebar_item", { - route: button.dataset.route, - id: button.dataset.itemId, - title: button.dataset.openTitle || "", - subtitle: button.dataset.openSubtitle || "" - }); - }; - - this.el.addEventListener("dblclick", this.handleDblClick); - }, - - destroyed() { - this.el.removeEventListener("dblclick", this.handleDblClick); - } - }, - - SettingsSectionScroll: { - mounted() { - this.lastTargetId = null; - this.scrollToSelectedSection(); - }, - - updated() { - this.scrollToSelectedSection(); - }, - - scrollToSelectedSection() { - const targetId = this.el.dataset.settingsScrollTarget; - - if (!targetId || targetId === this.lastTargetId) { - return; - } - - this.lastTargetId = targetId; - - window.requestAnimationFrame(() => { - const target = document.getElementById(targetId); - - if (target && this.el.contains(target)) { - target.scrollIntoView({ block: "start", behavior: "smooth" }); - } - }); - } - }, - - TagsSectionScroll: { - mounted() { - this.lastTargetId = null; - this.scrollToSelectedSection(); - }, - - updated() { - this.scrollToSelectedSection(); - }, - - scrollToSelectedSection() { - const targetId = this.el.dataset.tagsScrollTarget; - - if (!targetId || targetId === this.lastTargetId) { - return; - } - - this.lastTargetId = targetId; - - window.requestAnimationFrame(() => { - const target = document.getElementById(targetId); - - if (target && this.el.contains(target)) { - target.scrollIntoView({ block: "start", behavior: "smooth" }); - } - }); - } - }, - - ChatSurface: { - mounted() { - this.stickToBottom = true; - this.scrollContainer = null; - - this.autoResize = () => { - const textarea = this.el.querySelector(".chat-input"); - - if (!textarea) { - return; - } - - const styles = getComputedStyle(textarea); - const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20; - const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160; - - textarea.rows = 1; - textarea.style.minHeight = `${minHeight}px`; - - if (textarea.value.trim() === "") { - textarea.style.height = `${minHeight}px`; - textarea.style.maxHeight = `${minHeight}px`; - textarea.style.overflowY = "hidden"; - return; - } - - textarea.style.maxHeight = `${maxHeight}px`; - textarea.style.height = "0px"; - const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight); - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden"; - }; - - this.syncScrollContainer = () => { - const nextContainer = this.el.querySelector(".chat-messages"); - - if (nextContainer === this.scrollContainer) { - return; - } - - if (this.scrollContainer) { - this.scrollContainer.removeEventListener("scroll", this.handleScroll); - } - - this.scrollContainer = nextContainer; - - if (this.scrollContainer) { - this.scrollContainer.addEventListener("scroll", this.handleScroll); - } - }; - - this.scrollToBottom = (force = false) => { - if (!this.scrollContainer) { - return; - } - - if (force || this.stickToBottom) { - this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight; - } - }; - - this.syncExpandedSurfaces = () => { - this.el - .querySelectorAll(".chat-inline-surface[data-expanded='true']") - .forEach((surface) => { - surface.open = true; - }); - }; - - this.surfaceObserver = new MutationObserver(() => { - this.syncExpandedSurfaces(); - }); - - this.handleScroll = () => { - if (!this.scrollContainer) { - this.stickToBottom = true; - return; - } - - const distanceFromBottom = - this.scrollContainer.scrollHeight - - this.scrollContainer.scrollTop - - this.scrollContainer.clientHeight; - - this.stickToBottom = distanceFromBottom < 48; - }; - - this.handleInput = (event) => { - if (!event.target.closest(".chat-input")) { - return; - } - - this.stickToBottom = true; - this.autoResize(); - }; - - this.handleKeyDown = (event) => { - if (!event.target.closest(".chat-input")) { - return; - } - - if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { - event.preventDefault(); - - const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); - - if (sendButton && !sendButton.disabled) { - sendButton.click(); - } - } - }; - - this.el.addEventListener("input", this.handleInput); - this.el.addEventListener("keydown", this.handleKeyDown); - - this.syncScrollContainer(); - this.syncExpandedSurfaces(); - this.surfaceObserver.observe(this.el, { childList: true, subtree: true }); - this.autoResize(); - window.requestAnimationFrame(() => this.scrollToBottom(true)); - }, - - updated() { - this.syncScrollContainer(); - this.syncExpandedSurfaces(); - this.autoResize(); - window.requestAnimationFrame(() => this.scrollToBottom()); - }, - - destroyed() { - this.surfaceObserver.disconnect(); - this.el.removeEventListener("input", this.handleInput); - this.el.removeEventListener("keydown", this.handleKeyDown); - - if (this.scrollContainer) { - this.scrollContainer.removeEventListener("scroll", this.handleScroll); - } - } - }, - - MenuEditorTree: { - mounted() { - this.dragItemId = null; - this.dragSourceEl = null; - this.dropTargetEl = null; - this.dropPosition = null; - - this.clearDropTarget = () => { - if (this.dropTargetEl) { - this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside"); - } - - this.dropTargetEl = null; - this.dropPosition = null; - }; - - this.setDropTarget = (row, position) => { - if (this.dropTargetEl === row && this.dropPosition === position) { - return; - } - - this.clearDropTarget(); - this.dropTargetEl = row; - this.dropPosition = position; - row.classList.add(`is-drop-${position}`); - }; - - this.handleDragStart = (event) => { - const handle = event.target.closest("[data-menu-drag-handle='true']"); - const row = event.target.closest("[data-menu-item-id]"); - - if (!handle || !row || !this.el.contains(row)) { - return; - } - - this.dragItemId = row.dataset.menuItemId || null; - this.dragSourceEl = row; - row.classList.add("is-dragging"); - - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", this.dragItemId || ""); - } - }; - - this.handleDragOver = (event) => { - const row = event.target.closest("[data-menu-item-id]"); - - if (!this.dragItemId || !row || !this.el.contains(row)) { - this.clearDropTarget(); - return; - } - - const targetItemId = row.dataset.menuItemId || ""; - - if (!targetItemId || targetItemId === this.dragItemId) { - this.clearDropTarget(); - return; - } - - event.preventDefault(); - - const rect = row.getBoundingClientRect(); - const offsetY = event.clientY - rect.top; - const allowInside = row.dataset.menuCanDropInside === "true"; - const insideBandTop = rect.height * 0.3; - const insideBandBottom = rect.height * 0.7; - - const position = - allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom - ? "inside" - : offsetY < rect.height / 2 - ? "before" - : "after"; - - this.setDropTarget(row, position); - - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "move"; - } - }; - - this.handleDrop = (event) => { - const row = event.target.closest("[data-menu-item-id]"); - - if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) { - this.clearDropTarget(); - return; - } - - event.preventDefault(); - - this.pushEvent("menu_editor_drop_item", { - drag_item_id: this.dragItemId, - target_item_id: row.dataset.menuItemId, - position: this.dropPosition - }); - - this.clearDropTarget(); - }; - - this.handleDragLeave = (event) => { - const related = event.relatedTarget; - - if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) { - this.clearDropTarget(); - } - }; - - this.handleDragEnd = () => { - if (this.dragSourceEl) { - this.dragSourceEl.classList.remove("is-dragging"); - } - - this.dragItemId = null; - this.dragSourceEl = null; - this.clearDropTarget(); - }; - - this.el.addEventListener("dragstart", this.handleDragStart); - this.el.addEventListener("dragover", this.handleDragOver); - this.el.addEventListener("drop", this.handleDrop); - this.el.addEventListener("dragleave", this.handleDragLeave); - this.el.addEventListener("dragend", this.handleDragEnd); - }, - - destroyed() { - this.el.removeEventListener("dragstart", this.handleDragStart); - this.el.removeEventListener("dragover", this.handleDragOver); - this.el.removeEventListener("drop", this.handleDrop); - this.el.removeEventListener("dragleave", this.handleDragLeave); - this.el.removeEventListener("dragend", this.handleDragEnd); - } - }, - - MonacoEditor: { - mounted() { - this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); - this.host = this.el.querySelector(".monaco-editor-instance"); - this.language = this.el.dataset.monacoLanguage || "plaintext"; - this.wordWrap = this.el.dataset.monacoWordWrap || "off"; - this.editorId = this.el.dataset.monacoEditorId || ""; - this.insertEvent = this.el.dataset.monacoInsertEvent || ""; - this.syncTimer = null; - this.isApplyingRemoteUpdate = false; - this.lastKnownValue = this.textarea?.value || ""; - - this.syncEditorFromTextarea = () => { - this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); - - if (!this.textarea || !this.editor) { - return; - } - - const value = this.textarea.value || ""; - - if (this.editor.getValue() !== value) { - this.isApplyingRemoteUpdate = true; - this.editor.setValue(value); - this.isApplyingRemoteUpdate = false; - } - - this.lastKnownValue = value; - }; - - this.layoutEditorSoon = () => { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (!this.editor) { - return; - } - - this.editor.layout(); - }); - }); - }; - - this.waitForMonacoVisibleSize = () => - new Promise((resolve) => { - let settled = false; - let attempts = 0; - - const hasVisibleSize = () => { - const rect = this.host?.getBoundingClientRect(); - return Boolean(rect && rect.width > 0 && rect.height > 0); - }; - - const finish = () => { - if (settled) { - return; - } - - settled = true; - this.visibleSizeObserver?.disconnect(); - this.visibleSizeObserver = null; - resolve(); - }; - - const check = () => { - if (hasVisibleSize() || attempts >= 20) { - finish(); - return; - } - - attempts += 1; - window.requestAnimationFrame(check); - }; - - if (hasVisibleSize()) { - finish(); - return; - } - - if (window.ResizeObserver && this.host) { - this.visibleSizeObserver = new ResizeObserver(() => { - if (hasVisibleSize()) { - finish(); - } - }); - this.visibleSizeObserver.observe(this.host); - } - - window.requestAnimationFrame(check); - }); - - this.queueSync = () => { - if (!this.textarea || !this.editor) { - return; - } - - window.clearTimeout(this.syncTimer); - this.syncTimer = window.setTimeout(() => { - if (!this.textarea || !this.editor) { - return; - } - - const value = this.editor.getValue(); - - if (this.textarea.value === value) { - return; - } - - this.lastKnownValue = value; - this.textarea.value = value; - this.textarea.dispatchEvent(new Event("input", { bubbles: true })); - }, 120); - }; - - this.handleInsert = ({ id, content }) => { - if (!this.editor || !content || String(id) !== String(this.editorId)) { - return; - } - - const model = this.editor.getModel(); - const selection = this.editor.getSelection(); - - if (!model || !selection) { - return; - } - - const value = this.editor.getValue(); - const start = model.getOffsetAt(selection.getStartPosition()); - const end = model.getOffsetAt(selection.getEndPosition()); - const before = value.slice(0, start); - const after = value.slice(end); - const separator = before !== "" && !before.endsWith("\n") ? "\n" : ""; - const suffix = after !== "" && !content.endsWith("\n") ? "\n" : ""; - const inserted = `${separator}${content}${suffix}`; - this.editor.executeEdits("bds-insert-content", [ - { - range: selection, - text: inserted, - forceMoveMarkers: true - } - ]); - this.editor.focus(); - }; - - monacoServices.loadMonaco() - .then(async (monaco) => { - if (!this.host || !this.textarea) { - return; - } - - await this.waitForMonacoVisibleSize(); - - monacoServices.ensureMonacoTheme(monaco); - - this.editor = monaco.editor.create(this.host, { - value: this.textarea.value || "", - language: this.language, - theme: "bds-theme", - automaticLayout: true, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: this.wordWrap, - lineNumbers: "on", - lineNumbersMinChars: 3, - fontSize: 14, - fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", - padding: { top: 12, bottom: 12 }, - roundedSelection: false, - renderLineHighlight: "line", - formatOnPaste: true, - cursorStyle: "line", - cursorBlinking: "smooth", - quickSuggestions: this.language === "markdown-with-macros" ? false : true, - tabSize: 2, - insertSpaces: true - }); - - monacoEditors.set(this.editorId || this.el.id, this.editor); - monaco.editor.setTheme("bds-theme"); - this.syncEditorFromTextarea(); - this.layoutEditorSoon(); - - this.changeSubscription = this.editor.onDidChangeModelContent(() => { - if (this.isApplyingRemoteUpdate) { - return; - } - - this.queueSync(); - }); - - if (this.insertEvent) { - this.handleEvent(this.insertEvent, this.handleInsert); - } - }) - .catch((error) => { - console.error("Failed to load Monaco editor", error); - }); - }, - - updated() { - this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); - this.host = this.el.querySelector(".monaco-editor-instance"); - this.language = this.el.dataset.monacoLanguage || this.language || "plaintext"; - this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off"; - - if (!this.editor || !this.textarea) { - return; - } - - monacoServices.loadMonaco().then((monaco) => { - monacoServices.ensureMonacoTheme(monaco); - monaco.editor.setTheme("bds-theme"); - - if (this.editor.getModel()?.getLanguageId() !== this.language) { - monaco.editor.setModelLanguage(this.editor.getModel(), this.language); - } - - this.editor.updateOptions({ wordWrap: this.wordWrap }); - }); - - this.syncEditorFromTextarea(); - this.layoutEditorSoon(); - }, - - destroyed() { - window.clearTimeout(this.syncTimer); - this.visibleSizeObserver?.disconnect(); - this.changeSubscription?.dispose(); - monacoEditors.delete(this.editorId || this.el.id); - this.editor?.dispose(); - } - }, - - MonacoDiffEditor: { - mounted() { - this.host = this.el.querySelector(".monaco-diff-editor-instance"); - this.originalInput = this.el.querySelector(".monaco-diff-original"); - this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); - this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; - this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; - this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; - this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; - this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; - - this.readValues = () => ({ - original: this.originalInput?.value || "", - modified: this.modifiedInput?.value || "" - }); - - this.applyDataset = () => { - this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; - this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; - this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; - this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; - this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; - }; - - this.setModels = (monaco) => { - const values = this.readValues(); - - this.originalModel?.dispose(); - this.modifiedModel?.dispose(); - - this.originalModel = monaco.editor.createModel( - values.original, - this.language, - monaco.Uri.parse(diffModelPath(this.filePath, "original")) - ); - - this.modifiedModel = monaco.editor.createModel( - values.modified, - this.language, - monaco.Uri.parse(diffModelPath(this.filePath, "modified")) - ); - - this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel }); - this.lastFilePath = this.filePath; - }; - - monacoServices.loadMonaco() - .then((monaco) => { - if (!this.host) { - return; - } - - monacoServices.ensureMonacoTheme(monaco); - - this.editor = monaco.editor.createDiffEditor(this.host, { - theme: "bds-theme", - automaticLayout: true, - readOnly: true, - renderSideBySide: this.viewStyle === "side-by-side", - minimap: { enabled: false }, - scrollBeyondLastLine: false, - lineNumbers: "on", - diffCodeLens: false, - originalEditable: false, - wordWrap: this.wordWrap, - hideUnchangedRegions: { enabled: this.hideUnchanged }, - ignoreTrimWhitespace: false - }); - - this.setModels(monaco); - }) - .catch((error) => { - console.error("Failed to load Monaco diff editor", error); - }); - }, - - updated() { - this.host = this.el.querySelector(".monaco-diff-editor-instance"); - this.originalInput = this.el.querySelector(".monaco-diff-original"); - this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); - this.applyDataset(); - - if (!this.editor) { - return; - } - - monacoServices.loadMonaco().then((monaco) => { - monacoServices.ensureMonacoTheme(monaco); - monaco.editor.setTheme("bds-theme"); - - this.editor.updateOptions({ - renderSideBySide: this.viewStyle === "side-by-side", - wordWrap: this.wordWrap, - hideUnchangedRegions: { enabled: this.hideUnchanged } - }); - - if (this.lastFilePath !== this.filePath) { - this.setModels(monaco); - return; - } - - const values = this.readValues(); - - if (this.originalModel && this.originalModel.getLanguageId() !== this.language) { - monaco.editor.setModelLanguage(this.originalModel, this.language); - } - - if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) { - monaco.editor.setModelLanguage(this.modifiedModel, this.language); - } - - if (this.originalModel && this.originalModel.getValue() !== values.original) { - this.originalModel.setValue(values.original); - } - - if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) { - this.modifiedModel.setValue(values.modified); - } - }); - }, - - destroyed() { - this.originalModel?.dispose(); - this.modifiedModel?.dispose(); - this.editor?.dispose(); - } - } - }; - const liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, - hooks: createHooks(Hooks), + hooks: Hooks, metadata: { keydown: (event) => ({ key: event.key, @@ -1448,4 +26,4 @@ document.addEventListener("DOMContentLoaded", () => { liveSocket.connect(); window.liveSocket = liveSocket; -}); \ No newline at end of file +}); diff --git a/assets/js/bridges/document_commands.js b/assets/js/bridges/document_commands.js new file mode 100644 index 0000000..cd218aa --- /dev/null +++ b/assets/js/bridges/document_commands.js @@ -0,0 +1,19 @@ +import { clamp } from "../utils/dom.js"; + +export const applyAppZoom = (nextZoom) => { + const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2); + window.__bdsAppZoom = zoom; + document.documentElement.style.zoom = String(zoom); +}; + +export const runDocumentCommand = (command) => { + if (typeof document.execCommand !== "function") { + return false; + } + + try { + return document.execCommand(command); + } catch (_error) { + return false; + } +}; diff --git a/assets/js/bridges/menu_runtime.js b/assets/js/bridges/menu_runtime.js index 35083d6..51d7ac5 100644 --- a/assets/js/bridges/menu_runtime.js +++ b/assets/js/bridges/menu_runtime.js @@ -1,57 +1,58 @@ -export const createMenuRuntimeCommandRunner = ({ activeMonacoEditor, runMonacoEditorAction, runDocumentCommand, applyAppZoom }) => { - return (action) => { - const editor = activeMonacoEditor(); +import { activeMonacoEditor, runMonacoEditorAction } from "../monaco/services.js"; +import { applyAppZoom, runDocumentCommand } from "./document_commands.js"; - switch (action) { - case "undo": - return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo"); - case "redo": - return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo"); - case "cut": - return editor - ? runMonacoEditorAction(editor, "editor.action.clipboardCutAction") - : runDocumentCommand("cut"); - case "copy": - return editor - ? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction") - : runDocumentCommand("copy"); - case "paste": - return editor - ? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction") - : runDocumentCommand("paste"); - case "delete": - return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete"); - case "select_all": - return editor - ? runMonacoEditorAction(editor, "editor.action.selectAll") - : runDocumentCommand("selectAll"); - case "find": - return editor ? runMonacoEditorAction(editor, "actions.find") : false; - case "replace": - return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false; - case "reload": - case "force_reload": - window.location.reload(); - return true; - case "reset_zoom": - applyAppZoom(1); - return true; - case "zoom_in": - applyAppZoom((window.__bdsAppZoom || 1) + 0.1); - return true; - case "zoom_out": - applyAppZoom((window.__bdsAppZoom || 1) - 0.1); - return true; - case "toggle_full_screen": - if (document.fullscreenElement) { - document.exitFullscreen?.(); - } else { - document.documentElement.requestFullscreen?.(); - } +export const runMenuRuntimeCommand = (action) => { + const editor = activeMonacoEditor(); - return true; - default: - return false; - } - }; + switch (action) { + case "undo": + return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo"); + case "redo": + return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo"); + case "cut": + return editor + ? runMonacoEditorAction(editor, "editor.action.clipboardCutAction") + : runDocumentCommand("cut"); + case "copy": + return editor + ? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction") + : runDocumentCommand("copy"); + case "paste": + return editor + ? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction") + : runDocumentCommand("paste"); + case "delete": + return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete"); + case "select_all": + return editor + ? runMonacoEditorAction(editor, "editor.action.selectAll") + : runDocumentCommand("selectAll"); + case "find": + return editor ? runMonacoEditorAction(editor, "actions.find") : false; + case "replace": + return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false; + case "reload": + case "force_reload": + window.location.reload(); + return true; + case "reset_zoom": + applyAppZoom(1); + return true; + case "zoom_in": + applyAppZoom((window.__bdsAppZoom || 1) + 0.1); + return true; + case "zoom_out": + applyAppZoom((window.__bdsAppZoom || 1) - 0.1); + return true; + case "toggle_full_screen": + if (document.fullscreenElement) { + document.exitFullscreen?.(); + } else { + document.documentElement.requestFullscreen?.(); + } + + return true; + default: + return false; + } }; diff --git a/assets/js/constants.js b/assets/js/constants.js new file mode 100644 index 0000000..c92b897 --- /dev/null +++ b/assets/js/constants.js @@ -0,0 +1,4 @@ +export const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; +export const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; +export const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language"; +export const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-"; diff --git a/assets/js/hooks/app_shell.js b/assets/js/hooks/app_shell.js new file mode 100644 index 0000000..ea03d6d --- /dev/null +++ b/assets/js/hooks/app_shell.js @@ -0,0 +1,226 @@ +import { + SIDEBAR_STORAGE_KEY, + ASSISTANT_STORAGE_KEY, + UI_LANGUAGE_STORAGE_KEY, + WORKBENCH_SESSION_STORAGE_KEY_PREFIX +} from "../constants.js"; +import { + parseJsonObject, + setMediaThumbnailLoaded, + syncMediaThumbnailState, + clamp +} from "../utils/dom.js"; +import { shellWidth, setShellWidth, persistWidth, readStoredSize } from "../utils/layout.js"; +import { + parseShortcutConfig, + normalizeShortcutKey, + shortcutMatchesEvent, + shortcutTargetIsEditable +} from "../utils/shortcuts.js"; +import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js"; +import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js"; + +export const AppShell = { + mounted() { + this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts); + this.currentProjectId = this.el.dataset.projectId || ""; + this.syncStoredLayout(); + this.syncStoredUiLanguage(); + this.destroyOverlaySync = syncTitlebarOverlayInsets(); + + this.workbenchStorageKey = (projectId) => + projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null; + + this.restoreStoredWorkbenchSession = () => { + const projectId = this.el.dataset.projectId || ""; + const storageKey = this.workbenchStorageKey(projectId); + + if (!storageKey) { + return false; + } + + const session = parseJsonObject(window.localStorage.getItem(storageKey)); + + if (!session) { + return false; + } + + this.pushEvent("restore_workbench_session", { session }); + return true; + }; + + this.persistWorkbenchSession = () => { + const projectId = this.el.dataset.projectId || ""; + const storageKey = this.workbenchStorageKey(projectId); + const session = this.el.dataset.workbenchSession; + + if (!storageKey || !session) { + return; + } + + window.localStorage.setItem(storageKey, session); + }; + + this.handleMouseDown = (event) => { + const handle = event.target.closest("[data-role='resize-handle']"); + + if (!handle || !this.el.contains(handle)) { + return; + } + + event.preventDefault(); + + const target = handle.dataset.resize; + const startX = event.clientX; + const startWidth = + target === "assistant" + ? shellWidth("[data-testid='assistant-shell']") + : shellWidth("[data-testid='sidebar-shell']"); + + const min = target === "assistant" ? 280 : 200; + const max = target === "assistant" ? 640 : 500; + const invert = target === "assistant"; + + const onMouseMove = (moveEvent) => { + const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX; + const width = clamp(startWidth + delta, min, max); + const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']"; + + setShellWidth(selector, width); + persistWidth(target, width); + }; + + const onMouseUp = (upEvent) => { + const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX; + const width = clamp(startWidth + delta, min, max); + + persistWidth(target, width); + this.pushEvent("resize_panel", { target, width }); + + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + }; + + this.el.addEventListener("mousedown", this.handleMouseDown); + + this.handleNativeMenuAction = (event) => { + const action = event.detail?.action; + const ackId = event.detail?.ackId; + + if (action) { + this.pushEvent("native_menu_action", { action }, () => { + if (ackId) { + window.dispatchEvent( + new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } }) + ); + } + }); + } + }; + + this.handleChange = (event) => { + const select = event.target.closest(".status-bar-language-select"); + + if (select && this.el.contains(select)) { + window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value); + } + }; + + this.handleShortcutKeyDown = (event) => { + if (shortcutTargetIsEditable(event)) { + return; + } + + const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event)); + + if (!shortcut) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + this.pushEvent("shortcut", { + key: normalizeShortcutKey(event.key), + meta: event.metaKey, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + tag: event.target?.tagName || null, + contentEditable: event.target?.isContentEditable || false + }); + }; + + this.handleThumbnailLoad = (event) => { + if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { + setMediaThumbnailLoaded(event.target, true); + } + }; + + this.handleThumbnailError = (event) => { + if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { + setMediaThumbnailLoaded(event.target, false); + } + }; + + this.handleEvent("menu-runtime-command", ({ action }) => { + if (action) { + runMenuRuntimeCommand(String(action)); + } + }); + + window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); + window.addEventListener("keydown", this.handleShortcutKeyDown, true); + this.el.addEventListener("load", this.handleThumbnailLoad, true); + this.el.addEventListener("error", this.handleThumbnailError, true); + this.el.addEventListener("change", this.handleChange); + syncMediaThumbnailState(this.el); + this.restoreStoredWorkbenchSession(); + }, + + updated() { + const nextProjectId = this.el.dataset.projectId || ""; + + if (nextProjectId !== this.currentProjectId) { + this.currentProjectId = nextProjectId; + + if (this.restoreStoredWorkbenchSession()) { + return; + } + } + + syncMediaThumbnailState(this.el); + this.persistWorkbenchSession(); + }, + + destroyed() { + this.el.removeEventListener("mousedown", this.handleMouseDown); + this.el.removeEventListener("load", this.handleThumbnailLoad, true); + this.el.removeEventListener("error", this.handleThumbnailError, true); + this.el.removeEventListener("change", this.handleChange); + window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction); + window.removeEventListener("keydown", this.handleShortcutKeyDown, true); + if (this.destroyOverlaySync) { + this.destroyOverlaySync(); + } + }, + + syncStoredLayout() { + this.pushEvent("sync_layout", { + sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500), + assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640) + }); + }, + + syncStoredUiLanguage() { + const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY); + + if (stored) { + this.pushEvent("sync_ui_language", { language: stored }); + } + } +}; diff --git a/assets/js/hooks/chat_surface.js b/assets/js/hooks/chat_surface.js new file mode 100644 index 0000000..3baf0a3 --- /dev/null +++ b/assets/js/hooks/chat_surface.js @@ -0,0 +1,139 @@ +export const ChatSurface = { + mounted() { + this.stickToBottom = true; + this.scrollContainer = null; + + this.autoResize = () => { + const textarea = this.el.querySelector(".chat-input"); + + if (!textarea) { + return; + } + + const styles = getComputedStyle(textarea); + const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20; + const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160; + + textarea.rows = 1; + textarea.style.minHeight = `${minHeight}px`; + + if (textarea.value.trim() === "") { + textarea.style.height = `${minHeight}px`; + textarea.style.maxHeight = `${minHeight}px`; + textarea.style.overflowY = "hidden"; + return; + } + + textarea.style.maxHeight = `${maxHeight}px`; + textarea.style.height = "0px"; + const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight); + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden"; + }; + + this.syncScrollContainer = () => { + const nextContainer = this.el.querySelector(".chat-messages"); + + if (nextContainer === this.scrollContainer) { + return; + } + + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.handleScroll); + } + + this.scrollContainer = nextContainer; + + if (this.scrollContainer) { + this.scrollContainer.addEventListener("scroll", this.handleScroll); + } + }; + + this.scrollToBottom = (force = false) => { + if (!this.scrollContainer) { + return; + } + + if (force || this.stickToBottom) { + this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight; + } + }; + + this.syncExpandedSurfaces = () => { + this.el + .querySelectorAll(".chat-inline-surface[data-expanded='true']") + .forEach((surface) => { + surface.open = true; + }); + }; + + this.surfaceObserver = new MutationObserver(() => { + this.syncExpandedSurfaces(); + }); + + this.handleScroll = () => { + if (!this.scrollContainer) { + this.stickToBottom = true; + return; + } + + const distanceFromBottom = + this.scrollContainer.scrollHeight - + this.scrollContainer.scrollTop - + this.scrollContainer.clientHeight; + + this.stickToBottom = distanceFromBottom < 48; + }; + + this.handleInput = (event) => { + if (!event.target.closest(".chat-input")) { + return; + } + + this.stickToBottom = true; + this.autoResize(); + }; + + this.handleKeyDown = (event) => { + if (!event.target.closest(".chat-input")) { + return; + } + + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + + const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); + + if (sendButton && !sendButton.disabled) { + sendButton.click(); + } + } + }; + + this.el.addEventListener("input", this.handleInput); + this.el.addEventListener("keydown", this.handleKeyDown); + + this.syncScrollContainer(); + this.syncExpandedSurfaces(); + this.surfaceObserver.observe(this.el, { childList: true, subtree: true }); + this.autoResize(); + window.requestAnimationFrame(() => this.scrollToBottom(true)); + }, + + updated() { + this.syncScrollContainer(); + this.syncExpandedSurfaces(); + this.autoResize(); + window.requestAnimationFrame(() => this.scrollToBottom()); + }, + + destroyed() { + this.surfaceObserver.disconnect(); + this.el.removeEventListener("input", this.handleInput); + this.el.removeEventListener("keydown", this.handleKeyDown); + + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.handleScroll); + } + } +}; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index 2567c71..cc0daba 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1 +1,18 @@ -export const createHooks = (hooks) => hooks; +import { AppShell } from "./app_shell.js"; +import { SidebarInteractions } from "./sidebar_interactions.js"; +import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js"; +import { ChatSurface } from "./chat_surface.js"; +import { MenuEditorTree } from "./menu_editor_tree.js"; +import { MonacoEditor } from "./monaco_editor.js"; +import { MonacoDiffEditor } from "./monaco_diff_editor.js"; + +export const Hooks = { + AppShell, + SidebarInteractions, + SettingsSectionScroll, + TagsSectionScroll, + ChatSurface, + MenuEditorTree, + MonacoEditor, + MonacoDiffEditor +}; diff --git a/assets/js/hooks/menu_editor_tree.js b/assets/js/hooks/menu_editor_tree.js new file mode 100644 index 0000000..9cafce4 --- /dev/null +++ b/assets/js/hooks/menu_editor_tree.js @@ -0,0 +1,134 @@ +export const MenuEditorTree = { + mounted() { + this.dragItemId = null; + this.dragSourceEl = null; + this.dropTargetEl = null; + this.dropPosition = null; + + this.clearDropTarget = () => { + if (this.dropTargetEl) { + this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside"); + } + + this.dropTargetEl = null; + this.dropPosition = null; + }; + + this.setDropTarget = (row, position) => { + if (this.dropTargetEl === row && this.dropPosition === position) { + return; + } + + this.clearDropTarget(); + this.dropTargetEl = row; + this.dropPosition = position; + row.classList.add(`is-drop-${position}`); + }; + + this.handleDragStart = (event) => { + const handle = event.target.closest("[data-menu-drag-handle='true']"); + const row = event.target.closest("[data-menu-item-id]"); + + if (!handle || !row || !this.el.contains(row)) { + return; + } + + this.dragItemId = row.dataset.menuItemId || null; + this.dragSourceEl = row; + row.classList.add("is-dragging"); + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", this.dragItemId || ""); + } + }; + + this.handleDragOver = (event) => { + const row = event.target.closest("[data-menu-item-id]"); + + if (!this.dragItemId || !row || !this.el.contains(row)) { + this.clearDropTarget(); + return; + } + + const targetItemId = row.dataset.menuItemId || ""; + + if (!targetItemId || targetItemId === this.dragItemId) { + this.clearDropTarget(); + return; + } + + event.preventDefault(); + + const rect = row.getBoundingClientRect(); + const offsetY = event.clientY - rect.top; + const allowInside = row.dataset.menuCanDropInside === "true"; + const insideBandTop = rect.height * 0.3; + const insideBandBottom = rect.height * 0.7; + + const position = + allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom + ? "inside" + : offsetY < rect.height / 2 + ? "before" + : "after"; + + this.setDropTarget(row, position); + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + }; + + this.handleDrop = (event) => { + const row = event.target.closest("[data-menu-item-id]"); + + if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) { + this.clearDropTarget(); + return; + } + + event.preventDefault(); + + this.pushEvent("menu_editor_drop_item", { + drag_item_id: this.dragItemId, + target_item_id: row.dataset.menuItemId, + position: this.dropPosition + }); + + this.clearDropTarget(); + }; + + this.handleDragLeave = (event) => { + const related = event.relatedTarget; + + if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) { + this.clearDropTarget(); + } + }; + + this.handleDragEnd = () => { + if (this.dragSourceEl) { + this.dragSourceEl.classList.remove("is-dragging"); + } + + this.dragItemId = null; + this.dragSourceEl = null; + this.clearDropTarget(); + }; + + this.el.addEventListener("dragstart", this.handleDragStart); + this.el.addEventListener("dragover", this.handleDragOver); + this.el.addEventListener("drop", this.handleDrop); + this.el.addEventListener("dragleave", this.handleDragLeave); + this.el.addEventListener("dragend", this.handleDragEnd); + }, + + destroyed() { + this.el.removeEventListener("dragstart", this.handleDragStart); + this.el.removeEventListener("dragover", this.handleDragOver); + this.el.removeEventListener("drop", this.handleDrop); + this.el.removeEventListener("dragleave", this.handleDragLeave); + this.el.removeEventListener("dragend", this.handleDragEnd); + } +}; diff --git a/assets/js/hooks/monaco_diff_editor.js b/assets/js/hooks/monaco_diff_editor.js new file mode 100644 index 0000000..65cad7c --- /dev/null +++ b/assets/js/hooks/monaco_diff_editor.js @@ -0,0 +1,129 @@ +import { loadMonaco, ensureMonacoTheme, diffModelPath } from "../monaco/services.js"; + +export const MonacoDiffEditor = { + mounted() { + this.host = this.el.querySelector(".monaco-diff-editor-instance"); + this.originalInput = this.el.querySelector(".monaco-diff-original"); + this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); + this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; + this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; + this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; + this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; + this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; + + this.readValues = () => ({ + original: this.originalInput?.value || "", + modified: this.modifiedInput?.value || "" + }); + + this.applyDataset = () => { + this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; + this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; + this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; + this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; + this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; + }; + + this.setModels = (monaco) => { + const values = this.readValues(); + + this.originalModel?.dispose(); + this.modifiedModel?.dispose(); + + this.originalModel = monaco.editor.createModel( + values.original, + this.language, + monaco.Uri.parse(diffModelPath(this.filePath, "original")) + ); + + this.modifiedModel = monaco.editor.createModel( + values.modified, + this.language, + monaco.Uri.parse(diffModelPath(this.filePath, "modified")) + ); + + this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel }); + this.lastFilePath = this.filePath; + }; + + loadMonaco() + .then((monaco) => { + if (!this.host) { + return; + } + + ensureMonacoTheme(monaco); + + this.editor = monaco.editor.createDiffEditor(this.host, { + theme: "bds-theme", + automaticLayout: true, + readOnly: true, + renderSideBySide: this.viewStyle === "side-by-side", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbers: "on", + diffCodeLens: false, + originalEditable: false, + wordWrap: this.wordWrap, + hideUnchangedRegions: { enabled: this.hideUnchanged }, + ignoreTrimWhitespace: false + }); + + this.setModels(monaco); + }) + .catch((error) => { + console.error("Failed to load Monaco diff editor", error); + }); + }, + + updated() { + this.host = this.el.querySelector(".monaco-diff-editor-instance"); + this.originalInput = this.el.querySelector(".monaco-diff-original"); + this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); + this.applyDataset(); + + if (!this.editor) { + return; + } + + loadMonaco().then((monaco) => { + ensureMonacoTheme(monaco); + monaco.editor.setTheme("bds-theme"); + + this.editor.updateOptions({ + renderSideBySide: this.viewStyle === "side-by-side", + wordWrap: this.wordWrap, + hideUnchangedRegions: { enabled: this.hideUnchanged } + }); + + if (this.lastFilePath !== this.filePath) { + this.setModels(monaco); + return; + } + + const values = this.readValues(); + + if (this.originalModel && this.originalModel.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.originalModel, this.language); + } + + if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.modifiedModel, this.language); + } + + if (this.originalModel && this.originalModel.getValue() !== values.original) { + this.originalModel.setValue(values.original); + } + + if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) { + this.modifiedModel.setValue(values.modified); + } + }); + }, + + destroyed() { + this.originalModel?.dispose(); + this.modifiedModel?.dispose(); + this.editor?.dispose(); + } +}; diff --git a/assets/js/hooks/monaco_editor.js b/assets/js/hooks/monaco_editor.js new file mode 100644 index 0000000..20f5a61 --- /dev/null +++ b/assets/js/hooks/monaco_editor.js @@ -0,0 +1,238 @@ +import { + loadMonaco, + ensureMonacoTheme, + registerMonacoEditor, + unregisterMonacoEditor +} from "../monaco/services.js"; + +export const MonacoEditor = { + mounted() { + this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); + this.host = this.el.querySelector(".monaco-editor-instance"); + this.language = this.el.dataset.monacoLanguage || "plaintext"; + this.wordWrap = this.el.dataset.monacoWordWrap || "off"; + this.editorId = this.el.dataset.monacoEditorId || ""; + this.insertEvent = this.el.dataset.monacoInsertEvent || ""; + this.syncTimer = null; + this.isApplyingRemoteUpdate = false; + this.lastKnownValue = this.textarea?.value || ""; + + this.syncEditorFromTextarea = () => { + this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); + + if (!this.textarea || !this.editor) { + return; + } + + const value = this.textarea.value || ""; + + if (this.editor.getValue() !== value) { + this.isApplyingRemoteUpdate = true; + this.editor.setValue(value); + this.isApplyingRemoteUpdate = false; + } + + this.lastKnownValue = value; + }; + + this.layoutEditorSoon = () => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (!this.editor) { + return; + } + + this.editor.layout(); + }); + }); + }; + + this.waitForMonacoVisibleSize = () => + new Promise((resolve) => { + let settled = false; + let attempts = 0; + + const hasVisibleSize = () => { + const rect = this.host?.getBoundingClientRect(); + return Boolean(rect && rect.width > 0 && rect.height > 0); + }; + + const finish = () => { + if (settled) { + return; + } + + settled = true; + this.visibleSizeObserver?.disconnect(); + this.visibleSizeObserver = null; + resolve(); + }; + + const check = () => { + if (hasVisibleSize() || attempts >= 20) { + finish(); + return; + } + + attempts += 1; + window.requestAnimationFrame(check); + }; + + if (hasVisibleSize()) { + finish(); + return; + } + + if (window.ResizeObserver && this.host) { + this.visibleSizeObserver = new ResizeObserver(() => { + if (hasVisibleSize()) { + finish(); + } + }); + this.visibleSizeObserver.observe(this.host); + } + + window.requestAnimationFrame(check); + }); + + this.queueSync = () => { + if (!this.textarea || !this.editor) { + return; + } + + window.clearTimeout(this.syncTimer); + this.syncTimer = window.setTimeout(() => { + if (!this.textarea || !this.editor) { + return; + } + + const value = this.editor.getValue(); + + if (this.textarea.value === value) { + return; + } + + this.lastKnownValue = value; + this.textarea.value = value; + this.textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, 120); + }; + + this.handleInsert = ({ id, content }) => { + if (!this.editor || !content || String(id) !== String(this.editorId)) { + return; + } + + const model = this.editor.getModel(); + const selection = this.editor.getSelection(); + + if (!model || !selection) { + return; + } + + const value = this.editor.getValue(); + const start = model.getOffsetAt(selection.getStartPosition()); + const end = model.getOffsetAt(selection.getEndPosition()); + const before = value.slice(0, start); + const after = value.slice(end); + const separator = before !== "" && !before.endsWith("\n") ? "\n" : ""; + const suffix = after !== "" && !content.endsWith("\n") ? "\n" : ""; + const inserted = `${separator}${content}${suffix}`; + this.editor.executeEdits("bds-insert-content", [ + { + range: selection, + text: inserted, + forceMoveMarkers: true + } + ]); + this.editor.focus(); + }; + + loadMonaco() + .then(async (monaco) => { + if (!this.host || !this.textarea) { + return; + } + + await this.waitForMonacoVisibleSize(); + + ensureMonacoTheme(monaco); + + this.editor = monaco.editor.create(this.host, { + value: this.textarea.value || "", + language: this.language, + theme: "bds-theme", + automaticLayout: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: this.wordWrap, + lineNumbers: "on", + lineNumbersMinChars: 3, + fontSize: 14, + fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", + padding: { top: 12, bottom: 12 }, + roundedSelection: false, + renderLineHighlight: "line", + formatOnPaste: true, + cursorStyle: "line", + cursorBlinking: "smooth", + quickSuggestions: this.language === "markdown-with-macros" ? false : true, + tabSize: 2, + insertSpaces: true + }); + + registerMonacoEditor(this.editorId || this.el.id, this.editor); + monaco.editor.setTheme("bds-theme"); + this.syncEditorFromTextarea(); + this.layoutEditorSoon(); + + this.changeSubscription = this.editor.onDidChangeModelContent(() => { + if (this.isApplyingRemoteUpdate) { + return; + } + + this.queueSync(); + }); + + if (this.insertEvent) { + this.handleEvent(this.insertEvent, this.handleInsert); + } + }) + .catch((error) => { + console.error("Failed to load Monaco editor", error); + }); + }, + + updated() { + this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); + this.host = this.el.querySelector(".monaco-editor-instance"); + this.language = this.el.dataset.monacoLanguage || this.language || "plaintext"; + this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off"; + + if (!this.editor || !this.textarea) { + return; + } + + loadMonaco().then((monaco) => { + ensureMonacoTheme(monaco); + monaco.editor.setTheme("bds-theme"); + + if (this.editor.getModel()?.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.editor.getModel(), this.language); + } + + this.editor.updateOptions({ wordWrap: this.wordWrap }); + }); + + this.syncEditorFromTextarea(); + this.layoutEditorSoon(); + }, + + destroyed() { + window.clearTimeout(this.syncTimer); + this.visibleSizeObserver?.disconnect(); + this.changeSubscription?.dispose(); + unregisterMonacoEditor(this.editorId || this.el.id); + this.editor?.dispose(); + } +}; diff --git a/assets/js/hooks/section_scroll.js b/assets/js/hooks/section_scroll.js new file mode 100644 index 0000000..e6f6ed8 --- /dev/null +++ b/assets/js/hooks/section_scroll.js @@ -0,0 +1,31 @@ +const makeSectionScrollHook = (datasetKey) => ({ + mounted() { + this.lastTargetId = null; + this.scrollToSelectedSection(); + }, + + updated() { + this.scrollToSelectedSection(); + }, + + scrollToSelectedSection() { + const targetId = this.el.dataset[datasetKey]; + + if (!targetId || targetId === this.lastTargetId) { + return; + } + + this.lastTargetId = targetId; + + window.requestAnimationFrame(() => { + const target = document.getElementById(targetId); + + if (target && this.el.contains(target)) { + target.scrollIntoView({ block: "start", behavior: "smooth" }); + } + }); + } +}); + +export const SettingsSectionScroll = makeSectionScrollHook("settingsScrollTarget"); +export const TagsSectionScroll = makeSectionScrollHook("tagsScrollTarget"); diff --git a/assets/js/hooks/sidebar_interactions.js b/assets/js/hooks/sidebar_interactions.js new file mode 100644 index 0000000..9582468 --- /dev/null +++ b/assets/js/hooks/sidebar_interactions.js @@ -0,0 +1,24 @@ +export const SidebarInteractions = { + mounted() { + this.handleDblClick = (event) => { + const button = event.target.closest("[data-testid='sidebar-open-item']"); + + if (!button || !this.el.contains(button)) { + return; + } + + this.pushEvent("pin_sidebar_item", { + route: button.dataset.route, + id: button.dataset.itemId, + title: button.dataset.openTitle || "", + subtitle: button.dataset.openSubtitle || "" + }); + }; + + this.el.addEventListener("dblclick", this.handleDblClick); + }, + + destroyed() { + this.el.removeEventListener("dblclick", this.handleDblClick); + } +}; diff --git a/assets/js/monaco/languages.js b/assets/js/monaco/languages.js new file mode 100644 index 0000000..ac1860b --- /dev/null +++ b/assets/js/monaco/languages.js @@ -0,0 +1,145 @@ +let liquidLanguageRegistered = false; +let markdownWithMacrosRegistered = false; + +export const registerLiquidLanguage = (monaco) => { + if (liquidLanguageRegistered) { + return; + } + + monaco.languages.register({ id: "liquid" }); + monaco.languages.setLanguageConfiguration("liquid", { + comments: { + blockComment: ["{% comment %}", "{% endcomment %}"] + }, + brackets: [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ], + surroundingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ] + }); + + monaco.languages.setMonarchTokensProvider("liquid", { + defaultToken: "", + tokenizer: { + root: [ + [/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }], + [/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }], + [/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }], + [/<=!]=?|\.|:/, "operator"], + [/[a-zA-Z_][\w.-]*/, "identifier"], + [/[,:()[\]]/, "delimiter"] + ], + liquidComment: [ + [/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }], + [/./, "comment.block"] + ], + htmlComment: [ + [/-->/, { token: "comment", next: "@pop" }], + [/./, "comment"] + ], + htmlTag: [ + [/\/>/, { token: "delimiter.html", next: "@pop" }], + [/>/, { token: "delimiter.html", next: "@pop" }], + [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], + [/[\w:-]+/, "attribute.name"], + [/=/, "delimiter"] + ], + scriptTag: [ + [/>/, { token: "delimiter.html", next: "@pop" }], + [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], + [/[\w:-]+/, "attribute.name"], + [/=/, "delimiter"] + ], + styleTag: [ + [/>/, { token: "delimiter.html", next: "@pop" }], + [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], + [/[\w:-]+/, "attribute.name"], + [/=/, "delimiter"] + ] + } + }); + + liquidLanguageRegistered = true; +}; + +export const registerMarkdownWithMacrosLanguage = (monaco) => { + if (markdownWithMacrosRegistered) { + return; + } + + monaco.languages.register({ id: "markdown-with-macros" }); + monaco.languages.setMonarchTokensProvider("markdown-with-macros", { + defaultToken: "", + tokenPostfix: ".md", + tokenizer: { + root: [ + [/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }], + [/^#{1,6}\s.*$/, "keyword.header"], + [/^\s*>+/, "string.quote"], + [/^\s*[-+*]\s/, "keyword"], + [/^\s*\d+\.\s/, "keyword"], + [/^\s*```\w*/, { token: "string.code", next: "@codeblock" }], + [/\*\*[^*]+\*\*/, "strong"], + [/\*[^*]+\*/, "emphasis"], + [/__[^_]+__/, "strong"], + [/_[^_]+_/, "emphasis"], + [/`[^`]+`/, "variable"], + [/!?\[[^\]]*\]\([^)]*\)/, "string.link"], + [/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"] + ], + macroParams: [ + [/\]\]/, { token: "keyword.macro", next: "@root" }], + [/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"], + [/=/, "delimiter"], + [/"[^"]*"/, "string"], + [/\s+/, "white"], + [/[^\]"=\s]+/, "attribute.value"] + ], + codeblock: [ + [/^\s*```\s*$/, { token: "string.code", next: "@root" }], + [/.*$/, "variable.source"] + ] + } + }); + + markdownWithMacrosRegistered = true; +}; diff --git a/assets/js/monaco/services.js b/assets/js/monaco/services.js index e7bdbfa..efdffd9 100644 --- a/assets/js/monaco/services.js +++ b/assets/js/monaco/services.js @@ -1 +1,88 @@ -export const createMonacoServices = (services) => services; +import { loadScript } from "../utils/script_loader.js"; +import { ensureMonacoTheme } from "./theme.js"; +import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js"; + +let monacoLoaderPromise; +const monacoEditors = new Map(); + +export const loadMonaco = () => { + if (window.monaco?.editor) { + ensureMonacoTheme(window.monaco); + registerLiquidLanguage(window.monaco); + registerMarkdownWithMacrosLanguage(window.monaco); + return Promise.resolve(window.monaco); + } + + if (monacoLoaderPromise) { + return monacoLoaderPromise; + } + + monacoLoaderPromise = loadScript("/monaco/vs/loader.js") + .then( + () => + new Promise((resolve, reject) => { + window.require.config({ paths: { vs: "/monaco/vs" } }); + window.require(["vs/editor/editor.main"], () => { + ensureMonacoTheme(window.monaco); + registerLiquidLanguage(window.monaco); + registerMarkdownWithMacrosLanguage(window.monaco); + resolve(window.monaco); + }, reject); + }) + ) + .catch((error) => { + monacoLoaderPromise = null; + throw error; + }); + + return monacoLoaderPromise; +}; + +export const registerMonacoEditor = (key, editor) => { + if (key) { + monacoEditors.set(key, editor); + } +}; + +export const unregisterMonacoEditor = (key) => { + if (key) { + monacoEditors.delete(key); + } +}; + +export const activeMonacoEditor = () => { + for (const editor of monacoEditors.values()) { + if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) { + return editor; + } + } + + return null; +}; + +export const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => { + if (!editor) { + return false; + } + + const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null; + + if (action && typeof action.run === "function") { + action.run(); + return true; + } + + if (typeof editor.trigger === "function") { + editor.trigger("bds-menu", triggerId, null); + return true; + } + + return false; +}; + +export const diffModelPath = (filePath, side) => { + const normalized = String(filePath || "working-tree").replace(/^\/+/, ""); + return `inmemory://model/git-diff/${side}/${normalized}`; +}; + +export { ensureMonacoTheme }; diff --git a/assets/js/monaco/theme.js b/assets/js/monaco/theme.js new file mode 100644 index 0000000..21fb213 --- /dev/null +++ b/assets/js/monaco/theme.js @@ -0,0 +1,62 @@ +import { cssVar, normalizeMonacoColor } from "../utils/color.js"; + +let monacoThemeSignature = null; + +export const ensureMonacoTheme = (monaco) => { + const background = normalizeMonacoColor( + cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")), + "#1e1e1e" + ); + const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4"); + const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585"); + const activeLineNumber = normalizeMonacoColor( + cssVar("--vscode-editorLineNumber-activeForeground", foreground), + foreground + ); + const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78"); + const inactiveSelection = normalizeMonacoColor( + cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"), + "#3a3d41" + ); + const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground); + const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c"); + const lineHighlight = normalizeMonacoColor( + cssVar("--vscode-editor-lineHighlightBackground", background), + background + ); + const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|"); + + if (signature === monacoThemeSignature) { + monaco.editor.setTheme("bds-theme"); + return; + } + + monaco.editor.defineTheme("bds-theme", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" }, + { token: "attribute.name", foreground: "9CDCFE" }, + { token: "attribute.value", foreground: "CE9178" } + ], + colors: { + "editor.background": background, + "editor.foreground": foreground, + "editor.lineHighlightBackground": lineHighlight, + "editorCursor.foreground": cursor, + "editor.selectionBackground": selection, + "editor.inactiveSelectionBackground": inactiveSelection, + "editorLineNumber.foreground": lineNumber, + "editorLineNumber.activeForeground": activeLineNumber, + "editorIndentGuide.background1": border, + "editorIndentGuide.activeBackground1": foreground, + "editorWidget.border": border, + "editorGutter.background": background, + "focusBorder": border, + "input.border": border + } + }); + + monacoThemeSignature = signature; + monaco.editor.setTheme("bds-theme"); +}; diff --git a/assets/js/utils/color.js b/assets/js/utils/color.js new file mode 100644 index 0000000..421f237 --- /dev/null +++ b/assets/js/utils/color.js @@ -0,0 +1,46 @@ +import { clamp } from "./dom.js"; + +export const cssVar = (name, fallback) => { + const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +}; + +const parseRgbColor = (value) => { + if (!value) { + return null; + } + + const hex = value.match(/^#([0-9a-f]{6})$/i); + + if (hex) { + return { + r: Number.parseInt(hex[1].slice(0, 2), 16), + g: Number.parseInt(hex[1].slice(2, 4), 16), + b: Number.parseInt(hex[1].slice(4, 6), 16) + }; + } + + const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + + if (!rgb) { + return null; + } + + return { + r: Number.parseInt(rgb[1], 10), + g: Number.parseInt(rgb[2], 10), + b: Number.parseInt(rgb[3], 10) + }; +}; + +export const normalizeMonacoColor = (value, fallback) => { + const rgb = parseRgbColor(value); + + if (!rgb) { + return fallback; + } + + return `#${[rgb.r, rgb.g, rgb.b] + .map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0")) + .join("")}`; +}; diff --git a/assets/js/utils/dom.js b/assets/js/utils/dom.js new file mode 100644 index 0000000..86ee9a2 --- /dev/null +++ b/assets/js/utils/dom.js @@ -0,0 +1,34 @@ +export const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); + +export const parseJsonObject = (value) => { + if (!value) { + return null; + } + + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch (_error) { + return null; + } +}; + +export const setMediaThumbnailLoaded = (image, loaded) => { + const thumbnail = image?.closest(".media-thumbnail"); + + if (!thumbnail) { + return; + } + + if (loaded) { + thumbnail.classList.add("is-loaded"); + } else { + thumbnail.classList.remove("is-loaded"); + } +}; + +export const syncMediaThumbnailState = (root) => { + root.querySelectorAll(".media-thumbnail-image").forEach((image) => { + setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0)); + }); +}; diff --git a/assets/js/utils/layout.js b/assets/js/utils/layout.js new file mode 100644 index 0000000..b65f11f --- /dev/null +++ b/assets/js/utils/layout.js @@ -0,0 +1,43 @@ +import { clamp } from "./dom.js"; +import { SIDEBAR_STORAGE_KEY, ASSISTANT_STORAGE_KEY } from "../constants.js"; + +export const shellWidth = (selector) => { + const shell = document.querySelector(selector); + + if (!shell) { + return 0; + } + + const width = Number.parseInt(shell.style.width || "0", 10); + return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width; +}; + +export const setShellWidth = (selector, width) => { + const shell = document.querySelector(selector); + + if (shell) { + shell.style.width = `${width}px`; + shell.classList.remove("is-hidden"); + } +}; + +export const persistWidth = (target, width) => { + const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY; + window.localStorage.setItem(key, String(width)); +}; + +export const readStoredSize = (key, fallback, min, max) => { + const raw = window.localStorage.getItem(key); + + if (!raw) { + return fallback; + } + + const parsed = Number.parseInt(raw, 10); + + if (Number.isNaN(parsed)) { + return fallback; + } + + return clamp(parsed, min, max); +}; diff --git a/assets/js/utils/script_loader.js b/assets/js/utils/script_loader.js new file mode 100644 index 0000000..b49da55 --- /dev/null +++ b/assets/js/utils/script_loader.js @@ -0,0 +1,33 @@ +export const loadScript = (src) => + new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${src}"]`); + + if (existing) { + if (existing.dataset.loaded === "true") { + resolve(); + return; + } + + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { + once: true + }); + return; + } + + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.addEventListener( + "load", + () => { + script.dataset.loaded = "true"; + resolve(); + }, + { once: true } + ); + script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { + once: true + }); + document.head.appendChild(script); + }); diff --git a/assets/js/utils/shortcuts.js b/assets/js/utils/shortcuts.js new file mode 100644 index 0000000..156d441 --- /dev/null +++ b/assets/js/utils/shortcuts.js @@ -0,0 +1,30 @@ +export const normalizeShortcutKey = (key) => String(key || "").toLowerCase(); + +export const shortcutTargetIsEditable = (event) => { + const tag = event.target?.tagName || null; + return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag); +}; + +export const shortcutMatchesEvent = (shortcut, event) => { + const primary = event.metaKey || event.ctrlKey; + + return ( + normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) && + primary === Boolean(shortcut.primary) && + event.shiftKey === Boolean(shortcut.shift) && + event.altKey === Boolean(shortcut.alt) + ); +}; + +export const parseShortcutConfig = (value) => { + if (!value) { + return []; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return []; + } +}; diff --git a/priv/static/assets/app.css b/priv/static/assets/app.css index d31f3cd..4f0e889 100644 --- a/priv/static/assets/app.css +++ b/priv/static/assets/app.css @@ -7,6 +7,13 @@ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --spacing: 0.25rem; + --container-xs: 20rem; + --container-2xl: 42rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --tracking-wide: 0.025em; --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); } @@ -163,36 +170,114 @@ .visible { visibility: visible; } + .absolute { + position: absolute; + } + .relative { + position: relative; + } + .top-full { + top: 100%; + } + .right-0 { + right: calc(var(--spacing) * 0); + } + .z-10 { + z-index: 10; + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } .block { display: block; } .flex { display: flex; } + .grid { + display: grid; + } .hidden { display: none; } .inline { display: inline; } + .inline-flex { + display: inline-flex; + } .table { display: table; } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-8 { + height: calc(var(--spacing) * 8); + } + .h-9 { + height: calc(var(--spacing) * 9); + } + .h-12 { + height: calc(var(--spacing) * 12); + } + .h-\[22px\] { + height: 22px; + } .h-\[35px\] { height: 35px; } .h-full { height: 100%; } + .max-h-\[80vh\] { + max-height: 80vh; + } .min-h-0 { min-height: calc(var(--spacing) * 0); } + .min-h-\[8rem\] { + min-height: 8rem; + } + .min-h-\[16rem\] { + min-height: 16rem; + } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-8 { + width: calc(var(--spacing) * 8); + } + .w-12 { + width: calc(var(--spacing) * 12); + } .w-full { width: 100%; } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-\[240px\] { + max-width: 240px; + } + .max-w-full { + max-width: 100%; + } + .max-w-xs { + max-width: var(--container-xs); + } .min-w-0 { min-width: calc(var(--spacing) * 0); } + .min-w-9 { + min-width: calc(var(--spacing) * 9); + } + .min-w-56 { + min-width: calc(var(--spacing) * 56); + } + .min-w-72 { + min-width: calc(var(--spacing) * 72); + } .flex-1 { flex: 1; } @@ -205,15 +290,68 @@ .resize { resize: both; } + .resize-y { + resize: vertical; + } .flex-col { flex-direction: column; } + .flex-wrap { + flex-wrap: wrap; + } .items-center { align-items: center; } + .items-end { + align-items: flex-end; + } + .items-start { + align-items: flex-start; + } + .items-stretch { + align-items: stretch; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .overflow-auto { + overflow: auto; + } .overflow-hidden { overflow: hidden; } + .overflow-x-auto { + overflow-x: auto; + } + .overflow-y-auto { + overflow-y: auto; + } .rounded { border-radius: 0.25rem; } @@ -221,6 +359,48 @@ border-style: var(--tw-border-style); border-width: 1px; } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .pt-2 { + padding-top: calc(var(--spacing) * 2); + } + .pr-2 { + padding-right: calc(var(--spacing) * 2); + } + .text-left { + text-align: left; + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .uppercase { + text-transform: uppercase; + } .outline { outline-style: var(--tw-outline-style); outline-width: 1px; @@ -229,6 +409,31 @@ --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:grid-cols-\[minmax\(0\,1fr\)_auto\] { + @media (width >= 48rem) { + grid-template-columns: minmax(0,1fr) auto; + } + } + .md\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)_auto\] { + @media (width >= 48rem) { + grid-template-columns: minmax(0,1fr) minmax(0,1fr) auto; + } + } + .xl\:grid-cols-\[minmax\(0\,2fr\)_minmax\(280px\,1fr\)\] { + @media (width >= 80rem) { + grid-template-columns: minmax(0,2fr) minmax(280px,1fr); + } + } + .xl\:grid-cols-\[minmax\(320px\,1fr\)_minmax\(0\,1\.2fr\)\] { + @media (width >= 80rem) { + grid-template-columns: minmax(320px,1fr) minmax(0,1.2fr); + } + } } :root { --accent-color: #007acc; @@ -2310,43 +2515,6 @@ button svg, button svg * { background-color: var(--vscode-editor-background); overflow: hidden; } -.post-editor .editor-header, .scripts-view-shell.editor .editor-header, .templates-view-shell.editor .editor-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 0 12px; - min-height: 35px; - background-color: var(--vscode-tab-activeBackground); - border-bottom: 1px solid var(--vscode-panel-border); -} -.post-editor .editor-tabs, .scripts-view-shell.editor .editor-tabs, .templates-view-shell.editor .editor-tabs { - display: flex; - align-items: center; - gap: 2px; - min-width: 0; -} -.post-editor .editor-tab, .scripts-view-shell.editor .editor-tab, .templates-view-shell.editor .editor-tab { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - padding: 6px 12px; - background-color: var(--vscode-tab-inactiveBackground); - color: var(--vscode-tab-inactiveForeground); - font-size: 13px; - border-radius: 4px 4px 0 0; -} -.post-editor .editor-tab.active, .scripts-view-shell.editor .editor-tab.active, .templates-view-shell.editor .editor-tab.active { - background-color: var(--vscode-tab-activeBackground); - color: var(--vscode-tab-activeForeground); -} -.post-editor .editor-tab-title, .scripts-view-shell.editor .editor-tab-title, .templates-view-shell.editor .editor-tab-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} .post-editor .editor-tab-dirty { color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground)); font-size: 10px; @@ -2356,11 +2524,6 @@ button svg, button svg * { font-size: 11px; white-space: nowrap; } -.post-editor .editor-actions, .scripts-view-shell.editor .editor-actions, .templates-view-shell.editor .editor-actions { - display: flex; - align-items: center; - gap: 8px; -} .post-editor .quick-actions-wrapper { position: relative; display: inline-block; @@ -2375,53 +2538,15 @@ button svg, button svg * { font-size: 12px; line-height: 1; } -.post-editor .quick-actions-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 4px; - min-width: 280px; - background: var(--vscode-dropdown-background, #3c3c3c); - border: 1px solid var(--vscode-dropdown-border, #454545); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - z-index: 1000; - overflow: hidden; -} .post-editor .quick-actions-divider { height: 1px; background: var(--vscode-dropdown-border, #454545); } -.post-editor .quick-action-item { - display: flex; - align-items: flex-start; - gap: 10px; - width: 100%; - padding: 10px 12px; - background: none; - border: none; - color: var(--vscode-dropdown-foreground, #ccc); - cursor: pointer; - text-align: left; - transition: background 0.1s; -} -.post-editor .quick-action-item:hover:not(:disabled) { - background: var(--vscode-list-hoverBackground, #2a2d2e); -} -.post-editor .quick-action-item:disabled { - opacity: 0.5; - cursor: not-allowed; -} .post-editor .quick-action-icon { font-size: 16px; flex-shrink: 0; margin-top: 2px; } -.post-editor .quick-action-text { - display: flex; - flex-direction: column; - gap: 2px; -} .post-editor .quick-action-text strong { font-size: 13px; font-weight: 500; @@ -2454,19 +2579,6 @@ button svg, button svg * { color: var(--vscode-descriptionForeground); font-style: italic; } -.post-editor .editor-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 16px; - padding: 16px; - overflow-y: auto; -} -.post-editor .metadata-toggle-header { - display: flex; - align-items: center; - gap: 8px; -} .post-editor .metadata-toggle { display: flex; align-items: center; @@ -2489,32 +2601,13 @@ button svg, button svg * { .post-editor .metadata-toggle-chevron { font-size: 10px; } -.post-editor .editor-header-row { - display: flex; - gap: 16px; - align-items: flex-start; -} .post-editor .editor-header-row.is-collapsed { display: none; } -.post-editor .editor-meta { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; - min-width: 0; -} .post-editor .editor-media-panel { width: 200px; flex-shrink: 0; } -.post-editor .editor-field { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-width: 200px; -} .post-editor .editor-field label, .post-editor .editor-body label, .post-editor .post-editor-links-label { font-size: 11px; font-weight: 500; @@ -2530,23 +2623,10 @@ button svg, button svg * { letter-spacing: 0; color: var(--vscode-foreground); } -.post-editor .post-editor-input, .post-editor .post-editor-textarea { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); - border-radius: 4px; - background: var(--vscode-input-background, rgba(255, 255, 255, 0.06)); - color: var(--vscode-input-foreground, var(--vscode-foreground)); - font: inherit; -} .post-editor .post-editor-input.is-readonly { opacity: 0.7; cursor: not-allowed; } -.post-editor .post-editor-textarea { - line-height: 1.5; - resize: vertical; -} .post-editor .post-editor-excerpt { min-height: 96px; } @@ -2700,29 +2780,10 @@ button svg, button svg * { font-size: 0.9rem; font-weight: 600; } -.post-editor .editor-field-row { - display: flex; - gap: 12px; - width: 100%; -} -.post-editor .editor-language-row { - display: flex; - gap: 6px; - align-items: center; - flex-wrap: nowrap; -} .post-editor .editor-language-row select { flex: 1; min-width: 0; } -.post-editor .editor-translations-flags { - display: flex; - gap: 4px; - align-items: center; - flex: 1; - min-width: 0; - overflow-x: auto; -} .post-editor .editor-translation-flag { display: inline-flex; align-items: center; @@ -3554,25 +3615,15 @@ button svg, button svg * { background: var(--vscode-editor-background); } .chat-panel { - display: flex; - min-height: 0; - flex-direction: column; color: var(--vscode-editor-foreground); } .chat-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 16px; border-bottom: 1px solid var(--vscode-panel-border); background: var(--vscode-sideBar-background); } .chat-panel-title { flex: 1; min-width: 0; - display: flex; - align-items: center; gap: 10px; overflow: visible; font-size: 14px; @@ -3622,12 +3673,6 @@ button svg, button svg * { min-height: 0; overflow-y: auto; } -.chat-messages { - display: flex; - flex-direction: column; - gap: 16px; - padding: 16px; -} .chat-message { display: flex; max-width: 100%; @@ -3674,9 +3719,6 @@ button svg, button svg * { background: var(--vscode-sideBar-background); } .chat-panel .chat-input-wrapper { - display: flex; - align-items: flex-end; - gap: 8px; min-height: 30px; border: 1px solid var(--vscode-input-border); border-radius: 6px; @@ -3720,6 +3762,33 @@ button svg, button svg * { .chat-panel .chat-send-button:disabled { opacity: 0.5; } +@media (max-width: 720px) { + .chat-panel-header { + align-items: stretch; + flex-direction: column; + padding: 10px 12px; + } + .chat-panel-title { + width: 100%; + flex-wrap: wrap; + } + .chat-model-selector-wrap { + width: 100%; + } + .chat-panel .chat-model-selector-button.chat-model-selector-inline { + justify-content: space-between; + width: 100%; + } + .chat-messages { + padding: 12px; + } + .chat-message-content { + max-width: 100%; + } + .chat-panel .chat-input-container { + padding: 8px 12px; + } +} .overlay-root { position: fixed; inset: 0; @@ -3966,23 +4035,6 @@ button svg, button svg * { padding: 14px 20px; font: inherit; } -.menu-editor-view { - padding: 1rem; - height: 100%; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - gap: 0.75rem; - overflow: hidden; - background: var(--vscode-editor-background); -} -.menu-editor-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} .menu-editor-header h2 { margin: 0; } @@ -3990,17 +4042,7 @@ button svg, button svg * { margin: 0.25rem 0 0; color: var(--vscode-descriptionForeground); } -.menu-editor-main { - display: flex; - flex-direction: column; - min-height: 0; - flex: 1; - overflow: hidden; -} .menu-editor-tree-wrap { - display: flex; - flex-direction: column; - flex: 1; border: 1px solid var(--vscode-panel-border); border-radius: 6px; background: var(--vscode-editor-background); @@ -4008,9 +4050,6 @@ button svg, button svg * { min-height: 0; } .menu-editor-toolbar { - display: flex; - align-items: center; - gap: 0.2rem; margin-bottom: 0.5rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--vscode-panel-border); @@ -4212,59 +4251,10 @@ button svg, button svg * { flex-wrap: wrap; } } -[data-testid="media-editor"] { - flex: 1; - display: flex; - flex-direction: column; - background-color: var(--vscode-editor-background); - overflow: hidden; -} -[data-testid="media-editor"] .editor-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 0 12px; - min-height: 35px; - background-color: var(--vscode-tab-activeBackground); - border-bottom: 1px solid var(--vscode-panel-border); -} -[data-testid="media-editor"] .editor-tabs { - display: flex; - align-items: center; - gap: 2px; - min-width: 0; -} -[data-testid="media-editor"] .editor-tab { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - padding: 6px 12px; - background-color: var(--vscode-tab-inactiveBackground); - color: var(--vscode-tab-inactiveForeground); - font-size: 13px; - border-radius: 4px 4px 0 0; -} -[data-testid="media-editor"] .editor-tab.active { - background-color: var(--vscode-tab-activeBackground); - color: var(--vscode-tab-activeForeground); -} -[data-testid="media-editor"] .editor-tab-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} [data-testid="media-editor"] .editor-tab-dirty { color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground)); font-size: 10px; } -[data-testid="media-editor"] .editor-actions { - display: flex; - align-items: center; - gap: 8px; -} [data-testid="media-editor"] .editor-actions button { padding: 4px 10px; font-size: 12px; @@ -4289,55 +4279,15 @@ button svg, button svg * { font-size: 12px; line-height: 1; } -[data-testid="media-editor"] .quick-actions-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; - width: 280px; - background: var(--vscode-dropdown-background, #252526); - border: 1px solid var(--vscode-dropdown-border, #454545); - border-radius: 6px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); - overflow: hidden; - z-index: 30; -} [data-testid="media-editor"] .quick-actions-divider { height: 1px; background: var(--vscode-dropdown-border, #454545); } -[data-testid="media-editor"] .quick-action-item { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - width: 100%; - padding: 10px 12px; - background: none; - border: none; - color: var(--vscode-dropdown-foreground, #ccc); - cursor: pointer; - text-align: left; - transition: background 0.1s; -} -[data-testid="media-editor"] .quick-action-item:hover:not(:disabled) { - background: var(--vscode-list-hoverBackground, #2a2d2e); -} -[data-testid="media-editor"] .quick-action-item:disabled { - opacity: 0.5; - cursor: not-allowed; -} [data-testid="media-editor"] .quick-action-icon { font-size: 16px; flex-shrink: 0; margin-top: 2px; } -[data-testid="media-editor"] .quick-action-text { - display: flex; - flex: 1; - flex-direction: column; - gap: 2px; - min-width: 0; -} [data-testid="media-editor"] .quick-action-text strong { font-size: 13px; font-weight: 500; @@ -4346,26 +4296,11 @@ button svg, button svg * { font-size: 11px; opacity: 0.7; } -[data-testid="media-editor"] .editor-content { - flex: 1; - display: flex; - flex-direction: column; - padding: 16px; - overflow-y: auto; - gap: 16px; -} [data-testid="media-editor"] > .editor-content.media-editor { flex-direction: row; align-items: stretch; gap: 24px; } -[data-testid="media-editor"] .editor-field { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-width: 0; -} [data-testid="media-editor"] .editor-field label { font-size: 11px; font-weight: 500; @@ -4373,29 +4308,10 @@ button svg, button svg * { text-transform: uppercase; letter-spacing: 0.5px; } -[data-testid="media-editor"] .editor-field-row { - display: flex; - gap: 12px; - width: 100%; - margin-bottom: 0; -} -[data-testid="media-editor"] .post-editor-input, [data-testid="media-editor"] .post-editor-textarea { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); - border-radius: 4px; - background: var(--vscode-input-background, rgba(255, 255, 255, 0.06)); - color: var(--vscode-input-foreground, var(--vscode-foreground)); - font: inherit; -} [data-testid="media-editor"] .post-editor-input.disabled, [data-testid="media-editor"] .post-editor-input:disabled { opacity: 0.6; cursor: not-allowed; } -[data-testid="media-editor"] .post-editor-textarea { - line-height: 1.5; - resize: vertical; -} [data-testid="media-editor"] .media-preview { flex: 1; display: flex; @@ -4432,16 +4348,9 @@ button svg, button svg * { } [data-testid="media-editor"] .media-details { width: 320px; - display: flex; - flex-direction: column; gap: 12px; flex-shrink: 0; } -[data-testid="media-editor"] .media-editor-details-form { - display: flex; - flex-direction: column; - gap: 12px; -} [data-testid="media-editor"] .media-details textarea { resize: vertical; } @@ -5163,6 +5072,218 @@ button.import-taxonomy-pill { } } @layer components { + .ui-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 4px; + font: inherit; + line-height: 1.2; + cursor: pointer; + user-select: none; + } + .ui-button:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground, #0e639c); + } + .ui-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .ui-button-primary { + color: var(--vscode-button-foreground, #ffffff); + background: var(--vscode-button-background, var(--vscode-focusBorder)); + } + .ui-button-primary:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground, #0e639c); + } + .ui-button-secondary { + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08)); + border-color: var(--vscode-button-border, transparent); + } + .ui-button-secondary:hover:not(:disabled) { + background: var(--vscode-button-secondaryHoverBackground, #4a4d51); + } + .ui-button-danger { + color: var(--vscode-errorForeground, #f48771); + background: transparent; + border-color: var(--vscode-errorForeground, #f48771); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent); + } + } + .ui-button-danger:hover:not(:disabled) { + background: var(--vscode-errorForeground, #f48771); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent); + } + } + .ui-button-compact { + min-height: 24px; + padding: 3px 8px; + font-size: 12px; + } + .ui-input, .ui-textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 4px; + background: var(--vscode-input-background, rgba(255, 255, 255, 0.06)); + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font: inherit; + } + .ui-textarea { + line-height: 1.5; + resize: vertical; + } + .ui-input:focus, .ui-textarea:focus { + outline: 1px solid var(--vscode-focusBorder, #007fd4); + outline-offset: 1px; + } + .ui-input-readonly, .ui-input[readonly] { + opacity: 0.7; + cursor: not-allowed; + } + .ui-input-disabled, .ui-input:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .ui-tab { + border: none; + color: var(--vscode-tab-inactiveForeground, var(--vscode-foreground)); + background: transparent; + } + .ui-tab:hover { + color: var(--vscode-tab-activeForeground, var(--vscode-foreground)); + } + .ui-tab-active { + color: var(--vscode-tab-activeForeground, var(--vscode-foreground)); + } + .ui-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .ui-panel-entry { + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background-color: var(--vscode-sideBar-background); + } + .ui-empty-state { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--vscode-descriptionForeground); + } + .ui-editor-shell { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--vscode-editor-background); + } + .ui-editor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-height: 35px; + padding: 0 12px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-tab-activeBackground); + } + .ui-editor-tab-current { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 6px; + overflow: hidden; + padding: 6px 12px; + border-radius: 4px 4px 0 0; + background: var(--vscode-tab-activeBackground); + color: var(--vscode-tab-activeForeground); + } + .ui-editor-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + .ui-toolbar { + display: flex; + align-items: center; + gap: 12px; + min-height: 32px; + } + .ui-toolbar-group { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + .ui-field-stack { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + .ui-field-stack > label, .ui-field-label { + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; + } + .ui-field-grid-2, .ui-field-grid-3 { + display: grid; + gap: 16px; + } + .ui-dropdown-menu { + background: var(--vscode-dropdown-background, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + overflow: hidden; + } + .ui-dropdown-item { + display: flex; + align-items: flex-start; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: none; + background: transparent; + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); + cursor: pointer; + text-align: left; + transition: background 0.1s; + } + .ui-dropdown-item:hover:not(:disabled) { + background: var(--vscode-list-hoverBackground, #2a2d2e); + } + .ui-dropdown-item:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .ui-section-card { + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background: var(--vscode-editor-background); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background)); + } + } .btn-base { display: inline-flex; align-items: center; @@ -5209,6 +5330,14 @@ button.import-taxonomy-pill { overflow: hidden; } } +@media (min-width: 768px) { + .ui-field-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .ui-field-grid-3 { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + } +} @property --tw-rotate-x { syntax: "*"; inherits: false; @@ -5234,6 +5363,10 @@ button.import-taxonomy-pill { inherits: false; initial-value: solid; } +@property --tw-tracking { + syntax: "*"; + inherits: false; +} @property --tw-outline-style { syntax: "*"; inherits: false; @@ -5306,6 +5439,7 @@ button.import-taxonomy-pill { --tw-skew-x: initial; --tw-skew-y: initial; --tw-border-style: solid; + --tw-tracking: initial; --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; diff --git a/priv/static/assets/app.js b/priv/static/assets/app.js index e692a50..e0ce339 100644 --- a/priv/static/assets/app.js +++ b/priv/static/assets/app.js @@ -8405,6 +8405,96 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" }, false); })(); + // js/constants.js + var SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; + var ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; + var UI_LANGUAGE_STORAGE_KEY = "bds-ui-language"; + var WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-"; + + // js/utils/dom.js + var clamp = (value, min, max) => Math.max(min, Math.min(value, max)); + var parseJsonObject = (value) => { + if (!value) { + return null; + } + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch (_error) { + return null; + } + }; + var setMediaThumbnailLoaded = (image, loaded) => { + const thumbnail = image?.closest(".media-thumbnail"); + if (!thumbnail) { + return; + } + if (loaded) { + thumbnail.classList.add("is-loaded"); + } else { + thumbnail.classList.remove("is-loaded"); + } + }; + var syncMediaThumbnailState = (root) => { + root.querySelectorAll(".media-thumbnail-image").forEach((image) => { + setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0)); + }); + }; + + // js/utils/layout.js + var shellWidth = (selector) => { + const shell = document.querySelector(selector); + if (!shell) { + return 0; + } + const width = Number.parseInt(shell.style.width || "0", 10); + return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width; + }; + var setShellWidth = (selector, width) => { + const shell = document.querySelector(selector); + if (shell) { + shell.style.width = `${width}px`; + shell.classList.remove("is-hidden"); + } + }; + var persistWidth = (target, width) => { + const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY; + window.localStorage.setItem(key, String(width)); + }; + var readStoredSize = (key, fallback, min, max) => { + const raw = window.localStorage.getItem(key); + if (!raw) { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) { + return fallback; + } + return clamp(parsed, min, max); + }; + + // js/utils/shortcuts.js + var normalizeShortcutKey = (key) => String(key || "").toLowerCase(); + var shortcutTargetIsEditable = (event) => { + const tag = event.target?.tagName || null; + return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag); + }; + var shortcutMatchesEvent = (shortcut, event) => { + const primary = event.metaKey || event.ctrlKey; + return normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) && primary === Boolean(shortcut.primary) && event.shiftKey === Boolean(shortcut.shift) && event.altKey === Boolean(shortcut.alt); + }; + var parseShortcutConfig = (value) => { + if (!value) { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return []; + } + }; + // js/bridges/titlebar_overlay.js var syncTitlebarOverlayInsets = () => { const rootStyle = document.documentElement.style; @@ -8440,1203 +8530,1132 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" }; }; - // js/bridges/menu_runtime.js - var createMenuRuntimeCommandRunner = ({ activeMonacoEditor, runMonacoEditorAction, runDocumentCommand, applyAppZoom }) => { - return (action) => { - const editor = activeMonacoEditor(); - switch (action) { - case "undo": - return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo"); - case "redo": - return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo"); - case "cut": - return editor ? runMonacoEditorAction(editor, "editor.action.clipboardCutAction") : runDocumentCommand("cut"); - case "copy": - return editor ? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction") : runDocumentCommand("copy"); - case "paste": - return editor ? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction") : runDocumentCommand("paste"); - case "delete": - return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete"); - case "select_all": - return editor ? runMonacoEditorAction(editor, "editor.action.selectAll") : runDocumentCommand("selectAll"); - case "find": - return editor ? runMonacoEditorAction(editor, "actions.find") : false; - case "replace": - return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false; - case "reload": - case "force_reload": - window.location.reload(); - return true; - case "reset_zoom": - applyAppZoom(1); - return true; - case "zoom_in": - applyAppZoom((window.__bdsAppZoom || 1) + 0.1); - return true; - case "zoom_out": - applyAppZoom((window.__bdsAppZoom || 1) - 0.1); - return true; - case "toggle_full_screen": - if (document.fullscreenElement) { - document.exitFullscreen?.(); - } else { - document.documentElement.requestFullscreen?.(); - } - return true; - default: - return false; + // js/utils/script_loader.js + var loadScript = (src) => new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${src}"]`); + if (existing) { + if (existing.dataset.loaded === "true") { + resolve(); + return; } + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { + once: true + }); + return; + } + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.addEventListener( + "load", + () => { + script.dataset.loaded = "true"; + resolve(); + }, + { once: true } + ); + script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { + once: true + }); + document.head.appendChild(script); + }); + + // js/utils/color.js + var cssVar = (name, fallback) => { + const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; + }; + var parseRgbColor = (value) => { + if (!value) { + return null; + } + const hex = value.match(/^#([0-9a-f]{6})$/i); + if (hex) { + return { + r: Number.parseInt(hex[1].slice(0, 2), 16), + g: Number.parseInt(hex[1].slice(2, 4), 16), + b: Number.parseInt(hex[1].slice(4, 6), 16) + }; + } + const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); + if (!rgb) { + return null; + } + return { + r: Number.parseInt(rgb[1], 10), + g: Number.parseInt(rgb[2], 10), + b: Number.parseInt(rgb[3], 10) }; }; + var normalizeMonacoColor = (value, fallback) => { + const rgb = parseRgbColor(value); + if (!rgb) { + return fallback; + } + return `#${[rgb.r, rgb.g, rgb.b].map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0")).join("")}`; + }; + + // js/monaco/theme.js + var monacoThemeSignature = null; + var ensureMonacoTheme = (monaco) => { + const background = normalizeMonacoColor( + cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")), + "#1e1e1e" + ); + const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4"); + const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585"); + const activeLineNumber = normalizeMonacoColor( + cssVar("--vscode-editorLineNumber-activeForeground", foreground), + foreground + ); + const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78"); + const inactiveSelection = normalizeMonacoColor( + cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"), + "#3a3d41" + ); + const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground); + const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c"); + const lineHighlight = normalizeMonacoColor( + cssVar("--vscode-editor-lineHighlightBackground", background), + background + ); + const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|"); + if (signature === monacoThemeSignature) { + monaco.editor.setTheme("bds-theme"); + return; + } + monaco.editor.defineTheme("bds-theme", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" }, + { token: "attribute.name", foreground: "9CDCFE" }, + { token: "attribute.value", foreground: "CE9178" } + ], + colors: { + "editor.background": background, + "editor.foreground": foreground, + "editor.lineHighlightBackground": lineHighlight, + "editorCursor.foreground": cursor, + "editor.selectionBackground": selection, + "editor.inactiveSelectionBackground": inactiveSelection, + "editorLineNumber.foreground": lineNumber, + "editorLineNumber.activeForeground": activeLineNumber, + "editorIndentGuide.background1": border, + "editorIndentGuide.activeBackground1": foreground, + "editorWidget.border": border, + "editorGutter.background": background, + "focusBorder": border, + "input.border": border + } + }); + monacoThemeSignature = signature; + monaco.editor.setTheme("bds-theme"); + }; + + // js/monaco/languages.js + var liquidLanguageRegistered = false; + var markdownWithMacrosRegistered = false; + var registerLiquidLanguage = (monaco) => { + if (liquidLanguageRegistered) { + return; + } + monaco.languages.register({ id: "liquid" }); + monaco.languages.setLanguageConfiguration("liquid", { + comments: { + blockComment: ["{% comment %}", "{% endcomment %}"] + }, + brackets: [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ], + surroundingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ] + }); + monaco.languages.setMonarchTokensProvider("liquid", { + defaultToken: "", + tokenizer: { + root: [ + [/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }], + [/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }], + [/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }], + [/<=!]=?|\.|:/, "operator"], + [/[a-zA-Z_][\w.-]*/, "identifier"], + [/[,:()[\]]/, "delimiter"] + ], + liquidComment: [ + [/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }], + [/./, "comment.block"] + ], + htmlComment: [ + [/-->/, { token: "comment", next: "@pop" }], + [/./, "comment"] + ], + htmlTag: [ + [/\/>/, { token: "delimiter.html", next: "@pop" }], + [/>/, { token: "delimiter.html", next: "@pop" }], + [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], + [/[\w:-]+/, "attribute.name"], + [/=/, "delimiter"] + ], + scriptTag: [ + [/>/, { token: "delimiter.html", next: "@pop" }], + [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], + [/[\w:-]+/, "attribute.name"], + [/=/, "delimiter"] + ], + styleTag: [ + [/>/, { token: "delimiter.html", next: "@pop" }], + [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], + [/[\w:-]+/, "attribute.name"], + [/=/, "delimiter"] + ] + } + }); + liquidLanguageRegistered = true; + }; + var registerMarkdownWithMacrosLanguage = (monaco) => { + if (markdownWithMacrosRegistered) { + return; + } + monaco.languages.register({ id: "markdown-with-macros" }); + monaco.languages.setMonarchTokensProvider("markdown-with-macros", { + defaultToken: "", + tokenPostfix: ".md", + tokenizer: { + root: [ + [/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }], + [/^#{1,6}\s.*$/, "keyword.header"], + [/^\s*>+/, "string.quote"], + [/^\s*[-+*]\s/, "keyword"], + [/^\s*\d+\.\s/, "keyword"], + [/^\s*```\w*/, { token: "string.code", next: "@codeblock" }], + [/\*\*[^*]+\*\*/, "strong"], + [/\*[^*]+\*/, "emphasis"], + [/__[^_]+__/, "strong"], + [/_[^_]+_/, "emphasis"], + [/`[^`]+`/, "variable"], + [/!?\[[^\]]*\]\([^)]*\)/, "string.link"], + [/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"] + ], + macroParams: [ + [/\]\]/, { token: "keyword.macro", next: "@root" }], + [/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"], + [/=/, "delimiter"], + [/"[^"]*"/, "string"], + [/\s+/, "white"], + [/[^\]"=\s]+/, "attribute.value"] + ], + codeblock: [ + [/^\s*```\s*$/, { token: "string.code", next: "@root" }], + [/.*$/, "variable.source"] + ] + } + }); + markdownWithMacrosRegistered = true; + }; + + // js/monaco/services.js + var monacoLoaderPromise; + var monacoEditors = /* @__PURE__ */ new Map(); + var loadMonaco = () => { + if (window.monaco?.editor) { + ensureMonacoTheme(window.monaco); + registerLiquidLanguage(window.monaco); + registerMarkdownWithMacrosLanguage(window.monaco); + return Promise.resolve(window.monaco); + } + if (monacoLoaderPromise) { + return monacoLoaderPromise; + } + monacoLoaderPromise = loadScript("/monaco/vs/loader.js").then( + () => new Promise((resolve, reject) => { + window.require.config({ paths: { vs: "/monaco/vs" } }); + window.require(["vs/editor/editor.main"], () => { + ensureMonacoTheme(window.monaco); + registerLiquidLanguage(window.monaco); + registerMarkdownWithMacrosLanguage(window.monaco); + resolve(window.monaco); + }, reject); + }) + ).catch((error) => { + monacoLoaderPromise = null; + throw error; + }); + return monacoLoaderPromise; + }; + var registerMonacoEditor = (key, editor) => { + if (key) { + monacoEditors.set(key, editor); + } + }; + var unregisterMonacoEditor = (key) => { + if (key) { + monacoEditors.delete(key); + } + }; + var activeMonacoEditor = () => { + for (const editor of monacoEditors.values()) { + if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) { + return editor; + } + } + return null; + }; + var runMonacoEditorAction = (editor, actionId, triggerId = actionId) => { + if (!editor) { + return false; + } + const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null; + if (action && typeof action.run === "function") { + action.run(); + return true; + } + if (typeof editor.trigger === "function") { + editor.trigger("bds-menu", triggerId, null); + return true; + } + return false; + }; + var diffModelPath = (filePath, side) => { + const normalized = String(filePath || "working-tree").replace(/^\/+/, ""); + return `inmemory://model/git-diff/${side}/${normalized}`; + }; + + // js/bridges/document_commands.js + var applyAppZoom = (nextZoom) => { + const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2); + window.__bdsAppZoom = zoom; + document.documentElement.style.zoom = String(zoom); + }; + var runDocumentCommand = (command) => { + if (typeof document.execCommand !== "function") { + return false; + } + try { + return document.execCommand(command); + } catch (_error) { + return false; + } + }; + + // js/bridges/menu_runtime.js + var runMenuRuntimeCommand = (action) => { + const editor = activeMonacoEditor(); + switch (action) { + case "undo": + return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo"); + case "redo": + return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo"); + case "cut": + return editor ? runMonacoEditorAction(editor, "editor.action.clipboardCutAction") : runDocumentCommand("cut"); + case "copy": + return editor ? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction") : runDocumentCommand("copy"); + case "paste": + return editor ? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction") : runDocumentCommand("paste"); + case "delete": + return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete"); + case "select_all": + return editor ? runMonacoEditorAction(editor, "editor.action.selectAll") : runDocumentCommand("selectAll"); + case "find": + return editor ? runMonacoEditorAction(editor, "actions.find") : false; + case "replace": + return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false; + case "reload": + case "force_reload": + window.location.reload(); + return true; + case "reset_zoom": + applyAppZoom(1); + return true; + case "zoom_in": + applyAppZoom((window.__bdsAppZoom || 1) + 0.1); + return true; + case "zoom_out": + applyAppZoom((window.__bdsAppZoom || 1) - 0.1); + return true; + case "toggle_full_screen": + if (document.fullscreenElement) { + document.exitFullscreen?.(); + } else { + document.documentElement.requestFullscreen?.(); + } + return true; + default: + return false; + } + }; + + // js/hooks/app_shell.js + var AppShell = { + mounted() { + this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts); + this.currentProjectId = this.el.dataset.projectId || ""; + this.syncStoredLayout(); + this.syncStoredUiLanguage(); + this.destroyOverlaySync = syncTitlebarOverlayInsets(); + this.workbenchStorageKey = (projectId) => projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null; + this.restoreStoredWorkbenchSession = () => { + const projectId = this.el.dataset.projectId || ""; + const storageKey = this.workbenchStorageKey(projectId); + if (!storageKey) { + return false; + } + const session = parseJsonObject(window.localStorage.getItem(storageKey)); + if (!session) { + return false; + } + this.pushEvent("restore_workbench_session", { session }); + return true; + }; + this.persistWorkbenchSession = () => { + const projectId = this.el.dataset.projectId || ""; + const storageKey = this.workbenchStorageKey(projectId); + const session = this.el.dataset.workbenchSession; + if (!storageKey || !session) { + return; + } + window.localStorage.setItem(storageKey, session); + }; + this.handleMouseDown = (event) => { + const handle = event.target.closest("[data-role='resize-handle']"); + if (!handle || !this.el.contains(handle)) { + return; + } + event.preventDefault(); + const target = handle.dataset.resize; + const startX = event.clientX; + const startWidth = target === "assistant" ? shellWidth("[data-testid='assistant-shell']") : shellWidth("[data-testid='sidebar-shell']"); + const min = target === "assistant" ? 280 : 200; + const max = target === "assistant" ? 640 : 500; + const invert = target === "assistant"; + const onMouseMove = (moveEvent) => { + const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX; + const width = clamp(startWidth + delta, min, max); + const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']"; + setShellWidth(selector, width); + persistWidth(target, width); + }; + const onMouseUp = (upEvent) => { + const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX; + const width = clamp(startWidth + delta, min, max); + persistWidth(target, width); + this.pushEvent("resize_panel", { target, width }); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + }; + this.el.addEventListener("mousedown", this.handleMouseDown); + this.handleNativeMenuAction = (event) => { + const action = event.detail?.action; + const ackId = event.detail?.ackId; + if (action) { + this.pushEvent("native_menu_action", { action }, () => { + if (ackId) { + window.dispatchEvent( + new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } }) + ); + } + }); + } + }; + this.handleChange = (event) => { + const select = event.target.closest(".status-bar-language-select"); + if (select && this.el.contains(select)) { + window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value); + } + }; + this.handleShortcutKeyDown = (event) => { + if (shortcutTargetIsEditable(event)) { + return; + } + const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event)); + if (!shortcut) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.pushEvent("shortcut", { + key: normalizeShortcutKey(event.key), + meta: event.metaKey, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + tag: event.target?.tagName || null, + contentEditable: event.target?.isContentEditable || false + }); + }; + this.handleThumbnailLoad = (event) => { + if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { + setMediaThumbnailLoaded(event.target, true); + } + }; + this.handleThumbnailError = (event) => { + if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { + setMediaThumbnailLoaded(event.target, false); + } + }; + this.handleEvent("menu-runtime-command", ({ action }) => { + if (action) { + runMenuRuntimeCommand(String(action)); + } + }); + window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); + window.addEventListener("keydown", this.handleShortcutKeyDown, true); + this.el.addEventListener("load", this.handleThumbnailLoad, true); + this.el.addEventListener("error", this.handleThumbnailError, true); + this.el.addEventListener("change", this.handleChange); + syncMediaThumbnailState(this.el); + this.restoreStoredWorkbenchSession(); + }, + updated() { + const nextProjectId = this.el.dataset.projectId || ""; + if (nextProjectId !== this.currentProjectId) { + this.currentProjectId = nextProjectId; + if (this.restoreStoredWorkbenchSession()) { + return; + } + } + syncMediaThumbnailState(this.el); + this.persistWorkbenchSession(); + }, + destroyed() { + this.el.removeEventListener("mousedown", this.handleMouseDown); + this.el.removeEventListener("load", this.handleThumbnailLoad, true); + this.el.removeEventListener("error", this.handleThumbnailError, true); + this.el.removeEventListener("change", this.handleChange); + window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction); + window.removeEventListener("keydown", this.handleShortcutKeyDown, true); + if (this.destroyOverlaySync) { + this.destroyOverlaySync(); + } + }, + syncStoredLayout() { + this.pushEvent("sync_layout", { + sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500), + assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640) + }); + }, + syncStoredUiLanguage() { + const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY); + if (stored) { + this.pushEvent("sync_ui_language", { language: stored }); + } + } + }; + + // js/hooks/sidebar_interactions.js + var SidebarInteractions = { + mounted() { + this.handleDblClick = (event) => { + const button = event.target.closest("[data-testid='sidebar-open-item']"); + if (!button || !this.el.contains(button)) { + return; + } + this.pushEvent("pin_sidebar_item", { + route: button.dataset.route, + id: button.dataset.itemId, + title: button.dataset.openTitle || "", + subtitle: button.dataset.openSubtitle || "" + }); + }; + this.el.addEventListener("dblclick", this.handleDblClick); + }, + destroyed() { + this.el.removeEventListener("dblclick", this.handleDblClick); + } + }; + + // js/hooks/section_scroll.js + var makeSectionScrollHook = (datasetKey) => ({ + mounted() { + this.lastTargetId = null; + this.scrollToSelectedSection(); + }, + updated() { + this.scrollToSelectedSection(); + }, + scrollToSelectedSection() { + const targetId = this.el.dataset[datasetKey]; + if (!targetId || targetId === this.lastTargetId) { + return; + } + this.lastTargetId = targetId; + window.requestAnimationFrame(() => { + const target = document.getElementById(targetId); + if (target && this.el.contains(target)) { + target.scrollIntoView({ block: "start", behavior: "smooth" }); + } + }); + } + }); + var SettingsSectionScroll = makeSectionScrollHook("settingsScrollTarget"); + var TagsSectionScroll = makeSectionScrollHook("tagsScrollTarget"); + + // js/hooks/chat_surface.js + var ChatSurface = { + mounted() { + this.stickToBottom = true; + this.scrollContainer = null; + this.autoResize = () => { + const textarea = this.el.querySelector(".chat-input"); + if (!textarea) { + return; + } + const styles = getComputedStyle(textarea); + const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20; + const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160; + textarea.rows = 1; + textarea.style.minHeight = `${minHeight}px`; + if (textarea.value.trim() === "") { + textarea.style.height = `${minHeight}px`; + textarea.style.maxHeight = `${minHeight}px`; + textarea.style.overflowY = "hidden"; + return; + } + textarea.style.maxHeight = `${maxHeight}px`; + textarea.style.height = "0px"; + const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight); + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden"; + }; + this.syncScrollContainer = () => { + const nextContainer = this.el.querySelector(".chat-messages"); + if (nextContainer === this.scrollContainer) { + return; + } + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.handleScroll); + } + this.scrollContainer = nextContainer; + if (this.scrollContainer) { + this.scrollContainer.addEventListener("scroll", this.handleScroll); + } + }; + this.scrollToBottom = (force = false) => { + if (!this.scrollContainer) { + return; + } + if (force || this.stickToBottom) { + this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight; + } + }; + this.syncExpandedSurfaces = () => { + this.el.querySelectorAll(".chat-inline-surface[data-expanded='true']").forEach((surface) => { + surface.open = true; + }); + }; + this.surfaceObserver = new MutationObserver(() => { + this.syncExpandedSurfaces(); + }); + this.handleScroll = () => { + if (!this.scrollContainer) { + this.stickToBottom = true; + return; + } + const distanceFromBottom = this.scrollContainer.scrollHeight - this.scrollContainer.scrollTop - this.scrollContainer.clientHeight; + this.stickToBottom = distanceFromBottom < 48; + }; + this.handleInput = (event) => { + if (!event.target.closest(".chat-input")) { + return; + } + this.stickToBottom = true; + this.autoResize(); + }; + this.handleKeyDown = (event) => { + if (!event.target.closest(".chat-input")) { + return; + } + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); + if (sendButton && !sendButton.disabled) { + sendButton.click(); + } + } + }; + this.el.addEventListener("input", this.handleInput); + this.el.addEventListener("keydown", this.handleKeyDown); + this.syncScrollContainer(); + this.syncExpandedSurfaces(); + this.surfaceObserver.observe(this.el, { childList: true, subtree: true }); + this.autoResize(); + window.requestAnimationFrame(() => this.scrollToBottom(true)); + }, + updated() { + this.syncScrollContainer(); + this.syncExpandedSurfaces(); + this.autoResize(); + window.requestAnimationFrame(() => this.scrollToBottom()); + }, + destroyed() { + this.surfaceObserver.disconnect(); + this.el.removeEventListener("input", this.handleInput); + this.el.removeEventListener("keydown", this.handleKeyDown); + if (this.scrollContainer) { + this.scrollContainer.removeEventListener("scroll", this.handleScroll); + } + } + }; + + // js/hooks/menu_editor_tree.js + var MenuEditorTree = { + mounted() { + this.dragItemId = null; + this.dragSourceEl = null; + this.dropTargetEl = null; + this.dropPosition = null; + this.clearDropTarget = () => { + if (this.dropTargetEl) { + this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside"); + } + this.dropTargetEl = null; + this.dropPosition = null; + }; + this.setDropTarget = (row, position) => { + if (this.dropTargetEl === row && this.dropPosition === position) { + return; + } + this.clearDropTarget(); + this.dropTargetEl = row; + this.dropPosition = position; + row.classList.add(`is-drop-${position}`); + }; + this.handleDragStart = (event) => { + const handle = event.target.closest("[data-menu-drag-handle='true']"); + const row = event.target.closest("[data-menu-item-id]"); + if (!handle || !row || !this.el.contains(row)) { + return; + } + this.dragItemId = row.dataset.menuItemId || null; + this.dragSourceEl = row; + row.classList.add("is-dragging"); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", this.dragItemId || ""); + } + }; + this.handleDragOver = (event) => { + const row = event.target.closest("[data-menu-item-id]"); + if (!this.dragItemId || !row || !this.el.contains(row)) { + this.clearDropTarget(); + return; + } + const targetItemId = row.dataset.menuItemId || ""; + if (!targetItemId || targetItemId === this.dragItemId) { + this.clearDropTarget(); + return; + } + event.preventDefault(); + const rect = row.getBoundingClientRect(); + const offsetY = event.clientY - rect.top; + const allowInside = row.dataset.menuCanDropInside === "true"; + const insideBandTop = rect.height * 0.3; + const insideBandBottom = rect.height * 0.7; + const position = allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom ? "inside" : offsetY < rect.height / 2 ? "before" : "after"; + this.setDropTarget(row, position); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + }; + this.handleDrop = (event) => { + const row = event.target.closest("[data-menu-item-id]"); + if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) { + this.clearDropTarget(); + return; + } + event.preventDefault(); + this.pushEvent("menu_editor_drop_item", { + drag_item_id: this.dragItemId, + target_item_id: row.dataset.menuItemId, + position: this.dropPosition + }); + this.clearDropTarget(); + }; + this.handleDragLeave = (event) => { + const related = event.relatedTarget; + if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) { + this.clearDropTarget(); + } + }; + this.handleDragEnd = () => { + if (this.dragSourceEl) { + this.dragSourceEl.classList.remove("is-dragging"); + } + this.dragItemId = null; + this.dragSourceEl = null; + this.clearDropTarget(); + }; + this.el.addEventListener("dragstart", this.handleDragStart); + this.el.addEventListener("dragover", this.handleDragOver); + this.el.addEventListener("drop", this.handleDrop); + this.el.addEventListener("dragleave", this.handleDragLeave); + this.el.addEventListener("dragend", this.handleDragEnd); + }, + destroyed() { + this.el.removeEventListener("dragstart", this.handleDragStart); + this.el.removeEventListener("dragover", this.handleDragOver); + this.el.removeEventListener("drop", this.handleDrop); + this.el.removeEventListener("dragleave", this.handleDragLeave); + this.el.removeEventListener("dragend", this.handleDragEnd); + } + }; + + // js/hooks/monaco_editor.js + var MonacoEditor = { + mounted() { + this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); + this.host = this.el.querySelector(".monaco-editor-instance"); + this.language = this.el.dataset.monacoLanguage || "plaintext"; + this.wordWrap = this.el.dataset.monacoWordWrap || "off"; + this.editorId = this.el.dataset.monacoEditorId || ""; + this.insertEvent = this.el.dataset.monacoInsertEvent || ""; + this.syncTimer = null; + this.isApplyingRemoteUpdate = false; + this.lastKnownValue = this.textarea?.value || ""; + this.syncEditorFromTextarea = () => { + this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); + if (!this.textarea || !this.editor) { + return; + } + const value = this.textarea.value || ""; + if (this.editor.getValue() !== value) { + this.isApplyingRemoteUpdate = true; + this.editor.setValue(value); + this.isApplyingRemoteUpdate = false; + } + this.lastKnownValue = value; + }; + this.layoutEditorSoon = () => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + if (!this.editor) { + return; + } + this.editor.layout(); + }); + }); + }; + this.waitForMonacoVisibleSize = () => new Promise((resolve) => { + let settled = false; + let attempts = 0; + const hasVisibleSize = () => { + const rect = this.host?.getBoundingClientRect(); + return Boolean(rect && rect.width > 0 && rect.height > 0); + }; + const finish = () => { + if (settled) { + return; + } + settled = true; + this.visibleSizeObserver?.disconnect(); + this.visibleSizeObserver = null; + resolve(); + }; + const check = () => { + if (hasVisibleSize() || attempts >= 20) { + finish(); + return; + } + attempts += 1; + window.requestAnimationFrame(check); + }; + if (hasVisibleSize()) { + finish(); + return; + } + if (window.ResizeObserver && this.host) { + this.visibleSizeObserver = new ResizeObserver(() => { + if (hasVisibleSize()) { + finish(); + } + }); + this.visibleSizeObserver.observe(this.host); + } + window.requestAnimationFrame(check); + }); + this.queueSync = () => { + if (!this.textarea || !this.editor) { + return; + } + window.clearTimeout(this.syncTimer); + this.syncTimer = window.setTimeout(() => { + if (!this.textarea || !this.editor) { + return; + } + const value = this.editor.getValue(); + if (this.textarea.value === value) { + return; + } + this.lastKnownValue = value; + this.textarea.value = value; + this.textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, 120); + }; + this.handleInsert = ({ id, content }) => { + if (!this.editor || !content || String(id) !== String(this.editorId)) { + return; + } + const model = this.editor.getModel(); + const selection = this.editor.getSelection(); + if (!model || !selection) { + return; + } + const value = this.editor.getValue(); + const start = model.getOffsetAt(selection.getStartPosition()); + const end = model.getOffsetAt(selection.getEndPosition()); + const before = value.slice(0, start); + const after = value.slice(end); + const separator = before !== "" && !before.endsWith("\n") ? "\n" : ""; + const suffix = after !== "" && !content.endsWith("\n") ? "\n" : ""; + const inserted = `${separator}${content}${suffix}`; + this.editor.executeEdits("bds-insert-content", [ + { + range: selection, + text: inserted, + forceMoveMarkers: true + } + ]); + this.editor.focus(); + }; + loadMonaco().then(async (monaco) => { + if (!this.host || !this.textarea) { + return; + } + await this.waitForMonacoVisibleSize(); + ensureMonacoTheme(monaco); + this.editor = monaco.editor.create(this.host, { + value: this.textarea.value || "", + language: this.language, + theme: "bds-theme", + automaticLayout: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: this.wordWrap, + lineNumbers: "on", + lineNumbersMinChars: 3, + fontSize: 14, + fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", + padding: { top: 12, bottom: 12 }, + roundedSelection: false, + renderLineHighlight: "line", + formatOnPaste: true, + cursorStyle: "line", + cursorBlinking: "smooth", + quickSuggestions: this.language === "markdown-with-macros" ? false : true, + tabSize: 2, + insertSpaces: true + }); + registerMonacoEditor(this.editorId || this.el.id, this.editor); + monaco.editor.setTheme("bds-theme"); + this.syncEditorFromTextarea(); + this.layoutEditorSoon(); + this.changeSubscription = this.editor.onDidChangeModelContent(() => { + if (this.isApplyingRemoteUpdate) { + return; + } + this.queueSync(); + }); + if (this.insertEvent) { + this.handleEvent(this.insertEvent, this.handleInsert); + } + }).catch((error) => { + console.error("Failed to load Monaco editor", error); + }); + }, + updated() { + this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); + this.host = this.el.querySelector(".monaco-editor-instance"); + this.language = this.el.dataset.monacoLanguage || this.language || "plaintext"; + this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off"; + if (!this.editor || !this.textarea) { + return; + } + loadMonaco().then((monaco) => { + ensureMonacoTheme(monaco); + monaco.editor.setTheme("bds-theme"); + if (this.editor.getModel()?.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.editor.getModel(), this.language); + } + this.editor.updateOptions({ wordWrap: this.wordWrap }); + }); + this.syncEditorFromTextarea(); + this.layoutEditorSoon(); + }, + destroyed() { + window.clearTimeout(this.syncTimer); + this.visibleSizeObserver?.disconnect(); + this.changeSubscription?.dispose(); + unregisterMonacoEditor(this.editorId || this.el.id); + this.editor?.dispose(); + } + }; + + // js/hooks/monaco_diff_editor.js + var MonacoDiffEditor = { + mounted() { + this.host = this.el.querySelector(".monaco-diff-editor-instance"); + this.originalInput = this.el.querySelector(".monaco-diff-original"); + this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); + this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; + this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; + this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; + this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; + this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; + this.readValues = () => ({ + original: this.originalInput?.value || "", + modified: this.modifiedInput?.value || "" + }); + this.applyDataset = () => { + this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; + this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; + this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; + this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; + this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; + }; + this.setModels = (monaco) => { + const values = this.readValues(); + this.originalModel?.dispose(); + this.modifiedModel?.dispose(); + this.originalModel = monaco.editor.createModel( + values.original, + this.language, + monaco.Uri.parse(diffModelPath(this.filePath, "original")) + ); + this.modifiedModel = monaco.editor.createModel( + values.modified, + this.language, + monaco.Uri.parse(diffModelPath(this.filePath, "modified")) + ); + this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel }); + this.lastFilePath = this.filePath; + }; + loadMonaco().then((monaco) => { + if (!this.host) { + return; + } + ensureMonacoTheme(monaco); + this.editor = monaco.editor.createDiffEditor(this.host, { + theme: "bds-theme", + automaticLayout: true, + readOnly: true, + renderSideBySide: this.viewStyle === "side-by-side", + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbers: "on", + diffCodeLens: false, + originalEditable: false, + wordWrap: this.wordWrap, + hideUnchangedRegions: { enabled: this.hideUnchanged }, + ignoreTrimWhitespace: false + }); + this.setModels(monaco); + }).catch((error) => { + console.error("Failed to load Monaco diff editor", error); + }); + }, + updated() { + this.host = this.el.querySelector(".monaco-diff-editor-instance"); + this.originalInput = this.el.querySelector(".monaco-diff-original"); + this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); + this.applyDataset(); + if (!this.editor) { + return; + } + loadMonaco().then((monaco) => { + ensureMonacoTheme(monaco); + monaco.editor.setTheme("bds-theme"); + this.editor.updateOptions({ + renderSideBySide: this.viewStyle === "side-by-side", + wordWrap: this.wordWrap, + hideUnchangedRegions: { enabled: this.hideUnchanged } + }); + if (this.lastFilePath !== this.filePath) { + this.setModels(monaco); + return; + } + const values = this.readValues(); + if (this.originalModel && this.originalModel.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.originalModel, this.language); + } + if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) { + monaco.editor.setModelLanguage(this.modifiedModel, this.language); + } + if (this.originalModel && this.originalModel.getValue() !== values.original) { + this.originalModel.setValue(values.original); + } + if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) { + this.modifiedModel.setValue(values.modified); + } + }); + }, + destroyed() { + this.originalModel?.dispose(); + this.modifiedModel?.dispose(); + this.editor?.dispose(); + } + }; // js/hooks/index.js - var createHooks = (hooks) => hooks; - - // js/monaco/services.js - var createMonacoServices = (services) => services; + var Hooks2 = { + AppShell, + SidebarInteractions, + SettingsSectionScroll, + TagsSectionScroll, + ChatSurface, + MenuEditorTree, + MonacoEditor, + MonacoDiffEditor + }; // js/app.js document.addEventListener("DOMContentLoaded", () => { const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); - const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar"; - const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar"; - const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language"; - const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-"; - const parseShortcutConfig = (value) => { - if (!value) { - return []; - } - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch (_error) { - return []; - } - }; - const parseJsonObject = (value) => { - if (!value) { - return null; - } - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; - } catch (_error) { - return null; - } - }; - const setMediaThumbnailLoaded = (image, loaded) => { - const thumbnail = image?.closest(".media-thumbnail"); - if (!thumbnail) { - return; - } - if (loaded) { - thumbnail.classList.add("is-loaded"); - } else { - thumbnail.classList.remove("is-loaded"); - } - }; - const syncMediaThumbnailState = (root) => { - root.querySelectorAll(".media-thumbnail-image").forEach((image) => { - setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0)); - }); - }; - const normalizeShortcutKey = (key) => String(key || "").toLowerCase(); - const shortcutTargetIsEditable = (event) => { - const tag = event.target?.tagName || null; - return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag); - }; - const shortcutMatchesEvent = (shortcut, event) => { - const primary = event.metaKey || event.ctrlKey; - return normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) && primary === Boolean(shortcut.primary) && event.shiftKey === Boolean(shortcut.shift) && event.altKey === Boolean(shortcut.alt); - }; - const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); - const readStoredSize = (key, fallback, min, max) => { - const raw = window.localStorage.getItem(key); - if (!raw) { - return fallback; - } - const parsed = Number.parseInt(raw, 10); - if (Number.isNaN(parsed)) { - return fallback; - } - return clamp(parsed, min, max); - }; - const shellWidth = (selector) => { - const shell = document.querySelector(selector); - if (!shell) { - return 0; - } - const width = Number.parseInt(shell.style.width || "0", 10); - return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width; - }; - const setShellWidth = (selector, width) => { - const shell = document.querySelector(selector); - if (shell) { - shell.style.width = `${width}px`; - shell.classList.remove("is-hidden"); - } - }; - const persistWidth = (target, width) => { - const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY; - window.localStorage.setItem(key, String(width)); - }; - let monacoLoaderPromise; - let liquidLanguageRegistered = false; - let markdownWithMacrosRegistered = false; - let monacoThemeSignature = null; - const monacoEditors = /* @__PURE__ */ new Map(); - const activeMonacoEditor = () => { - for (const editor of monacoEditors.values()) { - if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) { - return editor; - } - } - return null; - }; - const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => { - if (!editor) { - return false; - } - const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null; - if (action && typeof action.run === "function") { - action.run(); - return true; - } - if (typeof editor.trigger === "function") { - editor.trigger("bds-menu", triggerId, null); - return true; - } - return false; - }; - const runDocumentCommand = (command) => { - if (typeof document.execCommand !== "function") { - return false; - } - try { - return document.execCommand(command); - } catch (_error) { - return false; - } - }; - const applyAppZoom = (nextZoom) => { - const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2); - window.__bdsAppZoom = zoom; - document.documentElement.style.zoom = String(zoom); - }; - const menuRuntimeCommandRunner = createMenuRuntimeCommandRunner({ - activeMonacoEditor, - runMonacoEditorAction, - runDocumentCommand, - applyAppZoom - }); - const cssVar = (name, fallback) => { - const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - return value || fallback; - }; - const parseRgbColor = (value) => { - if (!value) { - return null; - } - const hex = value.match(/^#([0-9a-f]{6})$/i); - if (hex) { - return { - r: Number.parseInt(hex[1].slice(0, 2), 16), - g: Number.parseInt(hex[1].slice(2, 4), 16), - b: Number.parseInt(hex[1].slice(4, 6), 16) - }; - } - const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i); - if (!rgb) { - return null; - } - return { - r: Number.parseInt(rgb[1], 10), - g: Number.parseInt(rgb[2], 10), - b: Number.parseInt(rgb[3], 10) - }; - }; - const normalizeMonacoColor = (value, fallback) => { - const rgb = parseRgbColor(value); - if (!rgb) { - return fallback; - } - return `#${[rgb.r, rgb.g, rgb.b].map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0")).join("")}`; - }; - const loadScript = (src) => new Promise((resolve, reject) => { - const existing = document.querySelector(`script[src="${src}"]`); - if (existing) { - if (existing.dataset.loaded === "true") { - resolve(); - return; - } - existing.addEventListener("load", () => resolve(), { once: true }); - existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { - once: true - }); - return; - } - const script = document.createElement("script"); - script.src = src; - script.async = true; - script.addEventListener( - "load", - () => { - script.dataset.loaded = "true"; - resolve(); - }, - { once: true } - ); - script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { - once: true - }); - document.head.appendChild(script); - }); - const diffModelPath = (filePath, side) => { - const normalized = String(filePath || "working-tree").replace(/^\/+/, ""); - return `inmemory://model/git-diff/${side}/${normalized}`; - }; - const registerLiquidLanguage = (monaco) => { - if (liquidLanguageRegistered) { - return; - } - monaco.languages.register({ id: "liquid" }); - monaco.languages.setLanguageConfiguration("liquid", { - comments: { - blockComment: ["{% comment %}", "{% endcomment %}"] - }, - brackets: [ - ["{", "}"], - ["[", "]"], - ["(", ")"] - ], - autoClosingPairs: [ - { open: "{", close: "}" }, - { open: "[", close: "]" }, - { open: "(", close: ")" }, - { open: '"', close: '"' }, - { open: "'", close: "'" } - ], - surroundingPairs: [ - { open: "{", close: "}" }, - { open: "[", close: "]" }, - { open: "(", close: ")" }, - { open: '"', close: '"' }, - { open: "'", close: "'" } - ] - }); - monaco.languages.setMonarchTokensProvider("liquid", { - defaultToken: "", - tokenizer: { - root: [ - [/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }], - [/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }], - [/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }], - [/<=!]=?|\.|:/, "operator"], - [/[a-zA-Z_][\w.-]*/, "identifier"], - [/[,:()[\]]/, "delimiter"] - ], - liquidComment: [ - [/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }], - [/./, "comment.block"] - ], - htmlComment: [ - [/-->/, { token: "comment", next: "@pop" }], - [/./, "comment"] - ], - htmlTag: [ - [/\/>/, { token: "delimiter.html", next: "@pop" }], - [/>/, { token: "delimiter.html", next: "@pop" }], - [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], - [/[\w:-]+/, "attribute.name"], - [/=/, "delimiter"] - ], - scriptTag: [ - [/>/, { token: "delimiter.html", next: "@pop" }], - [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], - [/[\w:-]+/, "attribute.name"], - [/=/, "delimiter"] - ], - styleTag: [ - [/>/, { token: "delimiter.html", next: "@pop" }], - [/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"], - [/[\w:-]+/, "attribute.name"], - [/=/, "delimiter"] - ] - } - }); - liquidLanguageRegistered = true; - }; - const registerMarkdownWithMacrosLanguage = (monaco) => { - if (markdownWithMacrosRegistered) { - return; - } - monaco.languages.register({ id: "markdown-with-macros" }); - monaco.languages.setMonarchTokensProvider("markdown-with-macros", { - defaultToken: "", - tokenPostfix: ".md", - tokenizer: { - root: [ - [/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }], - [/^#{1,6}\s.*$/, "keyword.header"], - [/^\s*>+/, "string.quote"], - [/^\s*[-+*]\s/, "keyword"], - [/^\s*\d+\.\s/, "keyword"], - [/^\s*```\w*/, { token: "string.code", next: "@codeblock" }], - [/\*\*[^*]+\*\*/, "strong"], - [/\*[^*]+\*/, "emphasis"], - [/__[^_]+__/, "strong"], - [/_[^_]+_/, "emphasis"], - [/`[^`]+`/, "variable"], - [/!?\[[^\]]*\]\([^)]*\)/, "string.link"], - [/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"] - ], - macroParams: [ - [/\]\]/, { token: "keyword.macro", next: "@root" }], - [/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"], - [/=/, "delimiter"], - [/"[^"]*"/, "string"], - [/\s+/, "white"], - [/[^\]"=\s]+/, "attribute.value"] - ], - codeblock: [ - [/^\s*```\s*$/, { token: "string.code", next: "@root" }], - [/.*$/, "variable.source"] - ] - } - }); - markdownWithMacrosRegistered = true; - }; - const ensureMonacoTheme = (monaco) => { - const background = normalizeMonacoColor( - cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")), - "#1e1e1e" - ); - const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4"); - const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585"); - const activeLineNumber = normalizeMonacoColor( - cssVar("--vscode-editorLineNumber-activeForeground", foreground), - foreground - ); - const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78"); - const inactiveSelection = normalizeMonacoColor( - cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"), - "#3a3d41" - ); - const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground); - const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c"); - const lineHighlight = normalizeMonacoColor( - cssVar("--vscode-editor-lineHighlightBackground", background), - background - ); - const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|"); - if (signature === monacoThemeSignature) { - monaco.editor.setTheme("bds-theme"); - return; - } - monaco.editor.defineTheme("bds-theme", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" }, - { token: "attribute.name", foreground: "9CDCFE" }, - { token: "attribute.value", foreground: "CE9178" } - ], - colors: { - "editor.background": background, - "editor.foreground": foreground, - "editor.lineHighlightBackground": lineHighlight, - "editorCursor.foreground": cursor, - "editor.selectionBackground": selection, - "editor.inactiveSelectionBackground": inactiveSelection, - "editorLineNumber.foreground": lineNumber, - "editorLineNumber.activeForeground": activeLineNumber, - "editorIndentGuide.background1": border, - "editorIndentGuide.activeBackground1": foreground, - "editorWidget.border": border, - "editorGutter.background": background, - "focusBorder": border, - "input.border": border - } - }); - monacoThemeSignature = signature; - monaco.editor.setTheme("bds-theme"); - }; - const loadMonaco = () => { - if (window.monaco?.editor) { - ensureMonacoTheme(window.monaco); - registerLiquidLanguage(window.monaco); - registerMarkdownWithMacrosLanguage(window.monaco); - return Promise.resolve(window.monaco); - } - if (monacoLoaderPromise) { - return monacoLoaderPromise; - } - monacoLoaderPromise = loadScript("/monaco/vs/loader.js").then( - () => new Promise((resolve, reject) => { - window.require.config({ paths: { vs: "/monaco/vs" } }); - window.require(["vs/editor/editor.main"], () => { - ensureMonacoTheme(window.monaco); - registerLiquidLanguage(window.monaco); - registerMarkdownWithMacrosLanguage(window.monaco); - resolve(window.monaco); - }, reject); - }) - ).catch((error) => { - monacoLoaderPromise = null; - throw error; - }); - return monacoLoaderPromise; - }; - const monacoServices = createMonacoServices({ loadMonaco, ensureMonacoTheme }); - const Hooks2 = { - AppShell: { - mounted() { - this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts); - this.currentProjectId = this.el.dataset.projectId || ""; - this.syncStoredLayout(); - this.syncStoredUiLanguage(); - this.destroyOverlaySync = syncTitlebarOverlayInsets(); - this.workbenchStorageKey = (projectId) => projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null; - this.restoreStoredWorkbenchSession = () => { - const projectId = this.el.dataset.projectId || ""; - const storageKey = this.workbenchStorageKey(projectId); - if (!storageKey) { - return false; - } - const session = parseJsonObject(window.localStorage.getItem(storageKey)); - if (!session) { - return false; - } - this.pushEvent("restore_workbench_session", { session }); - return true; - }; - this.persistWorkbenchSession = () => { - const projectId = this.el.dataset.projectId || ""; - const storageKey = this.workbenchStorageKey(projectId); - const session = this.el.dataset.workbenchSession; - if (!storageKey || !session) { - return; - } - window.localStorage.setItem(storageKey, session); - }; - this.handleMouseDown = (event) => { - const handle = event.target.closest("[data-role='resize-handle']"); - if (!handle || !this.el.contains(handle)) { - return; - } - event.preventDefault(); - const target = handle.dataset.resize; - const startX = event.clientX; - const startWidth = target === "assistant" ? shellWidth("[data-testid='assistant-shell']") : shellWidth("[data-testid='sidebar-shell']"); - const min = target === "assistant" ? 280 : 200; - const max = target === "assistant" ? 640 : 500; - const invert = target === "assistant"; - const onMouseMove = (moveEvent) => { - const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX; - const width = clamp(startWidth + delta, min, max); - const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']"; - setShellWidth(selector, width); - persistWidth(target, width); - }; - const onMouseUp = (upEvent) => { - const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX; - const width = clamp(startWidth + delta, min, max); - persistWidth(target, width); - this.pushEvent("resize_panel", { target, width }); - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - }; - this.el.addEventListener("mousedown", this.handleMouseDown); - this.handleNativeMenuAction = (event) => { - const action = event.detail?.action; - const ackId = event.detail?.ackId; - if (action) { - this.pushEvent("native_menu_action", { action }, () => { - if (ackId) { - window.dispatchEvent( - new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } }) - ); - } - }); - } - }; - this.handleChange = (event) => { - const select = event.target.closest(".status-bar-language-select"); - if (select && this.el.contains(select)) { - window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value); - } - }; - this.handleShortcutKeyDown = (event) => { - if (shortcutTargetIsEditable(event)) { - return; - } - const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event)); - if (!shortcut) { - return; - } - event.preventDefault(); - event.stopPropagation(); - this.pushEvent("shortcut", { - key: normalizeShortcutKey(event.key), - meta: event.metaKey, - ctrl: event.ctrlKey, - alt: event.altKey, - shift: event.shiftKey, - tag: event.target?.tagName || null, - contentEditable: event.target?.isContentEditable || false - }); - }; - this.handleThumbnailLoad = (event) => { - if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { - setMediaThumbnailLoaded(event.target, true); - } - }; - this.handleThumbnailError = (event) => { - if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) { - setMediaThumbnailLoaded(event.target, false); - } - }; - this.handleEvent("menu-runtime-command", ({ action }) => { - if (action) { - menuRuntimeCommandRunner(String(action)); - } - }); - window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); - window.addEventListener("keydown", this.handleShortcutKeyDown, true); - this.el.addEventListener("load", this.handleThumbnailLoad, true); - this.el.addEventListener("error", this.handleThumbnailError, true); - this.el.addEventListener("change", this.handleChange); - syncMediaThumbnailState(this.el); - this.restoreStoredWorkbenchSession(); - }, - updated() { - const nextProjectId = this.el.dataset.projectId || ""; - if (nextProjectId !== this.currentProjectId) { - this.currentProjectId = nextProjectId; - if (this.restoreStoredWorkbenchSession()) { - return; - } - } - syncMediaThumbnailState(this.el); - this.persistWorkbenchSession(); - }, - destroyed() { - this.el.removeEventListener("mousedown", this.handleMouseDown); - this.el.removeEventListener("load", this.handleThumbnailLoad, true); - this.el.removeEventListener("error", this.handleThumbnailError, true); - this.el.removeEventListener("change", this.handleChange); - window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction); - window.removeEventListener("keydown", this.handleShortcutKeyDown, true); - if (this.destroyOverlaySync) { - this.destroyOverlaySync(); - } - }, - syncStoredLayout() { - this.pushEvent("sync_layout", { - sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500), - assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640) - }); - }, - syncStoredUiLanguage() { - const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY); - if (stored) { - this.pushEvent("sync_ui_language", { language: stored }); - } - } - }, - SidebarInteractions: { - mounted() { - this.handleDblClick = (event) => { - const button = event.target.closest("[data-testid='sidebar-open-item']"); - if (!button || !this.el.contains(button)) { - return; - } - this.pushEvent("pin_sidebar_item", { - route: button.dataset.route, - id: button.dataset.itemId, - title: button.dataset.openTitle || "", - subtitle: button.dataset.openSubtitle || "" - }); - }; - this.el.addEventListener("dblclick", this.handleDblClick); - }, - destroyed() { - this.el.removeEventListener("dblclick", this.handleDblClick); - } - }, - SettingsSectionScroll: { - mounted() { - this.lastTargetId = null; - this.scrollToSelectedSection(); - }, - updated() { - this.scrollToSelectedSection(); - }, - scrollToSelectedSection() { - const targetId = this.el.dataset.settingsScrollTarget; - if (!targetId || targetId === this.lastTargetId) { - return; - } - this.lastTargetId = targetId; - window.requestAnimationFrame(() => { - const target = document.getElementById(targetId); - if (target && this.el.contains(target)) { - target.scrollIntoView({ block: "start", behavior: "smooth" }); - } - }); - } - }, - TagsSectionScroll: { - mounted() { - this.lastTargetId = null; - this.scrollToSelectedSection(); - }, - updated() { - this.scrollToSelectedSection(); - }, - scrollToSelectedSection() { - const targetId = this.el.dataset.tagsScrollTarget; - if (!targetId || targetId === this.lastTargetId) { - return; - } - this.lastTargetId = targetId; - window.requestAnimationFrame(() => { - const target = document.getElementById(targetId); - if (target && this.el.contains(target)) { - target.scrollIntoView({ block: "start", behavior: "smooth" }); - } - }); - } - }, - ChatSurface: { - mounted() { - this.stickToBottom = true; - this.scrollContainer = null; - this.autoResize = () => { - const textarea = this.el.querySelector(".chat-input"); - if (!textarea) { - return; - } - const styles = getComputedStyle(textarea); - const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20; - const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160; - textarea.rows = 1; - textarea.style.minHeight = `${minHeight}px`; - if (textarea.value.trim() === "") { - textarea.style.height = `${minHeight}px`; - textarea.style.maxHeight = `${minHeight}px`; - textarea.style.overflowY = "hidden"; - return; - } - textarea.style.maxHeight = `${maxHeight}px`; - textarea.style.height = "0px"; - const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight); - textarea.style.height = `${nextHeight}px`; - textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden"; - }; - this.syncScrollContainer = () => { - const nextContainer = this.el.querySelector(".chat-messages"); - if (nextContainer === this.scrollContainer) { - return; - } - if (this.scrollContainer) { - this.scrollContainer.removeEventListener("scroll", this.handleScroll); - } - this.scrollContainer = nextContainer; - if (this.scrollContainer) { - this.scrollContainer.addEventListener("scroll", this.handleScroll); - } - }; - this.scrollToBottom = (force = false) => { - if (!this.scrollContainer) { - return; - } - if (force || this.stickToBottom) { - this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight; - } - }; - this.syncExpandedSurfaces = () => { - this.el.querySelectorAll(".chat-inline-surface[data-expanded='true']").forEach((surface) => { - surface.open = true; - }); - }; - this.surfaceObserver = new MutationObserver(() => { - this.syncExpandedSurfaces(); - }); - this.handleScroll = () => { - if (!this.scrollContainer) { - this.stickToBottom = true; - return; - } - const distanceFromBottom = this.scrollContainer.scrollHeight - this.scrollContainer.scrollTop - this.scrollContainer.clientHeight; - this.stickToBottom = distanceFromBottom < 48; - }; - this.handleInput = (event) => { - if (!event.target.closest(".chat-input")) { - return; - } - this.stickToBottom = true; - this.autoResize(); - }; - this.handleKeyDown = (event) => { - if (!event.target.closest(".chat-input")) { - return; - } - if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { - event.preventDefault(); - const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); - if (sendButton && !sendButton.disabled) { - sendButton.click(); - } - } - }; - this.el.addEventListener("input", this.handleInput); - this.el.addEventListener("keydown", this.handleKeyDown); - this.syncScrollContainer(); - this.syncExpandedSurfaces(); - this.surfaceObserver.observe(this.el, { childList: true, subtree: true }); - this.autoResize(); - window.requestAnimationFrame(() => this.scrollToBottom(true)); - }, - updated() { - this.syncScrollContainer(); - this.syncExpandedSurfaces(); - this.autoResize(); - window.requestAnimationFrame(() => this.scrollToBottom()); - }, - destroyed() { - this.surfaceObserver.disconnect(); - this.el.removeEventListener("input", this.handleInput); - this.el.removeEventListener("keydown", this.handleKeyDown); - if (this.scrollContainer) { - this.scrollContainer.removeEventListener("scroll", this.handleScroll); - } - } - }, - MenuEditorTree: { - mounted() { - this.dragItemId = null; - this.dragSourceEl = null; - this.dropTargetEl = null; - this.dropPosition = null; - this.clearDropTarget = () => { - if (this.dropTargetEl) { - this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside"); - } - this.dropTargetEl = null; - this.dropPosition = null; - }; - this.setDropTarget = (row, position) => { - if (this.dropTargetEl === row && this.dropPosition === position) { - return; - } - this.clearDropTarget(); - this.dropTargetEl = row; - this.dropPosition = position; - row.classList.add(`is-drop-${position}`); - }; - this.handleDragStart = (event) => { - const handle = event.target.closest("[data-menu-drag-handle='true']"); - const row = event.target.closest("[data-menu-item-id]"); - if (!handle || !row || !this.el.contains(row)) { - return; - } - this.dragItemId = row.dataset.menuItemId || null; - this.dragSourceEl = row; - row.classList.add("is-dragging"); - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", this.dragItemId || ""); - } - }; - this.handleDragOver = (event) => { - const row = event.target.closest("[data-menu-item-id]"); - if (!this.dragItemId || !row || !this.el.contains(row)) { - this.clearDropTarget(); - return; - } - const targetItemId = row.dataset.menuItemId || ""; - if (!targetItemId || targetItemId === this.dragItemId) { - this.clearDropTarget(); - return; - } - event.preventDefault(); - const rect = row.getBoundingClientRect(); - const offsetY = event.clientY - rect.top; - const allowInside = row.dataset.menuCanDropInside === "true"; - const insideBandTop = rect.height * 0.3; - const insideBandBottom = rect.height * 0.7; - const position = allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom ? "inside" : offsetY < rect.height / 2 ? "before" : "after"; - this.setDropTarget(row, position); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "move"; - } - }; - this.handleDrop = (event) => { - const row = event.target.closest("[data-menu-item-id]"); - if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) { - this.clearDropTarget(); - return; - } - event.preventDefault(); - this.pushEvent("menu_editor_drop_item", { - drag_item_id: this.dragItemId, - target_item_id: row.dataset.menuItemId, - position: this.dropPosition - }); - this.clearDropTarget(); - }; - this.handleDragLeave = (event) => { - const related = event.relatedTarget; - if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) { - this.clearDropTarget(); - } - }; - this.handleDragEnd = () => { - if (this.dragSourceEl) { - this.dragSourceEl.classList.remove("is-dragging"); - } - this.dragItemId = null; - this.dragSourceEl = null; - this.clearDropTarget(); - }; - this.el.addEventListener("dragstart", this.handleDragStart); - this.el.addEventListener("dragover", this.handleDragOver); - this.el.addEventListener("drop", this.handleDrop); - this.el.addEventListener("dragleave", this.handleDragLeave); - this.el.addEventListener("dragend", this.handleDragEnd); - }, - destroyed() { - this.el.removeEventListener("dragstart", this.handleDragStart); - this.el.removeEventListener("dragover", this.handleDragOver); - this.el.removeEventListener("drop", this.handleDrop); - this.el.removeEventListener("dragleave", this.handleDragLeave); - this.el.removeEventListener("dragend", this.handleDragEnd); - } - }, - MonacoEditor: { - mounted() { - this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); - this.host = this.el.querySelector(".monaco-editor-instance"); - this.language = this.el.dataset.monacoLanguage || "plaintext"; - this.wordWrap = this.el.dataset.monacoWordWrap || "off"; - this.editorId = this.el.dataset.monacoEditorId || ""; - this.insertEvent = this.el.dataset.monacoInsertEvent || ""; - this.syncTimer = null; - this.isApplyingRemoteUpdate = false; - this.lastKnownValue = this.textarea?.value || ""; - this.syncEditorFromTextarea = () => { - this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); - if (!this.textarea || !this.editor) { - return; - } - const value = this.textarea.value || ""; - if (this.editor.getValue() !== value) { - this.isApplyingRemoteUpdate = true; - this.editor.setValue(value); - this.isApplyingRemoteUpdate = false; - } - this.lastKnownValue = value; - }; - this.layoutEditorSoon = () => { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (!this.editor) { - return; - } - this.editor.layout(); - }); - }); - }; - this.waitForMonacoVisibleSize = () => new Promise((resolve) => { - let settled = false; - let attempts = 0; - const hasVisibleSize = () => { - const rect = this.host?.getBoundingClientRect(); - return Boolean(rect && rect.width > 0 && rect.height > 0); - }; - const finish = () => { - if (settled) { - return; - } - settled = true; - this.visibleSizeObserver?.disconnect(); - this.visibleSizeObserver = null; - resolve(); - }; - const check = () => { - if (hasVisibleSize() || attempts >= 20) { - finish(); - return; - } - attempts += 1; - window.requestAnimationFrame(check); - }; - if (hasVisibleSize()) { - finish(); - return; - } - if (window.ResizeObserver && this.host) { - this.visibleSizeObserver = new ResizeObserver(() => { - if (hasVisibleSize()) { - finish(); - } - }); - this.visibleSizeObserver.observe(this.host); - } - window.requestAnimationFrame(check); - }); - this.queueSync = () => { - if (!this.textarea || !this.editor) { - return; - } - window.clearTimeout(this.syncTimer); - this.syncTimer = window.setTimeout(() => { - if (!this.textarea || !this.editor) { - return; - } - const value = this.editor.getValue(); - if (this.textarea.value === value) { - return; - } - this.lastKnownValue = value; - this.textarea.value = value; - this.textarea.dispatchEvent(new Event("input", { bubbles: true })); - }, 120); - }; - this.handleInsert = ({ id, content }) => { - if (!this.editor || !content || String(id) !== String(this.editorId)) { - return; - } - const model = this.editor.getModel(); - const selection = this.editor.getSelection(); - if (!model || !selection) { - return; - } - const value = this.editor.getValue(); - const start = model.getOffsetAt(selection.getStartPosition()); - const end = model.getOffsetAt(selection.getEndPosition()); - const before = value.slice(0, start); - const after = value.slice(end); - const separator = before !== "" && !before.endsWith("\n") ? "\n" : ""; - const suffix = after !== "" && !content.endsWith("\n") ? "\n" : ""; - const inserted = `${separator}${content}${suffix}`; - this.editor.executeEdits("bds-insert-content", [ - { - range: selection, - text: inserted, - forceMoveMarkers: true - } - ]); - this.editor.focus(); - }; - monacoServices.loadMonaco().then(async (monaco) => { - if (!this.host || !this.textarea) { - return; - } - await this.waitForMonacoVisibleSize(); - monacoServices.ensureMonacoTheme(monaco); - this.editor = monaco.editor.create(this.host, { - value: this.textarea.value || "", - language: this.language, - theme: "bds-theme", - automaticLayout: true, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: this.wordWrap, - lineNumbers: "on", - lineNumbersMinChars: 3, - fontSize: 14, - fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", - padding: { top: 12, bottom: 12 }, - roundedSelection: false, - renderLineHighlight: "line", - formatOnPaste: true, - cursorStyle: "line", - cursorBlinking: "smooth", - quickSuggestions: this.language === "markdown-with-macros" ? false : true, - tabSize: 2, - insertSpaces: true - }); - monacoEditors.set(this.editorId || this.el.id, this.editor); - monaco.editor.setTheme("bds-theme"); - this.syncEditorFromTextarea(); - this.layoutEditorSoon(); - this.changeSubscription = this.editor.onDidChangeModelContent(() => { - if (this.isApplyingRemoteUpdate) { - return; - } - this.queueSync(); - }); - if (this.insertEvent) { - this.handleEvent(this.insertEvent, this.handleInsert); - } - }).catch((error) => { - console.error("Failed to load Monaco editor", error); - }); - }, - updated() { - this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); - this.host = this.el.querySelector(".monaco-editor-instance"); - this.language = this.el.dataset.monacoLanguage || this.language || "plaintext"; - this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off"; - if (!this.editor || !this.textarea) { - return; - } - monacoServices.loadMonaco().then((monaco) => { - monacoServices.ensureMonacoTheme(monaco); - monaco.editor.setTheme("bds-theme"); - if (this.editor.getModel()?.getLanguageId() !== this.language) { - monaco.editor.setModelLanguage(this.editor.getModel(), this.language); - } - this.editor.updateOptions({ wordWrap: this.wordWrap }); - }); - this.syncEditorFromTextarea(); - this.layoutEditorSoon(); - }, - destroyed() { - window.clearTimeout(this.syncTimer); - this.visibleSizeObserver?.disconnect(); - this.changeSubscription?.dispose(); - monacoEditors.delete(this.editorId || this.el.id); - this.editor?.dispose(); - } - }, - MonacoDiffEditor: { - mounted() { - this.host = this.el.querySelector(".monaco-diff-editor-instance"); - this.originalInput = this.el.querySelector(".monaco-diff-original"); - this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); - this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; - this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; - this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; - this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; - this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; - this.readValues = () => ({ - original: this.originalInput?.value || "", - modified: this.modifiedInput?.value || "" - }); - this.applyDataset = () => { - this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree"; - this.language = this.el.dataset.monacoDiffLanguage || "plaintext"; - this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline"; - this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off"; - this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true"; - }; - this.setModels = (monaco) => { - const values = this.readValues(); - this.originalModel?.dispose(); - this.modifiedModel?.dispose(); - this.originalModel = monaco.editor.createModel( - values.original, - this.language, - monaco.Uri.parse(diffModelPath(this.filePath, "original")) - ); - this.modifiedModel = monaco.editor.createModel( - values.modified, - this.language, - monaco.Uri.parse(diffModelPath(this.filePath, "modified")) - ); - this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel }); - this.lastFilePath = this.filePath; - }; - monacoServices.loadMonaco().then((monaco) => { - if (!this.host) { - return; - } - monacoServices.ensureMonacoTheme(monaco); - this.editor = monaco.editor.createDiffEditor(this.host, { - theme: "bds-theme", - automaticLayout: true, - readOnly: true, - renderSideBySide: this.viewStyle === "side-by-side", - minimap: { enabled: false }, - scrollBeyondLastLine: false, - lineNumbers: "on", - diffCodeLens: false, - originalEditable: false, - wordWrap: this.wordWrap, - hideUnchangedRegions: { enabled: this.hideUnchanged }, - ignoreTrimWhitespace: false - }); - this.setModels(monaco); - }).catch((error) => { - console.error("Failed to load Monaco diff editor", error); - }); - }, - updated() { - this.host = this.el.querySelector(".monaco-diff-editor-instance"); - this.originalInput = this.el.querySelector(".monaco-diff-original"); - this.modifiedInput = this.el.querySelector(".monaco-diff-modified"); - this.applyDataset(); - if (!this.editor) { - return; - } - monacoServices.loadMonaco().then((monaco) => { - monacoServices.ensureMonacoTheme(monaco); - monaco.editor.setTheme("bds-theme"); - this.editor.updateOptions({ - renderSideBySide: this.viewStyle === "side-by-side", - wordWrap: this.wordWrap, - hideUnchangedRegions: { enabled: this.hideUnchanged } - }); - if (this.lastFilePath !== this.filePath) { - this.setModels(monaco); - return; - } - const values = this.readValues(); - if (this.originalModel && this.originalModel.getLanguageId() !== this.language) { - monaco.editor.setModelLanguage(this.originalModel, this.language); - } - if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) { - monaco.editor.setModelLanguage(this.modifiedModel, this.language); - } - if (this.originalModel && this.originalModel.getValue() !== values.original) { - this.originalModel.setValue(values.original); - } - if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) { - this.modifiedModel.setValue(values.modified); - } - }); - }, - destroyed() { - this.originalModel?.dispose(); - this.modifiedModel?.dispose(); - this.editor?.dispose(); - } - } - }; const liveSocket = new LiveSocket2("/live", Socket, { params: { _csrf_token: csrfToken }, - hooks: createHooks(Hooks2), + hooks: Hooks2, metadata: { keydown: (event) => ({ key: event.key, diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 6bdab07..d7a4a7e 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -4005,7 +4005,7 @@ defmodule BDS.Desktop.ShellLiveTest do end test "chat editor hook reopens server-expanded A2UI surfaces after patches" do - live_js = File.read!(Path.expand("../../../assets/js/app.js", __DIR__)) + live_js = File.read!(Path.expand("../../../assets/js/hooks/chat_surface.js", __DIR__)) chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__)) @@ -4161,7 +4161,7 @@ defmodule BDS.Desktop.ShellLiveTest do assert css =~ "max-height: 22px;" assert css =~ "padding: 0;" - live_js = File.read!(Path.expand("../../../assets/js/app.js", __DIR__)) + live_js = File.read!(Path.expand("../../../assets/js/hooks/chat_surface.js", __DIR__)) assert live_js =~ "minHeight = parseFloat(styles.getPropertyValue(\"--chat-input-min-height\"))" diff --git a/test/bds/ui/shell_test.exs b/test/bds/ui/shell_test.exs index 2ec4c55..b0ed217 100644 --- a/test/bds/ui/shell_test.exs +++ b/test/bds/ui/shell_test.exs @@ -28,6 +28,13 @@ defmodule BDS.UI.ShellTest do |> Enum.join("\n") end + defp live_js_source do + Path.wildcard("/Users/gb/Projects/bDS2/assets/js/**/*.js") + |> Enum.sort() + |> Enum.map(&File.read!/1) + |> Enum.join("\n") + end + test "registry exposes the shared sidebar and editor contracts for the base shell" do sidebar_views = Registry.sidebar_views() editor_routes = Registry.editor_routes() @@ -124,7 +131,7 @@ defmodule BDS.UI.ShellTest do test "desktop shell keeps the compact frame metrics and live bootstrap assets" do css = css_source() - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") assert File.exists?("/Users/gb/Projects/bDS2/assets/css/shell.css") @@ -255,7 +262,7 @@ defmodule BDS.UI.ShellTest do test "phase 5 desktop-specific surfaces stay in source modules with responsive behavior" do css = css_source() - app_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + app_js = live_js_source() assert css =~ ".ai-suggestions-modal-backdrop" assert css =~ ".gallery-overlay" @@ -304,15 +311,28 @@ defmodule BDS.UI.ShellTest do test "live javascript is split into focused Phoenix asset modules" do app_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + hooks_index = File.read!("/Users/gb/Projects/bDS2/assets/js/hooks/index.js") - assert app_js =~ ~s(import { createHooks } from "./hooks/index.js";) - assert app_js =~ ~s(import { syncTitlebarOverlayInsets } from "./bridges/titlebar_overlay.js";) - assert app_js =~ ~s(import { createMenuRuntimeCommandRunner } from "./bridges/menu_runtime.js";) - assert app_js =~ ~s(import { createMonacoServices } from "./monaco/services.js";) + assert app_js =~ ~s(import { Hooks } from "./hooks/index.js";) + assert hooks_index =~ ~s(import { AppShell } from "./app_shell.js";) + assert hooks_index =~ ~s(import { ChatSurface } from "./chat_surface.js";) + assert hooks_index =~ ~s(import { MenuEditorTree } from "./menu_editor_tree.js";) + assert hooks_index =~ ~s(import { MonacoEditor } from "./monaco_editor.js";) + assert hooks_index =~ ~s(import { MonacoDiffEditor } from "./monaco_diff_editor.js";) assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/index.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/app_shell.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/chat_surface.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/menu_editor_tree.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/monaco_editor.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/monaco_diff_editor.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/sidebar_interactions.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/hooks/section_scroll.js") assert File.exists?("/Users/gb/Projects/bDS2/assets/js/bridges/titlebar_overlay.js") assert File.exists?("/Users/gb/Projects/bDS2/assets/js/bridges/menu_runtime.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/bridges/document_commands.js") assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/services.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/theme.js") + assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/languages.js") end test "top level shell render uses utility classes for common layout" do @@ -368,7 +388,7 @@ defmodule BDS.UI.ShellTest do test "monaco editor styling forces the internal editor surface to the dark theme" do css = css_source() - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() assert css =~ ".monaco-editor .margin" assert css =~ ".monaco-editor-background" @@ -379,7 +399,7 @@ defmodule BDS.UI.ShellTest do end test "monaco editor hook forces first visible layout and textarea content sync" do - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() assert live_js =~ "this.syncEditorFromTextarea" assert live_js =~ "this.layoutEditorSoon" @@ -391,7 +411,7 @@ defmodule BDS.UI.ShellTest do end test "monaco theme uses normalized app colors before defining the dark theme" do - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() assert live_js =~ "normalizeMonacoColor" assert live_js =~ "base: \"vs-dark\"" @@ -400,7 +420,7 @@ defmodule BDS.UI.ShellTest do end test "desktop shell assets persist workbench layout per project" do - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") session_util_ex = @@ -416,7 +436,7 @@ defmodule BDS.UI.ShellTest do test "desktop shell assets reveal loaded media sidebar thumbnails" do css = css_source() - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-image" assert css =~ ".media-thumbnail.is-loaded .media-thumbnail-fallback" @@ -444,7 +464,7 @@ defmodule BDS.UI.ShellTest do test "desktop shell assets keep old activity, tab, focus, and titlebar overlay parity rules" do css = css_source() - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() titlebar_js = File.read!("/Users/gb/Projects/bDS2/assets/js/bridges/titlebar_overlay.js") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex") @@ -494,7 +514,7 @@ defmodule BDS.UI.ShellTest do test "desktop shell assets keep legacy titlebar menu keyboard and anchoring behavior" do css = css_source() - live_js = File.read!("/Users/gb/Projects/bDS2/assets/js/app.js") + live_js = live_js_source() live_ex = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex") template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")