-
-
-
Web Hosting β Deploy Static Sites Instantly
- Deploy your static websites, HTML pages, JavaScript apps, and assets directly to Back4app. Your files are served globally with automatic HTTPS and custom domain support.
-
- { !hasDeployed && (
- <>
-
+ )
}
export default B4aCloudPublicEmpty;
diff --git a/src/components/B4aFileTree/B4aFileTree.react.js b/src/components/B4aFileTree/B4aFileTree.react.js
new file mode 100644
index 0000000000..7da12d0cdb
--- /dev/null
+++ b/src/components/B4aFileTree/B4aFileTree.react.js
@@ -0,0 +1,590 @@
+/*
+ * Copyright (c) 2016-present, Parse, LLC
+ * All rights reserved.
+ *
+ * This source code is licensed under the license found in the LICENSE file in
+ * the root directory of this source tree.
+ */
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import PropTypes from 'lib/PropTypes';
+import styles from 'components/B4aFileTree/B4aFileTree.scss';
+
+const FOLDER_TYPES = new Set(['folder', 'new-folder']);
+
+const isFolder = node => FOLDER_TYPES.has(node && node.type);
+
+const buildPath = (parentPath, node) =>
+ parentPath ? `${parentPath}/${node.text}` : node.text;
+
+const ChevronIcon = () => (
+
+
+
+);
+
+const FolderIcon = ({ open }) => {
+ const color = open ? '#fbbf24' : 'rgba(251, 191, 36, 0.7)';
+ return (
+
+
+
+ );
+};
+
+const FILE_COLORS = {
+ js: '#fbbf24',
+ mjs: '#fbbf24',
+ jsx: '#fbbf24',
+ ts: '#60a5fa',
+ tsx: '#60a5fa',
+ json: '#ca8a04',
+ html: '#fb923c',
+ htm: '#fb923c',
+ css: '#3b82f6',
+ scss: '#f472b6',
+ md: 'rgba(255, 255, 255, 0.6)',
+ txt: 'rgba(255, 255, 255, 0.5)',
+};
+
+const FILE_LABELS = {
+ js: 'JS',
+ mjs: 'JS',
+ jsx: 'JSX',
+ ts: 'TS',
+ tsx: 'TSX',
+ json: '{}',
+ html: '<>',
+ htm: '<>',
+ css: '#',
+ scss: '#',
+ md: 'MD',
+};
+
+const getExtension = filename => {
+ if (typeof filename !== 'string') {
+ return '';
+ }
+ const idx = filename.lastIndexOf('.');
+ if (idx <= 0) {
+ return '';
+ }
+ return filename.slice(idx + 1).toLowerCase();
+};
+
+const FileIcon = ({ filename }) => {
+ const ext = getExtension(filename);
+ const color = FILE_COLORS[ext] || 'rgba(255, 255, 255, 0.5)';
+ const label = FILE_LABELS[ext] || '';
+ return (
+
+
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ );
+};
+
+const NewFileIcon = () => (
+
+
+
+
+
+
+);
+
+const NewFolderIcon = () => (
+
+
+
+
+
+);
+
+const RenameIcon = () => (
+
+
+
+
+);
+
+const DeleteIcon = () => (
+
+
+
+
+
+
+
+);
+
+const ContextMenu = ({ x, y, items, onClose }) => {
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = e => {
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
+ onClose();
+ }
+ };
+ const handleEscape = e => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside, true);
+ document.addEventListener('keydown', handleEscape, true);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside, true);
+ document.removeEventListener('keydown', handleEscape, true);
+ };
+ }, [onClose]);
+
+ useEffect(() => {
+ if (!menuRef.current) {
+ return;
+ }
+ const rect = menuRef.current.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ if (rect.right > vw) {
+ menuRef.current.style.left = `${x - rect.width}px`;
+ }
+ if (rect.bottom > vh) {
+ menuRef.current.style.top = `${y - rect.height}px`;
+ }
+ }, [x, y]);
+
+ return createPortal(
+
+ {items.map(item => (
+ { item.action(); onClose(); }}
+ >
+ {item.icon}
+ {item.label}
+
+ ))}
+
,
+ document.body
+ );
+};
+
+const TreeNode = ({
+ node,
+ parentPath,
+ depth,
+ expanded,
+ selectedPath,
+ editingPath,
+ editingValue,
+ draggingPath,
+ dropTargetPath,
+ onToggle,
+ onSelect,
+ onContextAction,
+ onRenameChange,
+ onRenameCommit,
+ onRenameCancel,
+ onDragStart,
+ onDragEnd,
+ onDropNode,
+ canDropNode,
+}) => {
+ const folder = isFolder(node);
+ const path = buildPath(parentPath, node);
+ const isExpanded = expanded.has(path);
+ const isSelected = selectedPath === path;
+ const isEditing = editingPath === path;
+ const isDragging = draggingPath === path;
+ const isDropTarget = dropTargetPath === path;
+
+ const handleClick = () => {
+ if (isEditing) {
+ return;
+ }
+ if (folder) {
+ onToggle(path);
+ }
+ onSelect(node, path);
+ };
+
+ const handleContextMenu = e => {
+ if (typeof onContextAction !== 'function') {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ onContextAction(node, path, folder, e.clientX, e.clientY);
+ };
+
+ const draggable = typeof onDropNode === 'function' && path.indexOf('/') > -1;
+
+ const handleDragStart = e => {
+ if (isEditing || !draggable) {
+ e.preventDefault();
+ return;
+ }
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', path);
+ onDragStart(path);
+ };
+
+ const handleDragOver = e => {
+ if (!folder || typeof canDropNode !== 'function' || !canDropNode(draggingPath, path)) {
+ return;
+ }
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ };
+
+ const handleDrop = e => {
+ if (!folder || typeof onDropNode !== 'function') {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ onDropNode(draggingPath || e.dataTransfer.getData('text/plain'), path);
+ };
+
+ const handleDragEnter = e => {
+ if (!folder || typeof canDropNode !== 'function' || !canDropNode(draggingPath, path)) {
+ return;
+ }
+ e.preventDefault();
+ onToggle(path, true);
+ };
+
+ const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
+ const rowClassName = [
+ styles.row,
+ isSelected ? styles.selected : '',
+ isDragging ? styles.dragging : '',
+ isDropTarget ? styles.dropTarget : '',
+ ].filter(Boolean).join(' ');
+
+ return (
+
+
+
+ {folder ? : null}
+
+
+ {folder ? : }
+
+ {isEditing ? (
+ e.stopPropagation()}
+ onFocus={e => e.target.select()}
+ onChange={e => onRenameChange(e.target.value)}
+ onBlur={() => onRenameCommit(node, path)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ onRenameCommit(node, path);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ onRenameCancel();
+ }
+ }}
+ />
+ ) : (
+ {node.text}
+ )}
+
+
+ {folder && isExpanded && Array.isArray(node.children) && node.children.length > 0 ? (
+
+ {node.children.map(child => (
+
+ ))}
+
+ ) : null}
+
+ );
+};
+
+const B4aFileTree = ({
+ tree,
+ selectedPath,
+ onFileSelect,
+ onContextAction,
+ onNodeDrop,
+ rootFilter,
+ defaultExpanded,
+ emptyMessage,
+}) => {
+ const [expanded, setExpanded] = useState(() => new Set(defaultExpanded || []));
+ const [ctxMenu, setCtxMenu] = useState(null);
+ const [editingPath, setEditingPath] = useState('');
+ const [editingValue, setEditingValue] = useState('');
+ const [draggingPath, setDraggingPath] = useState('');
+ const [dropTargetPath, setDropTargetPath] = useState('');
+
+ useEffect(() => {
+ if (!selectedPath) {
+ return;
+ }
+ const segments = selectedPath.split('/');
+ if (segments.length <= 1) {
+ return;
+ }
+ setExpanded(prev => {
+ const next = new Set(prev);
+ let accumulated = '';
+ for (let i = 0; i < segments.length - 1; i++) {
+ accumulated = accumulated ? `${accumulated}/${segments[i]}` : segments[i];
+ next.add(accumulated);
+ }
+ if (next.size === prev.size) {
+ return prev;
+ }
+ return next;
+ });
+ }, [selectedPath]);
+
+ const toggleFolder = useCallback((path, forceOpen) => {
+ setExpanded(prev => {
+ const next = new Set(prev);
+ if (forceOpen) {
+ next.add(path);
+ } else if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
+
+ const startRename = useCallback((node, path) => {
+ setEditingPath(path);
+ setEditingValue(node.text || '');
+ }, []);
+
+ const cancelRename = useCallback(() => {
+ setEditingPath('');
+ setEditingValue('');
+ }, []);
+
+ const commitRename = useCallback((node, path) => {
+ const nextName = editingValue.trim();
+ if (!nextName || nextName === node.text) {
+ cancelRename();
+ return;
+ }
+ onContextAction('rename', node, path, nextName);
+ cancelRename();
+ }, [cancelRename, editingValue, onContextAction]);
+
+ const canDropNode = useCallback((sourcePath, targetPath) => {
+ if (typeof onNodeDrop !== 'function' || !sourcePath || !targetPath) {
+ return false;
+ }
+ if (sourcePath === targetPath) {
+ return false;
+ }
+ if (sourcePath.indexOf('/') === -1) {
+ return false;
+ }
+ if (sourcePath.split('/').slice(0, -1).join('/') === targetPath) {
+ return false;
+ }
+ if (targetPath.startsWith(`${sourcePath}/`)) {
+ return false;
+ }
+ return true;
+ }, [onNodeDrop]);
+
+ const handleDragStart = useCallback(path => {
+ setDraggingPath(path);
+ setDropTargetPath('');
+ }, []);
+
+ const handleDragEnd = useCallback(() => {
+ setDraggingPath('');
+ setDropTargetPath('');
+ }, []);
+
+ const handleDropNode = useCallback((sourcePath, targetPath) => {
+ if (!canDropNode(sourcePath, targetPath)) {
+ handleDragEnd();
+ return;
+ }
+ onNodeDrop(sourcePath, targetPath);
+ handleDragEnd();
+ }, [canDropNode, handleDragEnd, onNodeDrop]);
+
+ const handleNodeContext = useCallback((node, path, folder, x, y) => {
+ if (typeof onContextAction !== 'function') {
+ return;
+ }
+ setCtxMenu({ node, path, folder, x, y });
+ }, [onContextAction]);
+
+ const ctxMenuItems = useMemo(() => {
+ if (!ctxMenu) {
+ return [];
+ }
+ const isProtectedRoot =
+ ctxMenu.path.indexOf('/') === -1 && (ctxMenu.node.text === 'cloud' || ctxMenu.node.text === 'public');
+ const items = [
+ {
+ key: 'new-file',
+ label: 'New File',
+ icon:
,
+ action: () => onContextAction('create-file', ctxMenu.node, ctxMenu.path),
+ },
+ ];
+ if (ctxMenu.folder) {
+ items.push({
+ key: 'new-folder',
+ label: 'New Folder',
+ icon:
,
+ action: () => onContextAction('create-folder', ctxMenu.node, ctxMenu.path),
+ });
+ }
+ if (!isProtectedRoot) {
+ items.push(
+ {
+ key: 'rename',
+ label: 'Rename',
+ icon:
,
+ action: () => startRename(ctxMenu.node, ctxMenu.path),
+ },
+ {
+ key: 'delete',
+ label: ctxMenu.folder ? 'Delete Folder' : 'Delete File',
+ icon:
,
+ action: () => onContextAction('delete', ctxMenu.node, ctxMenu.path),
+ }
+ );
+ }
+ return items;
+ }, [ctxMenu, onContextAction, startRename]);
+
+ const filteredTree = useMemo(() => {
+ if (!Array.isArray(tree) || tree.length === 0) {
+ return [];
+ }
+ if (!rootFilter || rootFilter.length === 0) {
+ return tree;
+ }
+ const allowed = new Set(rootFilter);
+ return tree.filter(node => allowed.has(node.text));
+ }, [tree, rootFilter]);
+
+ if (filteredTree.length === 0) {
+ return (
+
{emptyMessage || 'No files found'}
+ );
+ }
+
+ return (
+
+ {filteredTree.map(node => (
+ {
+ const canDrop = canDropNode(sourcePath, targetPath);
+ setDropTargetPath(canDrop ? targetPath : '');
+ return canDrop;
+ }}
+ />
+ ))}
+ {ctxMenu ? (
+
+ ) : null}
+
+ );
+};
+
+B4aFileTree.propTypes = {
+ tree: PropTypes.arrayOf(PropTypes.any).isRequired.describe('Hierarchical tree data (jstree-style nodes with text/type/children).'),
+ selectedPath: PropTypes.string.describe('The currently selected node path.'),
+ onFileSelect: PropTypes.func.isRequired.describe('Called with (node, path) when a file is clicked.'),
+ onContextAction: PropTypes.func.describe('Called with (action, node, path) on right-click menu selection. Actions: "create-file", "create-folder", "rename", "delete".'),
+ onNodeDrop: PropTypes.func.describe('Called with (sourcePath, targetPath) when a node is dropped onto a folder.'),
+ rootFilter: PropTypes.arrayOf(PropTypes.string).describe('When set, only root nodes whose `text` is in this list are rendered.'),
+ defaultExpanded: PropTypes.arrayOf(PropTypes.string).describe('Paths that should start expanded.'),
+ emptyMessage: PropTypes.string.describe('Message to show when the tree is empty.'),
+};
+
+export default B4aFileTree;
+export { getExtension };
diff --git a/src/components/B4aFileTree/B4aFileTree.scss b/src/components/B4aFileTree/B4aFileTree.scss
new file mode 100644
index 0000000000..8ff028decc
--- /dev/null
+++ b/src/components/B4aFileTree/B4aFileTree.scss
@@ -0,0 +1,184 @@
+.tree {
+ flex: 1 1 auto;
+ overflow: auto;
+ padding: 8px 0;
+ background: transparent;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif;
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.7);
+
+ &::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.16);
+ }
+}
+
+.empty {
+ padding: 16px;
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.4);
+ text-align: center;
+}
+
+.nodeWrapper {
+ width: 100%;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 4px 8px;
+ background: transparent;
+ border: 0;
+ outline: none;
+ text-align: left;
+ color: rgba(255, 255, 255, 0.7);
+ font: inherit;
+ font-size: 14px;
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ transition: background-color 150ms ease, color 150ms ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.05);
+ }
+
+ &:focus-visible {
+ outline: 1px solid rgba(32, 138, 236, 0.6);
+ outline-offset: -1px;
+ }
+}
+
+.selected {
+ background: rgba(32, 138, 236, 0.2) !important;
+ color: #208AEC !important;
+
+ &:hover {
+ background: rgba(32, 138, 236, 0.28) !important;
+ }
+}
+
+.dragging {
+ opacity: 0.45;
+}
+
+.dropTarget {
+ background: rgba(32, 138, 236, 0.18) !important;
+ box-shadow: inset 2px 0 0 #208AEC;
+}
+
+.chevron {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ color: rgba(255, 255, 255, 0.55);
+ transition: transform 200ms ease;
+ flex-shrink: 0;
+}
+
+.chevronOpen {
+ transform: rotate(90deg);
+}
+
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+.label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.renameInput {
+ flex: 1 1 auto;
+ min-width: 0;
+ height: 24px;
+ padding: 2px 6px;
+ background: #2d2d30;
+ border: 1px solid #208AEC;
+ border-radius: 4px;
+ box-shadow: 0 0 0 1px rgba(32, 138, 236, 0.35);
+ color: #ffffff;
+ font: inherit;
+ outline: none;
+}
+
+.children {
+ display: block;
+}
+
+.contextMenu {
+ position: fixed;
+ z-index: 10000;
+ min-width: 160px;
+ padding: 4px 0;
+ background: #252526;
+ border: 1px solid #3c3c3c;
+ border-radius: 4px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.8);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', 'Ubuntu', 'Droid Sans', sans-serif;
+}
+
+.contextMenuItem {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 12px;
+ background: transparent;
+ border: 0;
+ outline: none;
+ text-align: left;
+ color: rgba(255, 255, 255, 0.8);
+ font: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background-color 100ms ease;
+
+ &:hover {
+ background: #094771;
+ color: #ffffff;
+ }
+
+ &:focus-visible {
+ background: #094771;
+ color: #ffffff;
+ }
+}
+
+.contextMenuIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ color: rgba(255, 255, 255, 0.6);
+}
diff --git a/src/components/CodeEditor/B4aCodeEditor.react.js b/src/components/CodeEditor/B4aCodeEditor.react.js
index 7b8eb41bb3..9923fdf688 100644
--- a/src/components/CodeEditor/B4aCodeEditor.react.js
+++ b/src/components/CodeEditor/B4aCodeEditor.react.js
@@ -1,168 +1,40 @@
-import React, { useState, useEffect, forwardRef } from 'react';
-import CodeMirror from '@uiw/react-codemirror';
-import { javascript, esLint } from '@codemirror/lang-javascript';
-import { html } from '@codemirror/lang-html';
-import { css } from '@codemirror/lang-css';
-import { json } from '@codemirror/lang-json';
-import { xml } from '@codemirror/lang-xml';
-import { linter, lintGutter } from '@codemirror/lint';
-import { search } from '@codemirror/search';
-import { EditorView } from '@codemirror/view';
-import globals from 'globals';
-import { createTheme } from '@uiw/codemirror-themes';
-import { tags as t } from '@lezer/highlight';
-// import * as eslint from 'eslint-linter-browserify';
-// import { StreamLanguage } from '@codemirror/language';
-
-const myTheme = createTheme({
- theme: 'dark',
- settings: {
- background: '#111214',
- foreground: '#CECFD0',
- caret: '#fff',
- selection: '#727377',
- selectionMatch: '#727377',
- lineHighlight: '#ffffff0f',
- backgroundImage: '',
-
- gutterBackground: '#0A0B0C',
- gutterForeground: '#f9f9f980',
- gutterBorder: '#dddddd',
- gutterActiveForeground: '#f9f9f9',
- },
- styles: [
- { tag: [t.comment, t.quote], color: '#7F8C98' },
- { tag: [t.keyword], color: '#FF7AB2', fontWeight: 'bold' },
- { tag: [t.string, t.meta], color: '#27AE60' },
- { tag: [t.typeName], color: '#7cacf8' },
- { tag: [t.definition(t.variableName)], color: '#6BDFFF' },
- { tag: [t.name], color: '#6BAA9F' },
- { tag: [t.variableName], color: '#15A9FF' },
- { tag: [t.regexp, t.link], color: '#FF8170' },
- ],
-});
-
-const config = {
- languageOptions: {
- globals: {
- ...globals.node,
- Parse: true,
- },
- ecmaVersion: 2022,
- sourceType: 'module'
- },
- rules: {
- 'no-const-assign': 'error',
- 'no-restricted-syntax': [
- 'error',
- {
- selector: 'VariableDeclaration[kind=\'const\'] > VariableDeclarator[init.type=\'Identifier\'][init.name=\'undefined\']',
- message: 'Do not initialize `const` variables to `undefined`.'
- }
- ]
- }
-};
-
-const loadEslint = () => {
- return new Promise((resolve, reject) => {
- if (window.eslint) {
- resolve(window.eslint);
- return;
- }
-
- const script = document.createElement('script');
- script.src = 'https://cdn.jsdelivr.net/npm/eslint-linter-browserify/linter.min.js';
- script.async = true;
- script.onload = () => resolve(window.eslint);
- script.onerror = reject;
- document.head.appendChild(script);
- });
+import React, { Suspense, lazy, forwardRef, useMemo } from 'react';
+
+// Lazy-load the heavy Monaco-based editor implementation into its own webpack
+// chunk so it (and `@monaco-editor/react` + `@monaco-editor/loader`) don't
+// land in the main dashboard bundle. The chunk is fetched the first time any
+// route mounts an editor (Cloud Code, Playground, Custom Parse Options
+// modal) and is cached for subsequent mounts.
+const B4aCodeEditorImpl = lazy(() =>
+ import(/* webpackChunkName: "b4a-code-editor" */ './B4aCodeEditorImpl.react')
+);
+
+const loadingFallbackStyle = {
+ color: '#CECFD0',
+ background: '#111214',
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: '12px',
+ boxSizing: 'border-box',
};
-const B4aCodeEditor = forwardRef(({ code: initialCode, onCodeChange, mode, readOnly = false }, ref) => {
- const [code, setCode] = useState(initialCode);
- const [eslintInstance, setEslintInstance] = useState(null);
-
- useEffect(() => {
- if (mode === 'javascript' || mode === 'js') {
- loadEslint()
- .then(eslint => {
- setEslintInstance(new eslint.Linter());
- })
- .catch(error => {
- console.error('Failed to load ESLint:', error);
- });
- }
- }, [mode]);
-
- useEffect(() => {
- if (window && window.document.querySelector('.cm-theme')) {
- const el = window.document.querySelector('.cm-theme');
- el.style.height = '100%';
- el.style.fontSize = '12px';
- }
- }, []);
-
- useEffect(() => {
- setCode(initialCode);
- }, [initialCode]);
-
- const handleCodeChange = (value) => {
- setCode(value);
- typeof onCodeChange === 'function' && onCodeChange(value)
- };
-
- // Set the language extension
- const getLanguageExtension = () => {
- switch (mode) {
- case 'html':
- return [html()];
- case 'xml':
- return [xml()];
- case 'css':
- return [css()];
- case 'json':
- return [json()];
- case 'javascript':
- case 'js':
- return [
- javascript(),
- ...(eslintInstance ? [linter(esLint(eslintInstance, config))] : [])
- ];
- default:
- return [];
- }
- };
+// Kept visually identical to the impl's own `loading` prop so the user only
+// ever sees one consistent "Loading editorβ¦" surface across both phases:
+// (1) chunk download, then (2) Monaco CDN bootstrap.
+const LoadingFallback = () =>
Loading editorβ¦
;
+const B4aCodeEditor = forwardRef((props, ref) => {
+ const fallback = useMemo(() =>
, []);
return (
-
EditorView.scrollIntoView(range.from, { y: 'center' }),
- })
- ]}
- onChange={(value) => {
- handleCodeChange(value)
- }}
- onCreateEditor={() => {
- if (window && window.document.querySelector('.cm-editor')) {
- const el = window.document.querySelector('.cm-editor');
- el.style.outline = 'none'
- }
- }}
- basicSetup={{ lineNumbers: true, highlightActiveLine: true }}
- theme={myTheme}
- editable={!readOnly}
- readOnly={readOnly}
- />
+
+
+
);
-})
+});
+
+B4aCodeEditor.displayName = 'B4aCodeEditor';
export default B4aCodeEditor;
diff --git a/src/components/CodeEditor/B4aCodeEditorImpl.react.js b/src/components/CodeEditor/B4aCodeEditorImpl.react.js
new file mode 100644
index 0000000000..178a52a534
--- /dev/null
+++ b/src/components/CodeEditor/B4aCodeEditorImpl.react.js
@@ -0,0 +1,163 @@
+import React, { useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react';
+import Editor from '@monaco-editor/react';
+
+const MONACO_THEME = 'vs-dark';
+
+const languageMap = {
+ bash: 'shell',
+ dart: 'dart',
+ graphql: 'graphql',
+ html: 'html',
+ java: 'java',
+ javascript: 'javascript',
+ js: 'javascript',
+ json: 'json',
+ kotlin: 'kotlin',
+ php: 'php',
+ plaintext: 'plaintext',
+ shell: 'shell',
+ swift: 'swift',
+ text: 'plaintext',
+ css: 'css',
+ xml: 'xml',
+};
+
+const loadingFallbackStyle = {
+ color: '#d4d4d4',
+ background: '#1e1e1e',
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: '12px',
+ boxSizing: 'border-box',
+};
+
+const B4aCodeEditorImpl = forwardRef(
+ ({ code: initialCode, onCodeChange, mode, readOnly = false, fontSize = 12 }, ref) => {
+ const editorRef = useRef(null);
+ const monacoRef = useRef(null);
+ const [code, setCode] = useState(initialCode ?? '');
+
+ useEffect(() => {
+ const next = initialCode ?? '';
+ // Avoid re-applying the value when the incoming prop is just an echo of
+ // what the user typed (parent re-renders with the same source). This
+ // prevents Monaco from doing a full setValue on every keystroke, which
+ // is heavy (revalidation, undo reset) and can cause cursor jumps.
+ const current = editorRef.current ? editorRef.current.getValue() : code;
+ if (current === next) {
+ return;
+ }
+ setCode(next);
+ }, [initialCode]);
+
+ useImperativeHandle(ref, () => ({
+ get editor() {
+ return editorRef.current;
+ },
+ get monaco() {
+ return monacoRef.current;
+ },
+ get value() {
+ return editorRef.current ? editorRef.current.getValue() : '';
+ },
+ set value(next) {
+ if (editorRef.current && typeof next === 'string') {
+ editorRef.current.setValue(next);
+ }
+ },
+ focus() {
+ editorRef.current && editorRef.current.focus();
+ },
+ }));
+
+ const handleMount = (editor, monaco) => {
+ editorRef.current = editor;
+ monacoRef.current = monaco;
+
+ if (monaco.languages.typescript) {
+ const jsDefaults = monaco.languages.typescript.javascriptDefaults;
+ jsDefaults.setDiagnosticsOptions({
+ noSemanticValidation: false,
+ noSyntaxValidation: false,
+ });
+ jsDefaults.setCompilerOptions({
+ target: monaco.languages.typescript.ScriptTarget.ES2022,
+ allowNonTsExtensions: true,
+ allowJs: true,
+ checkJs: false,
+ });
+ jsDefaults.addExtraLib(
+ [
+ 'declare var Parse: any;',
+ 'declare var process: any;',
+ 'declare var require: any;',
+ 'declare var module: any;',
+ 'declare var __dirname: string;',
+ 'declare var __filename: string;',
+ ].join('\n'),
+ 'file:///b4a-globals.d.ts'
+ );
+ }
+ };
+
+ const handleChange = value => {
+ const next = value ?? '';
+ setCode(next);
+ if (typeof onCodeChange === 'function') {
+ onCodeChange(next);
+ }
+ };
+
+ const language = languageMap[mode] || 'plaintext';
+
+ const options = useMemo(
+ () => ({
+ readOnly,
+ fontSize,
+ lineNumbers: 'on',
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+ tabSize: 2,
+ renderLineHighlight: 'all',
+ fontFamily:
+ '\'Roboto Mono\', Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace',
+ fontLigatures: false,
+ smoothScrolling: true,
+ cursorSmoothCaretAnimation: 'on',
+ bracketPairColorization: { enabled: true },
+ guides: { bracketPairs: true, indentation: true },
+ padding: { top: 8, bottom: 8 },
+ wordWrap: 'off',
+ fixedOverflowWidgets: true,
+ }),
+ [readOnly, fontSize]
+ );
+
+ const loadingElement = useMemo(
+ () => Loading editorβ¦
,
+ []
+ );
+
+ return (
+
+ );
+ }
+);
+
+B4aCodeEditorImpl.displayName = 'B4aCodeEditorImpl';
+
+export default B4aCodeEditorImpl;
diff --git a/src/dashboard/Data/AppOverview/AppOverview.scss b/src/dashboard/Data/AppOverview/AppOverview.scss
index 59ce750c00..8689c30824 100644
--- a/src/dashboard/Data/AppOverview/AppOverview.scss
+++ b/src/dashboard/Data/AppOverview/AppOverview.scss
@@ -1082,12 +1082,35 @@
padding-top: 0.5rem;
background: $regal-blue;
border-radius: 4px;
+ margin-bottom: 1rem;
& .codeBlockHeader {
display: flex;
align-items: center;
justify-content: space-between;
- margin: 0rem 1.5rem;
+ margin: 0rem 1.5rem 0.5rem 1.5rem;
+ }
+
+ & .languageLabel {
+ @include InterFont;
+ color: $light-blue;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ }
+
+ & .codeEditorBlock {
+ background: #111214;
+ border-radius: 0 0 4px 4px;
+ overflow: hidden;
+ }
+
+ & .copyButton {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ line-height: 0;
}
.copyButtonWrapper {
@@ -1440,6 +1463,14 @@
display: inline-flex;
align-items: center;
gap: 0.5em;
+
+ & .copyButton {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ line-height: 0;
+ padding: 2px;
+ }
}
}
}
@@ -1586,4 +1617,4 @@
&:hover {
text-decoration: underline;
}
-}
\ No newline at end of file
+}
diff --git a/src/dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react.js b/src/dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react.js
new file mode 100644
index 0000000000..2ddc575d80
--- /dev/null
+++ b/src/dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react.js
@@ -0,0 +1,65 @@
+import React, { useMemo, useState } from 'react';
+import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react';
+import Icon from 'components/Icon/Icon.react';
+import styles from 'dashboard/Data/AppOverview/AppOverview.scss';
+
+const getEditorHeight = code => {
+ const lineCount = code.split('\n').length;
+ return Math.min(Math.max(lineCount * 19 + 16, 96), 420);
+};
+
+const AppOverviewCodeEditorBlock = ({ language, value, fileName, inline = false }) => {
+ const [copied, setCopied] = useState(false);
+ const codeText = useMemo(() => String(value || '').trim(), [value]);
+ const mode = language || 'plaintext';
+
+ const copyToClipboard = async () => {
+ try {
+ await navigator.clipboard.writeText(codeText);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Failed to copy text: ', err);
+ }
+ };
+
+ if (inline) {
+ return (
+
+ {codeText}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
{fileName || mode}
+
+ {copied &&
Copied!
}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AppOverviewCodeEditorBlock;
diff --git a/src/dashboard/Data/AppOverview/ConnectAppModal.react.js b/src/dashboard/Data/AppOverview/ConnectAppModal.react.js
index 5a3c7c298b..33cbe2d432 100644
--- a/src/dashboard/Data/AppOverview/ConnectAppModal.react.js
+++ b/src/dashboard/Data/AppOverview/ConnectAppModal.react.js
@@ -1,31 +1,10 @@
-import React, { useEffect, useState, Suspense, lazy } from 'react';
+import React, { useState } from 'react';
import Popover from 'components/Popover/Popover.react';
import Position from 'lib/Position';
import styles from 'dashboard/Data/AppOverview/AppOverview.scss';
import Icon from 'components/Icon/Icon.react';
import ReactMarkdown from 'react-markdown';
-import Prism from 'prismjs';
-// Import Prism Line Numbers plugin
-import 'prismjs/plugins/line-numbers/prism-line-numbers';
-import 'prismjs/plugins/line-numbers/prism-line-numbers.css';
-
-import 'prismjs/components/prism-markup-templating.js';
-import 'prismjs/components/prism-javascript';
-import 'prismjs/components/prism-bash';
-import 'prismjs/components/prism-graphql';
-import 'prismjs/components/prism-java';
-import 'prismjs/components/prism-php';
-import 'prismjs/components/prism-dart';
-import 'prismjs/components/prism-kotlin';
-import 'prismjs/components/prism-swift';
-
-import 'prismjs/plugins/line-numbers/prism-line-numbers'
-import 'prismjs/plugins/line-numbers/prism-line-numbers.css'
-
-// eslint-disable-next-line no-unused-vars
-import customPrisma from 'stylesheets/b4a-prisma.css';
-
-const CodeBlock = lazy(() => import('components/CodeBlock/CodeBlock.react'));
+import AppOverviewCodeEditorBlock from './AppOverviewCodeEditorBlock.react';
const LanguageDocMap = {
rest: {
@@ -925,16 +904,15 @@ const ConnectAppModal = ({ closeModal }) => {