From b966e9a4a8df7520896f2fd812c112708ee8a9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 22 Oct 2025 00:00:37 +0200 Subject: [PATCH 01/31] Playground file browser --- .../site-file-browser/code-editor.tsx | 244 ++++++++++ .../site-file-browser/constants.ts | 3 + .../file-explorer-sidebar.tsx | 218 +++++++++ .../file-explorer.module.css | 62 +++ .../site-manager/site-file-browser/index.tsx | 427 ++++++++++++++++++ .../site-file-browser/style.module.css | 155 +++++++ .../site-manager/site-info-panel/index.tsx | 18 + .../site-info-panel/style.module.css | 14 +- 8 files changed, 1139 insertions(+), 2 deletions(-) create mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx create mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/constants.ts create mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx create mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css create mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/index.tsx create mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/style.module.css diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx new file mode 100644 index 0000000000..2f420038b2 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -0,0 +1,244 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + type MutableRefObject, +} from 'react'; +import { Compartment, EditorState } from '@codemirror/state'; +import { + EditorView, + keymap, + lineNumbers, + highlightActiveLine, + highlightActiveLineGutter, + dropCursor, + rectangularSelection, + crosshairCursor, +} from '@codemirror/view'; +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from '@codemirror/commands'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { + autocompletion, + completionKeymap, + closeBrackets, + closeBracketsKeymap, +} from '@codemirror/autocomplete'; +import { + foldGutter, + indentOnInput, + bracketMatching, + foldKeymap, + syntaxHighlighting, + defaultHighlightStyle, +} from '@codemirror/language'; +import { php } from '@codemirror/lang-php'; +import { css } from '@codemirror/lang-css'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { html } from '@codemirror/lang-html'; +import { markdown } from '@codemirror/lang-markdown'; + +const getLanguageExtension = (filePath: string | null) => { + if (!filePath) { + return php(); + } + + const extension = filePath.split('.').pop()?.toLowerCase(); + + switch (extension) { + case 'css': + return css(); + case 'js': + case 'jsx': + case 'ts': + case 'tsx': + return javascript({ + jsx: extension === 'jsx' || extension === 'tsx', + typescript: extension === 'ts' || extension === 'tsx', + }); + case 'json': + return json(); + case 'html': + case 'htm': + return html(); + case 'md': + case 'markdown': + return markdown(); + case 'php': + default: + return php(); + } +}; + +export type CodeEditorHandle = { + focus: () => void; +}; + +export type CodeEditorProps = { + code: string; + onChange: (next: string) => void; + currentPath: string | null; + className?: string; + onSaveShortcut?: () => void; + readOnly?: boolean; + containerRef?: MutableRefObject; +}; + +export const CodeEditor = forwardRef( + function CodeEditor( + { + code, + onChange, + currentPath, + className, + onSaveShortcut, + readOnly = false, + containerRef, + }, + ref + ) { + const editorRootRef = + containerRef ?? + (useRef( + null + ) as MutableRefObject); + const viewRef = useRef(null); + const languageCompartmentRef = useRef(new Compartment()); + const editableCompartmentRef = useRef(new Compartment()); + const latestCodeRef = useRef(code); + + useImperativeHandle(ref, () => ({ + focus: () => { + viewRef.current?.focus(); + }, + })); + + useEffect(() => { + latestCodeRef.current = code; + }, [code]); + + useEffect(() => { + if (viewRef.current) { + return; + } + const container = editorRootRef.current; + if (!container) { + return; + } + + const state = EditorState.create({ + doc: code, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + foldGutter(), + dropCursor(), + rectangularSelection(), + crosshairCursor(), + languageCompartmentRef.current.of( + getLanguageExtension(currentPath) + ), + editableCompartmentRef.current.of( + EditorView.editable.of(!readOnly) + ), + syntaxHighlighting(defaultHighlightStyle), + indentOnInput(), + bracketMatching(), + closeBrackets(), + history(), + highlightSelectionMatches(), + autocompletion(), + EditorView.updateListener.of((update) => { + if (!update.docChanged) { + return; + } + const nextDoc = update.state.doc.toString(); + if (nextDoc === latestCodeRef.current) { + return; + } + latestCodeRef.current = nextDoc; + onChange(nextDoc); + }), + keymap.of([ + { + key: 'Mod-s', + preventDefault: true, + run: () => { + onSaveShortcut?.(); + return true; + }, + }, + ...closeBracketsKeymap, + ...completionKeymap, + ...foldKeymap, + ...searchKeymap, + ...historyKeymap, + ...defaultKeymap, + indentWithTab, + ]), + ], + }); + + const view = new EditorView({ state, parent: container }); + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // The editor instance should be created only once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + const currentDoc = view.state.doc.toString(); + if (code === currentDoc) { + return; + } + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: code }, + }); + }, [code]); + + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + const languageExtension = getLanguageExtension(currentPath); + view.dispatch({ + effects: + languageCompartmentRef.current.reconfigure( + languageExtension + ), + }); + }, [currentPath]); + + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + view.dispatch({ + effects: editableCompartmentRef.current.reconfigure( + EditorView.editable.of(!readOnly) + ), + }); + }, [readOnly]); + + return
; + } +); + +CodeEditor.displayName = 'CodeEditor'; diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/constants.ts b/packages/playground/website/src/components/site-manager/site-file-browser/constants.ts new file mode 100644 index 0000000000..670bce37d5 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-file-browser/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_WORKSPACE_DIR = '/wordpress/workspace'; +export const WORDPRESS_ROOT_DIR = '/wordpress'; +export const MAX_INLINE_FILE_BYTES = 1024 * 1024; // 1MB diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx new file mode 100644 index 0000000000..e226d1bc10 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx @@ -0,0 +1,218 @@ +import { + useMemo, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import styles from './file-explorer.module.css'; +import { + FilePickerTree, + type AsyncWritableFilesystem, + type FilePickerTreeHandle, +} from '@wp-playground/components'; +import { + DEFAULT_WORKSPACE_DIR, + MAX_INLINE_FILE_BYTES, + WORDPRESS_ROOT_DIR, +} from './constants'; + +const normalizeFsPath = (path: string) => { + if (!path) { + return '/'; + } + let normalized = path.replace(/\\+/g, '/'); + if (!normalized.startsWith('/')) { + normalized = `/${normalized}`; + } + normalized = normalized.replace(/\/{2,}/g, '/'); + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized || '/'; +}; + +const dirnameSafe = (path: string) => { + const normalized = normalizeFsPath(path); + if (normalized === '/') { + return '/'; + } + const index = normalized.lastIndexOf('/'); + return index <= 0 ? '/' : normalized.slice(0, index); +}; + +const isProbablyTextBuffer = (buffer: Uint8Array) => { + const len = buffer.byteLength; + for (let i = 0; i < Math.min(len, 4096); i++) { + if (buffer[i] === 0) { + return false; + } + } + try { + new TextDecoder('utf-8', { fatal: true }).decode(buffer); + return true; + } catch { + return false; + } +}; + +const createDownloadUrl = (data: Uint8Array, filename: string) => { + const blob = new Blob([data]); + const url = URL.createObjectURL(blob); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return { url, filename }; +}; + +export type FileExplorerSidebarProps = { + filesystem: AsyncWritableFilesystem; + currentPath: string | null; + selectedDirPath: string | null; + setSelectedDirPath: Dispatch>; + onFileOpened: (path: string, content: string) => Promise | void; + onSelectionCleared: () => Promise | void; + onShowMessage: (message: string) => Promise | void; +}; + +export function FileExplorerSidebar({ + filesystem, + currentPath, + selectedDirPath, + setSelectedDirPath, + onFileOpened, + onSelectionCleared, + onShowMessage, +}: FileExplorerSidebarProps) { + const treeRef = useRef(null); + + const treeInitialPath = useMemo(() => { + return normalizeFsPath( + currentPath + ? dirnameSafe(currentPath) + : selectedDirPath ?? DEFAULT_WORKSPACE_DIR + ); + // Prevent tree from jumping unexpectedly when selectedDirPath changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPath]); + + const [lastSelectedPath, setLastSelectedPath] = useState( + null + ); + const [root, setRoot] = useState(DEFAULT_WORKSPACE_DIR); + + return ( +
+
+ Files +
+ + + +
+
+
+ { + setLastSelectedPath(path); + if (!path) { + await onSelectionCleared(); + return; + } + try { + if (await filesystem.isDir(path)) { + setSelectedDirPath(path); + return; + } + } catch { + // If we cannot determine whether it is a directory, treat as file. + } + try { + const data = await filesystem.readFileAsBuffer( + path + ); + const size = data.byteLength; + if (size > MAX_INLINE_FILE_BYTES) { + const { url, filename } = createDownloadUrl( + data, + path.split('/').pop() || 'download' + ); + await onShowMessage( + [ + 'File too large to open (>1MB).', + `Download: ${url}`, + `Filename: ${filename}`, + ].join('\n') + ); + return; + } + if (!isProbablyTextBuffer(data)) { + const { url, filename } = createDownloadUrl( + data, + path.split('/').pop() || 'download' + ); + await onShowMessage( + [ + 'Binary file. Download instead:', + `Download: ${url}`, + `Filename: ${filename}`, + ].join('\n') + ); + return; + } + const text = new TextDecoder('utf-8').decode(data); + await onFileOpened(path, text); + } catch (error) { + console.error('Could not open file', error); + await onShowMessage('Could not open file.'); + } + }} + /> +
+
+ ); +} diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css new file mode 100644 index 0000000000..bc880a22a0 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css @@ -0,0 +1,62 @@ +.fileExplorerContainer { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + background: #ffffff; +} + +.fileExplorerHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--color-gray-050); + border-bottom: 1px solid var(--color-gray-200); +} + +.fileExplorerTitle { + text-transform: uppercase; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + color: var(--color-gray-700); +} + +.fileExplorerActions { + display: flex; + align-items: center; + gap: 6px; +} + +.fileExplorerButton { + border: 1px solid transparent; + background: transparent; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + color: var(--color-gray-700); + transition: background-color 0.2s ease, color 0.2s ease, + border-color 0.2s ease; +} + +.fileExplorerButton:hover { + background: rgba(0, 0, 0, 0.04); + color: var(--color-gray-900); + border-color: rgba(0, 0, 0, 0.08); +} + +.fileExplorerButton:disabled { + opacity: 0.5; + cursor: default; + background: transparent; + border-color: transparent; +} + +.fileExplorerTree { + flex: 1; + min-height: 0; + overflow: auto; + padding: 6px 0; +} diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx new file mode 100644 index 0000000000..ad9d946645 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx @@ -0,0 +1,427 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, +} from 'react'; +import classNames from 'classnames'; +import { Button, Notice } from '@wordpress/components'; +import type { SiteInfo } from '../../../lib/state/redux/slice-sites'; +import { usePlaygroundClient } from '../../../lib/use-playground-client'; +import type { AsyncWritableFilesystem } from '@wp-playground/components'; +import type { PlaygroundClient } from '@wp-playground/remote'; +import { FileExplorerSidebar } from './file-explorer-sidebar'; +import { CodeEditor, type CodeEditorHandle } from './code-editor'; +import styles from './style.module.css'; +import { DEFAULT_WORKSPACE_DIR, WORDPRESS_ROOT_DIR } from './constants'; + +const SAVE_DEBOUNCE_MS = 1500; + +type SaveState = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; + +export function SiteFileBrowser({ site }: { site: SiteInfo }) { + const client = usePlaygroundClient(site.slug); + const filesystem = useFilesystem(client); + + const [selectedDirPath, setSelectedDirPath] = useState( + DEFAULT_WORKSPACE_DIR + ); + const [currentPath, setCurrentPath] = useState(null); + const [code, setCode] = useState(''); + const [readOnly, setReadOnly] = useState(true); + const [saveState, setSaveState] = useState('idle'); + const [saveError, setSaveError] = useState(null); + const [showExplorerOnMobile, setShowExplorerOnMobile] = + useState(false); + + const editorRef = useRef(null); + const saveTimeoutRef = useRef(null); + const skipNextSaveRef = useRef(false); + const codeRef = useRef(code); + const currentPathRef = useRef(currentPath); + const clientRef = useRef(client); + const previousClientRef = useRef(client); + + useEffect(() => { + codeRef.current = code; + }, [code]); + + useEffect(() => { + currentPathRef.current = currentPath; + }, [currentPath]); + + useEffect(() => { + clientRef.current = client ?? null; + }, [client]); + + useEffect(() => { + if (previousClientRef.current && previousClientRef.current !== client) { + void flushPendingSave(previousClientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + } + previousClientRef.current = client ?? null; + }, [client]); + + useEffect(() => { + return () => { + void flushPendingSave(clientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + }; + }, []); + + useEffect(() => { + if (!client) { + skipNextSaveRef.current = true; + setCode(''); + setCurrentPath(null); + setReadOnly(true); + setSaveState('idle'); + setSaveError(null); + setShowExplorerOnMobile(false); + } + }, [client]); + + useEffect(() => { + setSelectedDirPath(DEFAULT_WORKSPACE_DIR); + setCurrentPath(null); + setCode(''); + setReadOnly(true); + setSaveState('idle'); + setSaveError(null); + skipNextSaveRef.current = true; + }, [site.slug]); + + useEffect(() => { + const activeClient = clientRef.current; + if (!activeClient || !currentPath) { + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + if (!currentPath) { + setSaveState('idle'); + } + return; + } + if (skipNextSaveRef.current) { + skipNextSaveRef.current = false; + return; + } + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + setSaveState('pending'); + const timeout = window.setTimeout(async () => { + saveTimeoutRef.current = null; + setSaveState('saving'); + try { + await activeClient.writeFile( + currentPathRef.current as string, + codeRef.current + ); + setSaveState('saved'); + setSaveError(null); + } catch (error) { + console.error('Failed to save file', error); + setSaveState('error'); + setSaveError('Could not save changes. Try again.'); + } + }, SAVE_DEBOUNCE_MS); + saveTimeoutRef.current = timeout; + + return () => { + if (saveTimeoutRef.current === timeout) { + window.clearTimeout(timeout); + saveTimeoutRef.current = null; + } + }; + }, [code, currentPath]); + + useEffect(() => { + if (saveState !== 'saved') { + return; + } + const timeout = window.setTimeout(() => { + setSaveState((previous) => + previous === 'saved' ? 'idle' : previous + ); + }, 2000); + return () => window.clearTimeout(timeout); + }, [saveState]); + + const handleFileOpened = useCallback( + async (path: string, content: string) => { + try { + await flushPendingSave(clientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + } catch { + // Best-effort save; ignore errors so the new file can still open. + } + skipNextSaveRef.current = true; + setCurrentPath(path); + setCode(content); + setReadOnly(false); + setSaveState('idle'); + setSaveError(null); + setShowExplorerOnMobile(false); + editorRef.current?.focus(); + }, + [] + ); + + const handleClearSelection = useCallback(async () => { + try { + await flushPendingSave(clientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + } catch { + /* noop */ + } + skipNextSaveRef.current = true; + setCurrentPath(null); + setCode(''); + setReadOnly(true); + setSaveState('idle'); + setSaveError(null); + }, []); + + const handleShowMessage = useCallback(async (message: string) => { + try { + await flushPendingSave(clientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + } catch { + /* noop */ + } + skipNextSaveRef.current = true; + setCurrentPath(null); + setCode(message); + setReadOnly(true); + setSaveState('idle'); + setSaveError(null); + }, []); + + const handleManualSave = useCallback(() => { + void flushPendingSave(clientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + }, []); + + const saveStatusLabel = getSaveStatusLabel(saveState, saveError); + const saveStatusClassName = getSaveStatusClassName(saveState, styles); + + if (!client || !filesystem) { + return ( +
+
+ Start this Playground to browse and edit its files. +
+
+ ); + } + + return ( +
+
+
setShowExplorerOnMobile(false)} + /> + +
+
+ +
+ {currentPath?.length + ? currentPath + : `Browse files under ${WORDPRESS_ROOT_DIR}`} +
+
+ {saveStatusLabel} +
+
+ {saveError ? ( +
+ + {saveError} + +
+ ) : null} + {currentPath || code ? ( + + ) : ( +
+ Select a file to view or edit its contents. +
+ )} +
+
+
+ ); +} + +function useFilesystem( + client: PlaygroundClient | null +): AsyncWritableFilesystem | null { + return useMemo(() => { + if (!client) { + return null; + } + return { + isDir: (path: string) => client.isDir(path), + fileExists: (path: string) => client.fileExists(path), + readFileAsBuffer: (path: string) => client.readFileAsBuffer(path), + readFileAsText: (path: string) => client.readFileAsText(path), + listFiles: (path: string) => client.listFiles(path), + writeFile: (path: string, data: string | Uint8Array) => + client.writeFile(path, data), + mkdir: (path: string) => client.mkdir(path), + rmdir: (path: string, options?: { recursive?: boolean }) => + client.rmdir(path, options), + mv: (source: string, destination: string) => + client.mv(source, destination), + unlink: (path: string) => client.unlink(path), + }; + }, [client]); +} + +function getSaveStatusLabel(saveState: SaveState, saveError: string | null) { + switch (saveState) { + case 'pending': + case 'saving': + return 'Saving…'; + case 'saved': + return 'Saved'; + case 'error': + return saveError ?? 'Save failed'; + default: + return ''; + } +} + +function getSaveStatusClassName( + saveState: SaveState, + styleSheet: typeof styles +) { + switch (saveState) { + case 'pending': + return styleSheet.saveStatusPending; + case 'saving': + return styleSheet.saveStatusSaving; + case 'error': + return styleSheet.saveStatusError; + default: + return undefined; + } +} + +async function flushPendingSave( + client: PlaygroundClient | null, + { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }: { + saveTimeoutRef: MutableRefObject; + currentPathRef: MutableRefObject; + codeRef: MutableRefObject; + setSaveState: React.Dispatch>; + setSaveError: React.Dispatch>; + } +) { + if (saveTimeoutRef.current === null) { + return; + } + if (!client || !currentPathRef.current) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + return; + } + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + setSaveState('saving'); + try { + await client.writeFile(currentPathRef.current, codeRef.current); + setSaveState('saved'); + setSaveError(null); + } catch (error) { + console.error('Failed to save file', error); + setSaveState('error'); + setSaveError('Could not save changes. Try again.'); + throw error; + } +} diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css b/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css new file mode 100644 index 0000000000..040be9dba3 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css @@ -0,0 +1,155 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.content { + position: relative; + flex: 1; + display: flex; + min-height: 0; + background: var(--color-gray-000); +} + +.sidebar-wrapper { + display: flex; + flex-direction: column; + width: 280px; + max-width: 360px; + min-width: 220px; + border-right: 1px solid var(--color-gray-200); + background: #ffffff; + z-index: 3; +} + +.editor-wrapper { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} + +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--color-gray-200); + background: #ffffff; + z-index: 1; +} + +.editor-path { + flex: 1; + min-width: 0; + font-family: 'SFMono-Regular', ui-monospace, SFMono-Regular, Menlo, Monaco, + Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 12px; + color: var(--color-gray-700); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.editor-path-placeholder { + color: var(--color-gray-500); +} + +.save-status { + font-size: 12px; + line-height: 16px; + color: var(--color-gray-600); + white-space: nowrap; +} + +.save-status-saving, +.save-status-pending { + color: var(--color-gray-900); +} + +.save-status-error { + color: var(--color-alert-red); + font-weight: 500; +} + +.editor { + flex: 1; + min-height: 0; + background: #ffffff; +} + +.placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + text-align: center; + color: var(--color-gray-700); + background: #ffffff; +} + +.mobile-toggle { + display: none; +} + +.mobile-overlay { + display: none; +} + +@media (max-width: 960px) { + .sidebar-wrapper { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: min(85%, 340px); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.2); + display: none; + } + + .sidebar-open { + /* Marker class applied in JS when the sidebar should be visible on mobile. */ + } + + .sidebar-open .sidebar-wrapper { + display: flex; + } + + .mobile-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 2; + display: none; + } + + .sidebar-open .mobile-overlay { + display: block; + } + + .editor-header { + flex-wrap: wrap; + gap: 8px; + background: #ffffff; + } + + .editor-path { + width: 100%; + order: 2; + } + + .mobile-toggle { + display: inline-flex; + order: 1; + } + + .content { + flex-direction: column; + } +} diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 12af8c9cde..2663f0ae61 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -33,6 +33,7 @@ import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; import { removeSite } from '../../../lib/state/redux/slice-sites'; import { BlueprintReflection } from '@wp-playground/blueprints'; +import { SiteFileBrowser } from '../site-file-browser'; export function SiteInfoPanel({ className, @@ -315,6 +316,10 @@ export function SiteInfoPanel({ name: 'settings', title: 'Settings', }, + { + name: 'files', + title: 'File browser', + }, { name: 'logs', title: 'Logs', @@ -342,6 +347,19 @@ export function SiteInfoPanel({
)} + {tab.name === 'files' && ( +
+ +
+ )} {tab.name === 'logs' && (
Date: Wed, 22 Oct 2025 01:22:21 +0200 Subject: [PATCH 02/31] resizable site panel --- .../website/src/components/layout/index.tsx | 108 +++++++---- .../src/components/layout/style.module.css | 140 ++++---------- .../site-manager/site-info-panel/index.tsx | 183 ++++++++++-------- .../site-info-panel/style.module.css | 14 ++ .../components/site-manager/style.module.css | 2 + 5 files changed, 230 insertions(+), 217 deletions(-) diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 1da56b7323..67fc484fe7 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -1,4 +1,5 @@ import css from './style.module.css'; +import classNames from 'classnames'; import { SiteManager } from '../site-manager'; import { CSSTransition } from 'react-transition-group'; @@ -25,14 +26,17 @@ import { supportedDisplayModes, PlaygroundViewport, } from '../playground-viewport'; -import { - setActiveModal, - setSiteManagerOpen, -} from '../../lib/state/redux/slice-ui'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; import { ImportFormModal } from '../import-form-modal'; import { PreviewPRModal } from '../../github/preview-pr'; import { MissingSiteModal } from '../missing-site-modal'; import { RenameSiteModal } from '../rename-site-modal'; +import { + PanelGroup, + Panel, + PanelResizeHandle, + type ImperativePanelHandle, +} from 'react-resizable-panels'; acquireOAuthTokenIfNeeded(); @@ -62,47 +66,75 @@ export function Layout() { (state) => state.ui.siteManagerIsOpen ); const siteManagerWrapperRef = useRef(null); - const dispatch = useAppDispatch(); + const siteManagerPanelRef = useRef(null); + const [defaultPanelSize] = useState(() => { + if (typeof window === 'undefined') { + return 55; + } + const defaultWidth = 320 + 555 + 24; // sidebar + info + borders + const percent = (defaultWidth / window.innerWidth) * 100; + return Math.max(35, Math.min(70, Math.round(percent))); + }); + + useEffect(() => { + if (!siteManagerPanelRef.current) { + return; + } + if (siteManagerIsOpen) { + siteManagerPanelRef.current.expand(); + } else { + siteManagerPanelRef.current.collapse(); + } + }, [siteManagerIsOpen]); return (
- -
- -
-
-
- {siteManagerIsOpen && ( -
{ - dispatch(setSiteManagerOpen(false)); + - )} -
- -
-
+ unmountOnExit + > +
+ +
+ + + + +
+
+ +
+
+
+
); } diff --git a/packages/playground/website/src/components/layout/style.module.css b/packages/playground/website/src/components/layout/style.module.css index 6c1258176b..f979d5de1f 100644 --- a/packages/playground/website/src/components/layout/style.module.css +++ b/packages/playground/website/src/components/layout/style.module.css @@ -3,8 +3,8 @@ --site-manager-site-info-min-width: 555px; --site-manager-width-desktop: calc( var(--site-manager-site-list-width) + - var(--site-manager-site-info-min-width) + - 2 * var(--site-manager-border-width) + var(--site-manager-site-info-min-width) + 2 * + var(--site-manager-border-width) ); --site-view-min-width: 320px; --site-manager-background-color: #1e1e1e; @@ -29,134 +29,78 @@ body { overflow: hidden; } -.site-view { +.main-panels { + display: flex; + width: 100%; height: 100%; } .site-manager-wrapper { - max-width: var(--site-manager-width-desktop); - @media (max-width: 875px) { - min-width: 0; - width: 100%; - } + height: 100%; + display: flex; } .site-manager-wrapper-enter { opacity: 0; - max-width: 0; } -.site-manager-wrapper-exit, +.site-manager-wrapper-enter-active, +.site-manager-wrapper-exit-active { + transition: opacity 300ms ease; +} + .site-manager-wrapper-enter-active { opacity: 1; - transform: none; - max-width: var(--site-manager-width-desktop); - @media (max-width: 875px) { - width: 100%; - } } -/* - * Repeated rule to ensure it's more specific than - * .site-manager-wrapper-exit - */ -.site-manager-wrapper-exit-active { - opacity: 0; - max-width: 0; +.site-manager-wrapper-exit { + opacity: 1; } -.site-manager-wrapper-exit-active, -.site-manager-wrapper-enter-active { - transition: - /* - * Workaround: Animate max-width to allow `width: auto` - * The `width` property needs to be `auto` to ensure the - * site manager panel expands and shrinks with its content. - * Unfortunately, we can't animate width from `0` to `auto`. - * Therefore, we animate the `max-width` property instead. - * We assign a large enough number (1300px) to make sure it won't - * interfere with regular use cases, and give it more time to animate - * so that it hits its `auto` width approximately around the same time - * as the `opacity` transition finishes. - */ max-width - 450ms, - opacity 300ms; +.site-manager-wrapper-exit-active { + opacity: 0; } -.site-view { - position: relative; - flex: 1 1 auto; - min-width: var(--site-view-min-width); - height: 100dvh; - border: 0px solid var(--site-manager-background-color); - transition: border-radius 300ms ease, border-width 300ms ease, - transform 300ms ease; - border-radius: 0; - border-width: 0; +.layoutResizeHandle { + width: 12px; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; } -.site-manager-wrapper + .site-view { - position: relative; - border-width: var(--site-manager-border-width); - border-left-width: 0; - .site-view-content { - border-radius: var(--site-manager-border-radius); - } +.layoutResizeHandle::before { + content: ''; + display: block; + width: 2px; + height: 70%; + border-radius: 1px; + background: rgba(255, 255, 255, 0.18); } -.site-manager-wrapper:not( - .site-manager-wrapper-exit-active, - .site-manager-wrapper-enter-active - ) - + .site-view:hover { - transform: scale(1.01); +.handleHidden { + visibility: hidden; + pointer-events: none; } -.site-manager-wrapper-exit-active + .site-view { - border-radius: 0; - border-width: 0; - .site-view-content { - border-radius: 0; - } +.site-view { + height: 100%; + display: flex; } .site-view-content { overflow: hidden; - transition: border-radius 300ms; height: 100%; + border-radius: var(--site-manager-border-radius); + border: var(--site-manager-border-width) solid + var(--site-manager-background-color); + border-left-width: 0; + width: 100%; + transition: border-radius 300ms ease; } -.site-view-overlay { - content: ''; - display: block; - width: 100%; - height: 100dvh; - background-color: transparent; - position: absolute; - top: 0; - left: 0; - z-index: 1; - cursor: pointer; -} - -/* - * Unfortunately we cannot use calc() in media queries. - * - * 1166px = --site-manager-width + --site-view-min-width - * - * This manual calculation ensures the site view gets hidden - * on smaller screens and never overflows out of the screen. - */ @media (max-width: 1126px) { - .site-manager-wrapper + .site-view { + .layoutResizeHandle { display: none; } - .site-manager-wrapper, - .site-manager-wrapper-exit, - .site-manager-wrapper-enter-active { - width: 100%; - } - .site-manager-wrapper-exit-active + .site-view { - display: block; - } } diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 2663f0ae61..0907e47bcc 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -11,7 +11,12 @@ import { MenuItem, TabPanel, } from '@wordpress/components'; -import { moreVertical, external, chevronLeft } from '@wordpress/icons'; +import { + moreVertical, + external, + chevronLeft, + chevronRight, +} from '@wordpress/icons'; import { SiteLogs } from '../../log-modal'; import { useAppDispatch, useAppSelector } from '../../../lib/state/redux/store'; import { usePlaygroundClientInfo } from '../../../lib/use-playground-client'; @@ -215,95 +220,111 @@ export function SiteInfoPanel({ )} - - {({ onClose }) => ( - <> - {!isTemporary && ( + + {({ onClose }) => ( + <> + {!isTemporary && ( + + { + dispatch( + setActiveModal( + modalSlugs.RENAME_SITE + ) + ); + onClose(); + }} + > + Rename + + + removeSiteAndCloseMenu( + onClose + ) + } + > + Delete + + + )} + + + + { - dispatch( - setActiveModal( - modalSlugs.RENAME_SITE - ) + onClick={async () => { + const reflection = + await BlueprintReflection.create( + site.metadata + .originalBlueprint as any + ); + const declaration = + reflection.getDeclaration() as any; + const encoded = + encodeStringAsBase64( + JSON.stringify( + declaration + ) as string + ); + window.open( + `/builder/builder.html#${encoded}`, + '_blank', + 'noopener,noreferrer' ); onClose(); }} + icon={external} + iconPosition="right" + aria-label="View Blueprint" + disabled={offline} > - Rename - - - removeSiteAndCloseMenu( - onClose - ) - } - > - Delete + View Blueprint - )} - - - - - - { - const reflection = - await BlueprintReflection.create( - site.metadata - .originalBlueprint as any - ); - const declaration = - reflection.getDeclaration() as any; - const encoded = - encodeStringAsBase64( - JSON.stringify( - declaration - ) as string - ); - window.open( - `/builder/builder.html#${encoded}`, - '_blank', - 'noopener,noreferrer' - ); - onClose(); - }} - icon={external} - iconPosition="right" - aria-label="View Blueprint" - disabled={offline} - > - View Blueprint - - - - - - - )} - + + + + + )} + +
@@ -155,7 +174,6 @@ export function FileExplorerSidebar({ ref={treeRef} filesystem={filesystem} root={root} - key={root} initialSelectedPath={treeInitialPath} onSelect={async (path) => { setLastSelectedPath(path); diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css index bc880a22a0..4896084294 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css @@ -37,6 +37,9 @@ cursor: pointer; font-size: 12px; color: var(--color-gray-700); + display: inline-flex; + align-items: center; + gap: 6px; transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; } diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 0907e47bcc..1cbf635362 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -11,12 +11,7 @@ import { MenuItem, TabPanel, } from '@wordpress/components'; -import { - moreVertical, - external, - chevronLeft, - chevronRight, -} from '@wordpress/icons'; +import { moreVertical, external, chevronLeft } from '@wordpress/icons'; import { SiteLogs } from '../../log-modal'; import { useAppDispatch, useAppSelector } from '../../../lib/state/redux/store'; import { usePlaygroundClientInfo } from '../../../lib/use-playground-client'; @@ -220,118 +215,101 @@ export function SiteInfoPanel({ )} - - - {({ onClose }) => ( - <> - {!isTemporary && ( - - { - dispatch( - setActiveModal( - modalSlugs.RENAME_SITE - ) - ); - onClose(); - }} - > - Rename - - - removeSiteAndCloseMenu( - onClose - ) - } - > - Delete - - - )} - - - - + {({ onClose }) => ( + <> + {!isTemporary && ( { - const reflection = - await BlueprintReflection.create( - site.metadata - .originalBlueprint as any - ); - const declaration = - reflection.getDeclaration() as any; - const encoded = - encodeStringAsBase64( - JSON.stringify( - declaration - ) as string - ); - window.open( - `/builder/builder.html#${encoded}`, - '_blank', - 'noopener,noreferrer' + aria-label="Rename this Playground" + onClick={() => { + dispatch( + setActiveModal( + modalSlugs.RENAME_SITE + ) ); onClose(); }} - icon={external} - iconPosition="right" - aria-label="View Blueprint" - disabled={offline} > - View Blueprint + Rename + + + removeSiteAndCloseMenu( + onClose + ) + } + > + Delete - - - - - )} - -
diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index d79ced851b..8c98672633 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -33,7 +33,11 @@ import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; import { removeSite } from '../../../lib/state/redux/slice-sites'; import { BlueprintReflection } from '@wp-playground/blueprints'; -import { SiteFileBrowser } from '../site-file-browser'; +import { lazy, Suspense } from 'react'; + +const SiteFileBrowser = lazy(() => + import('../site-file-browser').then((m) => ({ default: m.SiteFileBrowser })) +); export function SiteInfoPanel({ className, @@ -393,10 +397,20 @@ export function SiteInfoPanel({ )} hidden={tab.name !== 'files'} > - + {tab.name === 'files' && ( + + Loading file browser... +
+ } + > + + + )}
Date: Thu, 23 Oct 2025 22:42:06 +0200 Subject: [PATCH 10/31] Long editor --- .../site-file-browser/code-editor.tsx | 52 +++++++++++++++++++ .../site-file-browser/style.module.css | 11 ++++ 2 files changed, 63 insertions(+) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx index 8f73d3840b..db28d14918 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -16,7 +16,11 @@ import { dropCursor, rectangularSelection, crosshairCursor, + ViewPlugin, + type PluginValue, + type EditorView as EditorViewType, } from '@codemirror/view'; +import { EditorSelection } from '@codemirror/state'; import { defaultKeymap, history, @@ -77,6 +81,53 @@ const getLanguageExtension = (filePath: string | null) => { } }; +// Plugin to handle clicks below the content and move cursor to end of document +class ClickBelowContentHandler implements PluginValue { + constructor(private view: EditorViewType) { + this.handleClick = this.handleClick.bind(this); + this.view.dom.addEventListener('mousedown', this.handleClick); + } + + handleClick(event: MouseEvent) { + const target = event.target as HTMLElement; + // Check if click is on the editor scroller or content area (empty space below text) + if ( + target.classList.contains('cm-scroller') || + target.classList.contains('cm-content') + ) { + const pos = this.view.posAtCoords({ + x: event.clientX, + y: event.clientY, + }); + + // If pos is null, we clicked below all content + // OR if we're at the document end, move cursor there + if (pos === null) { + const lastPos = this.view.state.doc.length; + const selection = EditorSelection.create([ + EditorSelection.range(lastPos, lastPos), + ]); + this.view.dispatch({ + selection, + effects: EditorView.scrollIntoView(lastPos, { + y: 'center', + }), + }); + this.view.focus(); + event.preventDefault(); + } + } + } + + destroy() { + this.view.dom.removeEventListener('mousedown', this.handleClick); + } +} + +const clickBelowContentExtension = ViewPlugin.define( + (view) => new ClickBelowContentHandler(view) +); + export type CodeEditorHandle = { focus: () => void; }; @@ -144,6 +195,7 @@ export const CodeEditor = forwardRef( dropCursor(), rectangularSelection(), crosshairCursor(), + clickBelowContentExtension, languageCompartmentRef.current.of( getLanguageExtension(currentPath) ), diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css b/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css index 040be9dba3..e0f744288d 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css +++ b/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css @@ -81,6 +81,17 @@ flex: 1; min-height: 0; background: #ffffff; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.editor :global(.cm-editor) { + height: 100%; +} + +.editor :global(.cm-scroller) { + overflow: auto; } .placeholder { From c4282d92110f0c8fd12e9940bbb61e777cbf9bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 23 Oct 2025 23:18:32 +0200 Subject: [PATCH 11/31] Retain cursor position in the code editor --- .../site-file-browser/code-editor.tsx | 24 ++++ .../site-manager/site-file-browser/index.tsx | 130 +++++++++++++++++- .../site-manager/site-info-panel/index.tsx | 27 ++-- 3 files changed, 163 insertions(+), 18 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx index db28d14918..6c991e6909 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -130,6 +130,8 @@ const clickBelowContentExtension = ViewPlugin.define( export type CodeEditorHandle = { focus: () => void; + getCursorPosition: () => number | null; + setCursorPosition: (pos: number) => void; }; export type CodeEditorProps = { @@ -170,6 +172,28 @@ export const CodeEditor = forwardRef( focus: () => { viewRef.current?.focus(); }, + getCursorPosition: () => { + if (!viewRef.current) { + return null; + } + return viewRef.current.state.selection.main.anchor; + }, + setCursorPosition: (pos: number) => { + if (!viewRef.current) { + return; + } + const clampedPos = Math.min( + pos, + viewRef.current.state.doc.length + ); + const selection = EditorSelection.create([ + EditorSelection.range(clampedPos, clampedPos), + ]); + viewRef.current.dispatch({ + selection, + scrollIntoView: true, + }); + }, })); useEffect(() => { diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx index a5184bffc7..197f3a80c7 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx @@ -21,7 +21,13 @@ const SAVE_DEBOUNCE_MS = 1500; type SaveState = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; -export function SiteFileBrowser({ site }: { site: SiteInfo }) { +export function SiteFileBrowser({ + site, + isVisible = true, +}: { + site: SiteInfo; + isVisible?: boolean; +}) { const client = usePlaygroundClient(site.slug); const filesystem = useFilesystem(client); @@ -43,6 +49,8 @@ export function SiteFileBrowser({ site }: { site: SiteInfo }) { const currentPathRef = useRef(currentPath); const clientRef = useRef(client); const previousClientRef = useRef(client); + const hasAutoOpenedRef = useRef(false); + const cursorPositionsRef = useRef>(new Map()); useEffect(() => { codeRef.current = code; @@ -101,6 +109,7 @@ export function SiteFileBrowser({ site }: { site: SiteInfo }) { setSaveState('idle'); setSaveError(null); skipNextSaveRef.current = true; + hasAutoOpenedRef.current = false; }, [site.slug]); useEffect(() => { @@ -162,8 +171,56 @@ export function SiteFileBrowser({ site }: { site: SiteInfo }) { return () => window.clearTimeout(timeout); }, [saveState]); + // Auto-open wp-config.php if it exists + useEffect(() => { + if (!client || hasAutoOpenedRef.current) { + return; + } + + const wpConfigPath = `${WORDPRESS_ROOT_DIR}/wp-config.php`; + + const tryAutoOpen = async () => { + try { + const exists = await client.fileExists(wpConfigPath); + if (exists) { + const content = await client.readFileAsText(wpConfigPath); + skipNextSaveRef.current = true; + setCurrentPath(wpConfigPath); + setCode(content); + setReadOnly(false); + setSaveState('idle'); + setSaveError(null); + // Focus the editor after opening + setTimeout(() => { + editorRef.current?.focus(); + }, 100); + } + } catch (error) { + // Silently fail - wp-config.php may not exist or may not be readable + console.debug('Could not auto-open wp-config.php:', error); + } finally { + hasAutoOpenedRef.current = true; + } + }; + + void tryAutoOpen(); + }, [client]); + const handleFileOpened = useCallback( async (path: string, content: string, shouldFocus = true) => { + // Save cursor position of current file before switching + const currentPos = editorRef.current?.getCursorPosition(); + if ( + currentPos !== null && + currentPos !== undefined && + currentPathRef.current + ) { + cursorPositionsRef.current.set( + currentPathRef.current, + currentPos + ); + } + try { await flushPendingSave(clientRef.current, { saveTimeoutRef, @@ -182,14 +239,79 @@ export function SiteFileBrowser({ site }: { site: SiteInfo }) { setSaveState('idle'); setSaveError(null); setShowExplorerOnMobile(false); - if (shouldFocus) { - editorRef.current?.focus(); - } + + // Restore cursor position for this file if we have one saved + setTimeout(() => { + const savedPos = cursorPositionsRef.current.get(path); + if (savedPos !== undefined) { + editorRef.current?.setCursorPosition(savedPos); + } + if (shouldFocus) { + editorRef.current?.focus(); + } + }, 50); }, [] ); + // Periodically save cursor position while editing + useEffect(() => { + if (!currentPath) { + return; + } + + const interval = setInterval(() => { + const pos = editorRef.current?.getCursorPosition(); + if (pos !== null && pos !== undefined) { + cursorPositionsRef.current.set(currentPath, pos); + } + }, 1000); + + // Save immediately on mount and when currentPath changes + const pos = editorRef.current?.getCursorPosition(); + if (pos !== null && pos !== undefined) { + cursorPositionsRef.current.set(currentPath, pos); + } + + return () => { + clearInterval(interval); + // Save one final time when unmounting or changing files + const finalPos = editorRef.current?.getCursorPosition(); + if (finalPos !== null && finalPos !== undefined) { + cursorPositionsRef.current.set(currentPath, finalPos); + } + }; + }, [currentPath]); + + // Restore cursor position when tab becomes visible + useEffect(() => { + if (!isVisible || !currentPath) { + return; + } + + // Wait a bit for the editor to be ready + const timeout = setTimeout(() => { + const savedPos = cursorPositionsRef.current.get(currentPath); + if (savedPos !== undefined) { + editorRef.current?.setCursorPosition(savedPos); + } + editorRef.current?.focus(); + }, 100); + + return () => clearTimeout(timeout); + }, [isVisible, currentPath]); + const handleClearSelection = useCallback(async () => { + // Save cursor position before clearing + const currentPos = editorRef.current?.getCursorPosition(); + if ( + currentPos !== null && + currentPos !== undefined && + currentPathRef.current + ) { + cursorPositionsRef.current.set(currentPathRef.current, currentPos); + } + try { await flushPendingSave(clientRef.current, { saveTimeoutRef, diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 8c98672633..5795ee2c07 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -397,20 +397,19 @@ export function SiteInfoPanel({ )} hidden={tab.name !== 'files'} > - {tab.name === 'files' && ( - - Loading file browser... -
- } - > - - - )} + + Loading file browser... + + } + > + +
Date: Thu, 23 Oct 2025 23:22:37 +0200 Subject: [PATCH 12/31] Reopen the last active tab when Playground is reopened --- .../site-manager/site-info-panel/index.tsx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 5795ee2c07..4415033bcd 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -33,12 +33,38 @@ import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; import { removeSite } from '../../../lib/state/redux/slice-sites'; import { BlueprintReflection } from '@wp-playground/blueprints'; -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useState, useEffect } from 'react'; const SiteFileBrowser = lazy(() => import('../site-file-browser').then((m) => ({ default: m.SiteFileBrowser })) ); +const LAST_TAB_STORAGE_KEY = 'playground-site-last-tabs'; + +function getSiteLastTab(siteSlug: string): string | null { + try { + const stored = localStorage.getItem(LAST_TAB_STORAGE_KEY); + if (!stored) { + return null; + } + const tabs = JSON.parse(stored); + return tabs[siteSlug] || null; + } catch { + return null; + } +} + +function setSiteLastTab(siteSlug: string, tabName: string): void { + try { + const stored = localStorage.getItem(LAST_TAB_STORAGE_KEY); + const tabs = stored ? JSON.parse(stored) : {}; + tabs[siteSlug] = tabName; + localStorage.setItem(LAST_TAB_STORAGE_KEY, JSON.stringify(tabs)); + } catch { + // Silently fail if localStorage is not available + } +} + export function SiteInfoPanel({ className, site, @@ -52,6 +78,18 @@ export function SiteInfoPanel({ }) { const offline = useAppSelector((state) => state.ui.offline); const dispatch = useAppDispatch(); + + // Load the last active tab for this site + const [initialTabName] = useState(() => { + const lastTab = getSiteLastTab(site.slug); + return lastTab || 'settings'; + }); + + // Save the tab when it changes + const handleTabSelect = (tabName: string) => { + setSiteLastTab(site.slug, tabName); + }; + const removeSiteAndCloseMenu = async (onClose: () => void) => { // TODO: Replace with HTML-based dialog const proceed = window.confirm( @@ -348,6 +386,8 @@ export function SiteInfoPanel({ Date: Thu, 23 Oct 2025 23:28:34 +0200 Subject: [PATCH 13/31] Lint, typecheck --- .../site-manager/site-file-browser/code-editor.tsx | 8 +++----- .../site-file-browser/file-explorer-sidebar.tsx | 3 ++- .../components/site-manager/site-file-browser/index.tsx | 7 ++++--- .../src/components/site-manager/site-info-panel/index.tsx | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx index 6c991e6909..f6f2cd8388 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -157,11 +157,9 @@ export const CodeEditor = forwardRef( }, ref ) { - const editorRootRef = - containerRef ?? - (useRef( - null - ) as MutableRefObject); + const editorRootRef = useRef( + null + ) as MutableRefObject; const viewRef = useRef(null); const languageCompartmentRef = useRef(new Compartment()); const editableCompartmentRef = useRef(new Compartment()); diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx index b59569f983..d28b88592c 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx @@ -18,6 +18,7 @@ import { MAX_INLINE_FILE_BYTES, WORDPRESS_ROOT_DIR, } from './constants'; +import { logger } from '@php-wasm/logger'; const normalizeFsPath = (path: string) => { if (!path) { @@ -141,7 +142,7 @@ export function FileExplorerSidebar({ const text = new TextDecoder('utf-8').decode(data); await onFileOpened(path, text, shouldFocus); } catch (error) { - console.error('Could not open file', error); + logger.error('Could not open file', error); await onShowMessage('Could not open file.'); } }; diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx index 197f3a80c7..7fedb25b8d 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx @@ -16,6 +16,7 @@ import { FileExplorerSidebar } from './file-explorer-sidebar'; import { CodeEditor, type CodeEditorHandle } from './code-editor'; import styles from './style.module.css'; import { DEFAULT_WORKSPACE_DIR, WORDPRESS_ROOT_DIR } from './constants'; +import { logger } from '@php-wasm/logger'; const SAVE_DEBOUNCE_MS = 1500; @@ -144,7 +145,7 @@ export function SiteFileBrowser({ setSaveState('saved'); setSaveError(null); } catch (error) { - console.error('Failed to save file', error); + logger.error('Failed to save file', error); setSaveState('error'); setSaveError('Could not save changes. Try again.'); } @@ -197,7 +198,7 @@ export function SiteFileBrowser({ } } catch (error) { // Silently fail - wp-config.php may not exist or may not be readable - console.debug('Could not auto-open wp-config.php:', error); + logger.debug('Could not auto-open wp-config.php:', error); } finally { hasAutoOpenedRef.current = true; } @@ -543,7 +544,7 @@ async function flushPendingSave( setSaveState('saved'); setSaveError(null); } catch (error) { - console.error('Failed to save file', error); + logger.error('Failed to save file', error); setSaveState('error'); setSaveError('Could not save changes. Try again.'); throw error; diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index 4415033bcd..b68d86b72f 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -33,7 +33,7 @@ import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; import { removeSite } from '../../../lib/state/redux/slice-sites'; import { BlueprintReflection } from '@wp-playground/blueprints'; -import { lazy, Suspense, useState, useEffect } from 'react'; +import { lazy, Suspense, useState } from 'react'; const SiteFileBrowser = lazy(() => import('../site-file-browser').then((m) => ({ default: m.SiteFileBrowser })) From 41707a592ba3368896a9aeadfa1d277ae315e85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 23 Oct 2025 23:29:37 +0200 Subject: [PATCH 14/31] remove dev artifacts --- packages/playground/components/e2e/playwright.config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/playground/components/e2e/playwright.config.ts b/packages/playground/components/e2e/playwright.config.ts index 8b5418402f..ef7143a7dc 100644 --- a/packages/playground/components/e2e/playwright.config.ts +++ b/packages/playground/components/e2e/playwright.config.ts @@ -13,7 +13,7 @@ export const playwrightConfig: PlaywrightTestConfig = { fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, - retries: 0, + retries: 3, workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ @@ -28,8 +28,7 @@ export const playwrightConfig: PlaywrightTestConfig = { navigationTimeout: 120000, }, - // timeout: 300000, - timeout: 30000, + timeout: 300000, expect: { timeout: 60000 }, /* Configure projects for major browsers */ From 43ddc831d502f39374bb846a319ac23b57405596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 23 Oct 2025 23:54:21 +0200 Subject: [PATCH 15/31] Load non-php language extensions asynchronously --- .../site-file-browser/code-editor.tsx | 104 +++++++++++++++--- 1 file changed, 90 insertions(+), 14 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx index f6f2cd8388..379d324f98 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -41,44 +41,97 @@ import { foldKeymap, syntaxHighlighting, defaultHighlightStyle, + type LanguageSupport, } from '@codemirror/language'; import { php } from '@codemirror/lang-php'; -import { css } from '@codemirror/lang-css'; -import { javascript } from '@codemirror/lang-javascript'; -import { json } from '@codemirror/lang-json'; -import { html } from '@codemirror/lang-html'; -import { markdown } from '@codemirror/lang-markdown'; + +// Cache for loaded language extensions +const languageExtensionCache = new Map(); + +// Async language loaders +const languageLoaders = { + css: () => import('@codemirror/lang-css').then((m) => m.css()), + javascript: (options: { jsx: boolean; typescript: boolean }) => + import('@codemirror/lang-javascript').then((m) => + m.javascript(options) + ), + json: () => import('@codemirror/lang-json').then((m) => m.json()), + html: () => import('@codemirror/lang-html').then((m) => m.html()), + markdown: () => + import('@codemirror/lang-markdown').then((m) => m.markdown()), +}; const getLanguageExtension = (filePath: string | null) => { + // Always return PHP synchronously as the default if (!filePath) { return php(); } const extension = filePath.split('.').pop()?.toLowerCase(); + // PHP is already loaded, return it synchronously + if (!extension || extension === 'php') { + return php(); + } + + // For non-PHP files, return PHP as default and load the correct one asynchronously + return php(); +}; + +// Load the appropriate language extension asynchronously +const loadLanguageExtension = async ( + filePath: string | null +): Promise => { + if (!filePath) { + return php(); + } + + const extension = filePath.split('.').pop()?.toLowerCase(); + + if (!extension || extension === 'php') { + return php(); + } + + // Check cache first + const cacheKey = filePath; + if (languageExtensionCache.has(cacheKey)) { + return languageExtensionCache.get(cacheKey)!; + } + + // Load the appropriate extension + let langSupport: LanguageSupport; + switch (extension) { case 'css': - return css(); + langSupport = await languageLoaders.css(); + break; case 'js': case 'jsx': case 'ts': case 'tsx': - return javascript({ + langSupport = await languageLoaders.javascript({ jsx: extension === 'jsx' || extension === 'tsx', typescript: extension === 'ts' || extension === 'tsx', }); + break; case 'json': - return json(); + langSupport = await languageLoaders.json(); + break; case 'html': case 'htm': - return html(); + langSupport = await languageLoaders.html(); + break; case 'md': case 'markdown': - return markdown(); - case 'php': + langSupport = await languageLoaders.markdown(); + break; default: - return php(); + langSupport = php(); } + + // Cache it + languageExtensionCache.set(cacheKey, langSupport); + return langSupport; }; // Plugin to handle clicks below the content and move cursor to end of document @@ -292,13 +345,36 @@ export const CodeEditor = forwardRef( if (!view) { return; } - const languageExtension = getLanguageExtension(currentPath); + + // First, apply PHP immediately (non-blocking) + const defaultExtension = getLanguageExtension(currentPath); view.dispatch({ effects: languageCompartmentRef.current.reconfigure( - languageExtension + defaultExtension ), }); + + // Then load the correct extension asynchronously + let cancelled = false; + void loadLanguageExtension(currentPath).then((langSupport) => { + if (cancelled || !viewRef.current) { + return; + } + // Only reconfigure if it's different from the default + if (langSupport !== defaultExtension) { + viewRef.current.dispatch({ + effects: + languageCompartmentRef.current.reconfigure( + langSupport + ), + }); + } + }); + + return () => { + cancelled = true; + }; }, [currentPath]); useEffect(() => { From 97c83a646f5d57e33b51d8a014b4d07764d78909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 00:27:45 +0200 Subject: [PATCH 16/31] Simplify logic, improve comments --- .../components/src/FilePickerTree/index.tsx | 14 +-- .../site-file-browser/code-editor.tsx | 106 ++++++++---------- 2 files changed, 51 insertions(+), 69 deletions(-) diff --git a/packages/playground/components/src/FilePickerTree/index.tsx b/packages/playground/components/src/FilePickerTree/index.tsx index 202410b4eb..210ae28277 100644 --- a/packages/playground/components/src/FilePickerTree/index.tsx +++ b/packages/playground/components/src/FilePickerTree/index.tsx @@ -655,9 +655,12 @@ export const FilePickerTree = forwardRef< }; }, []); + /** + * Wait for the context menu (right-click menu) to render, then focus the + * first menu item (e.g. "Rename"). This is similar to how VS Code works. + */ useEffect(() => { if (contextMenu) { - // Wait for the Popover to render, then focus the first menu item setTimeout(() => { const firstMenuItem = document.querySelector( '[role="menu"] [role="menuitem"]' @@ -1198,7 +1201,7 @@ export const FilePickerTree = forwardRef< setTimeout(() => { setFocusedPath(candidateNormalized); focusDomNode(candidateNormalized); - // If this was a newly created file, open it in the editor + // We've just saved a new file, immediately open it in the code editor. if (wasFileCreate && onDoubleClickFile) { onDoubleClickFile(candidateNormalized); } @@ -1618,7 +1621,7 @@ const NodeRow: React.FC<{ }; const handleClick = () => { - // For folders, always toggle immediately + // Folders – collapse or expand immediately if (node.type === 'folder') { toggleOpen(); selectPath(path); @@ -1626,21 +1629,18 @@ const NodeRow: React.FC<{ return; } - // For files, check if this is a double-click + // Files – vary the behavior between single clicks and double clicks if (clickTimeoutRef.current !== null) { // This is a double-click if (typeof window !== 'undefined') { window.clearTimeout(clickTimeoutRef.current); } clickTimeoutRef.current = null; - // Immediately update selection state (without notifying) selectPath(path, false); focusPath(path); - // Call the double-click handler if provided if (onDoubleClickFile) { onDoubleClickFile(path); } else { - // Fallback to normal behavior if no handler provided selectPath(path, true); } } else { diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx index 379d324f98..b43d96040a 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -45,40 +45,17 @@ import { } from '@codemirror/language'; import { php } from '@codemirror/lang-php'; -// Cache for loaded language extensions +/** + * Async language loaders. + * + * Language extensions can be heavy, so we only load the PHP extension + * optimistically. The other extensions are only loaded once the user opens a + * file with a relevant extension. The content of the file shows up in the + * code editor immediately without any highlighting, and then, once the extension + * is loaded, the highlighting is applied. + */ const languageExtensionCache = new Map(); -// Async language loaders -const languageLoaders = { - css: () => import('@codemirror/lang-css').then((m) => m.css()), - javascript: (options: { jsx: boolean; typescript: boolean }) => - import('@codemirror/lang-javascript').then((m) => - m.javascript(options) - ), - json: () => import('@codemirror/lang-json').then((m) => m.json()), - html: () => import('@codemirror/lang-html').then((m) => m.html()), - markdown: () => - import('@codemirror/lang-markdown').then((m) => m.markdown()), -}; - -const getLanguageExtension = (filePath: string | null) => { - // Always return PHP synchronously as the default - if (!filePath) { - return php(); - } - - const extension = filePath.split('.').pop()?.toLowerCase(); - - // PHP is already loaded, return it synchronously - if (!extension || extension === 'php') { - return php(); - } - - // For non-PHP files, return PHP as default and load the correct one asynchronously - return php(); -}; - -// Load the appropriate language extension asynchronously const loadLanguageExtension = async ( filePath: string | null ): Promise => { @@ -103,27 +80,38 @@ const loadLanguageExtension = async ( switch (extension) { case 'css': - langSupport = await languageLoaders.css(); + langSupport = await import('@codemirror/lang-css').then((m) => + m.css() + ); break; case 'js': case 'jsx': case 'ts': case 'tsx': - langSupport = await languageLoaders.javascript({ - jsx: extension === 'jsx' || extension === 'tsx', - typescript: extension === 'ts' || extension === 'tsx', - }); + langSupport = await import('@codemirror/lang-javascript').then( + (m) => + m.javascript({ + jsx: extension === 'jsx' || extension === 'tsx', + typescript: extension === 'ts' || extension === 'tsx', + }) + ); break; case 'json': - langSupport = await languageLoaders.json(); + langSupport = await import('@codemirror/lang-json').then((m) => + m.json() + ); break; case 'html': case 'htm': - langSupport = await languageLoaders.html(); + langSupport = await import('@codemirror/lang-html').then((m) => + m.html() + ); break; case 'md': case 'markdown': - langSupport = await languageLoaders.markdown(); + langSupport = await import('@codemirror/lang-markdown').then((m) => + m.markdown() + ); break; default: langSupport = php(); @@ -194,7 +182,6 @@ export type CodeEditorProps = { className?: string; onSaveShortcut?: () => void; readOnly?: boolean; - containerRef?: MutableRefObject; }; export const CodeEditor = forwardRef( @@ -206,7 +193,6 @@ export const CodeEditor = forwardRef( className, onSaveShortcut, readOnly = false, - containerRef, }, ref ) { @@ -271,9 +257,7 @@ export const CodeEditor = forwardRef( rectangularSelection(), crosshairCursor(), clickBelowContentExtension, - languageCompartmentRef.current.of( - getLanguageExtension(currentPath) - ), + languageCompartmentRef.current.of(php()), editableCompartmentRef.current.of( EditorView.editable.of(!readOnly) ), @@ -346,14 +330,17 @@ export const CodeEditor = forwardRef( return; } - // First, apply PHP immediately (non-blocking) - const defaultExtension = getLanguageExtension(currentPath); - view.dispatch({ - effects: - languageCompartmentRef.current.reconfigure( - defaultExtension - ), - }); + // Check if it's a PHP file + const extension = currentPath?.split('.').pop()?.toLowerCase(); + const isPhpFile = !extension || extension === 'php'; + + // For PHP files, apply PHP syntax immediately (non-blocking) + // For other files, start with no extension and let async loading handle it + if (isPhpFile) { + view.dispatch({ + effects: languageCompartmentRef.current.reconfigure(php()), + }); + } // Then load the correct extension asynchronously let cancelled = false; @@ -361,15 +348,10 @@ export const CodeEditor = forwardRef( if (cancelled || !viewRef.current) { return; } - // Only reconfigure if it's different from the default - if (langSupport !== defaultExtension) { - viewRef.current.dispatch({ - effects: - languageCompartmentRef.current.reconfigure( - langSupport - ), - }); - } + viewRef.current.dispatch({ + effects: + languageCompartmentRef.current.reconfigure(langSupport), + }); }); return () => { From 9712e4b65e0cc8f5f7bbb90cf135cc98b2bf3af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 00:39:13 +0200 Subject: [PATCH 17/31] Simplify logic, remove constants file, reuse documentRoot, reuse WP icons --- .../site-file-browser/constants.ts | 3 - .../file-explorer-sidebar.tsx | 47 ++---------- .../site-manager/site-file-browser/index.tsx | 74 +++++++++++-------- .../site-manager/site-info-panel/index.tsx | 30 ++++++-- 4 files changed, 74 insertions(+), 80 deletions(-) delete mode 100644 packages/playground/website/src/components/site-manager/site-file-browser/constants.ts diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/constants.ts b/packages/playground/website/src/components/site-manager/site-file-browser/constants.ts deleted file mode 100644 index 670bce37d5..0000000000 --- a/packages/playground/website/src/components/site-manager/site-file-browser/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const DEFAULT_WORKSPACE_DIR = '/wordpress/workspace'; -export const WORDPRESS_ROOT_DIR = '/wordpress'; -export const MAX_INLINE_FILE_BYTES = 1024 * 1024; // 1MB diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx index d28b88592c..c28537fd13 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx @@ -1,5 +1,4 @@ import { - createElement, useMemo, useRef, useState, @@ -7,19 +6,17 @@ import { type SetStateAction, } from 'react'; import { Icon } from '@wordpress/components'; +import { file as folderIcon, page as fileIcon } from '@wordpress/icons'; import styles from './file-explorer.module.css'; import { FilePickerTree, type AsyncWritableFilesystem, type FilePickerTreeHandle, } from '@wp-playground/components'; -import { - DEFAULT_WORKSPACE_DIR, - MAX_INLINE_FILE_BYTES, - WORDPRESS_ROOT_DIR, -} from './constants'; import { logger } from '@php-wasm/logger'; +export const MAX_INLINE_FILE_BYTES = 1024 * 1024; // 1MB + const normalizeFsPath = (path: string) => { if (!path) { return '/'; @@ -78,6 +75,7 @@ export type FileExplorerSidebarProps = { ) => Promise | void; onSelectionCleared: () => Promise | void; onShowMessage: (message: string) => Promise | void; + documentRoot: string; }; export function FileExplorerSidebar({ @@ -88,6 +86,7 @@ export function FileExplorerSidebar({ onFileOpened, onSelectionCleared, onShowMessage, + documentRoot, }: FileExplorerSidebarProps) { const treeRef = useRef(null); @@ -95,16 +94,15 @@ export function FileExplorerSidebar({ return normalizeFsPath( currentPath ? dirnameSafe(currentPath) - : selectedDirPath ?? DEFAULT_WORKSPACE_DIR + : selectedDirPath ?? documentRoot ); // Prevent tree from jumping unexpectedly when selectedDirPath changes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPath]); + }, [currentPath, documentRoot]); const [lastSelectedPath, setLastSelectedPath] = useState( null ); - const root = WORDPRESS_ROOT_DIR; // Helper function to handle file opening const handleOpenFile = async (path: string, shouldFocus: boolean) => { @@ -147,35 +145,6 @@ export function FileExplorerSidebar({ } }; - const folderIcon = createElement( - 'svg', - { - xmlns: 'http://www.w3.org/2000/svg', - viewBox: '0 0 24 24', - width: 16, - height: 16, - 'aria-hidden': true, - }, - createElement('path', { - d: 'M3 5.5A1.5 1.5 0 0 1 4.5 4h5.086a1.5 1.5 0 0 1 1.06.44l1.914 1.914a1.5 1.5 0 0 0 1.06.44H19.5A1.5 1.5 0 0 1 21 8.294V18.5A1.5 1.5 0 0 1 19.5 20h-15A1.5 1.5 0 0 1 3 18.5z', - fill: 'currentColor', - }) - ); - const fileIcon = createElement( - 'svg', - { - xmlns: 'http://www.w3.org/2000/svg', - viewBox: '0 0 24 24', - width: 16, - height: 16, - 'aria-hidden': true, - }, - createElement('path', { - d: 'M6.5 3A1.5 1.5 0 0 0 5 4.5v15A1.5 1.5 0 0 0 6.5 21h11a1.5 1.5 0 0 0 1.5-1.5V9.914a1.5 1.5 0 0 0-.44-1.06l-5.914-5.914A1.5 1.5 0 0 0 11.586 2H6.5zM12 3.914 18.086 10H12z', - fill: 'currentColor', - }) - ); - return (
@@ -219,7 +188,7 @@ export function FileExplorerSidebar({ { setLastSelectedPath(path); diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx index 7fedb25b8d..162645c016 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx @@ -15,30 +15,39 @@ import type { PlaygroundClient } from '@wp-playground/remote'; import { FileExplorerSidebar } from './file-explorer-sidebar'; import { CodeEditor, type CodeEditorHandle } from './code-editor'; import styles from './style.module.css'; -import { DEFAULT_WORKSPACE_DIR, WORDPRESS_ROOT_DIR } from './constants'; import { logger } from '@php-wasm/logger'; const SAVE_DEBOUNCE_MS = 1500; -type SaveState = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; +const SaveState = { + IDLE: 'idle', + PENDING: 'pending', + SAVING: 'saving', + SAVED: 'saved', + ERROR: 'error', +} as const; + +type SaveState = (typeof SaveState)[keyof typeof SaveState]; export function SiteFileBrowser({ site, isVisible = true, + documentRoot, }: { site: SiteInfo; isVisible?: boolean; + documentRoot: string; }) { const client = usePlaygroundClient(site.slug); const filesystem = useFilesystem(client); const [selectedDirPath, setSelectedDirPath] = useState( - DEFAULT_WORKSPACE_DIR + `${documentRoot}/workspace` ); const [currentPath, setCurrentPath] = useState(null); const [code, setCode] = useState(''); const [readOnly, setReadOnly] = useState(true); - const [saveState, setSaveState] = useState('idle'); + const [saveState, setSaveState] = useState(SaveState.IDLE); const [saveError, setSaveError] = useState(null); const [showExplorerOnMobile, setShowExplorerOnMobile] = useState(false); @@ -96,22 +105,22 @@ export function SiteFileBrowser({ setCode(''); setCurrentPath(null); setReadOnly(true); - setSaveState('idle'); + setSaveState(SaveState.IDLE); setSaveError(null); setShowExplorerOnMobile(false); } }, [client]); useEffect(() => { - setSelectedDirPath(DEFAULT_WORKSPACE_DIR); + setSelectedDirPath(`${documentRoot}/workspace`); setCurrentPath(null); setCode(''); setReadOnly(true); - setSaveState('idle'); + setSaveState(SaveState.IDLE); setSaveError(null); skipNextSaveRef.current = true; hasAutoOpenedRef.current = false; - }, [site.slug]); + }, [site.slug, documentRoot]); useEffect(() => { const activeClient = clientRef.current; @@ -121,7 +130,7 @@ export function SiteFileBrowser({ saveTimeoutRef.current = null; } if (!currentPath) { - setSaveState('idle'); + setSaveState(SaveState.IDLE); } return; } @@ -133,20 +142,20 @@ export function SiteFileBrowser({ window.clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } - setSaveState('pending'); + setSaveState(SaveState.PENDING); const timeout = window.setTimeout(async () => { saveTimeoutRef.current = null; - setSaveState('saving'); + setSaveState(SaveState.SAVING); try { await activeClient.writeFile( currentPathRef.current as string, codeRef.current ); - setSaveState('saved'); + setSaveState(SaveState.SAVED); setSaveError(null); } catch (error) { logger.error('Failed to save file', error); - setSaveState('error'); + setSaveState(SaveState.ERROR); setSaveError('Could not save changes. Try again.'); } }, SAVE_DEBOUNCE_MS); @@ -161,12 +170,12 @@ export function SiteFileBrowser({ }, [code, currentPath]); useEffect(() => { - if (saveState !== 'saved') { + if (saveState !== SaveState.SAVED) { return; } const timeout = window.setTimeout(() => { setSaveState((previous) => - previous === 'saved' ? 'idle' : previous + previous === SaveState.SAVED ? SaveState.IDLE : previous ); }, 2000); return () => window.clearTimeout(timeout); @@ -178,7 +187,7 @@ export function SiteFileBrowser({ return; } - const wpConfigPath = `${WORDPRESS_ROOT_DIR}/wp-config.php`; + const wpConfigPath = `${documentRoot}/wp-config.php`; const tryAutoOpen = async () => { try { @@ -189,7 +198,7 @@ export function SiteFileBrowser({ setCurrentPath(wpConfigPath); setCode(content); setReadOnly(false); - setSaveState('idle'); + setSaveState(SaveState.IDLE); setSaveError(null); // Focus the editor after opening setTimeout(() => { @@ -205,7 +214,7 @@ export function SiteFileBrowser({ }; void tryAutoOpen(); - }, [client]); + }, [client, documentRoot]); const handleFileOpened = useCallback( async (path: string, content: string, shouldFocus = true) => { @@ -237,7 +246,7 @@ export function SiteFileBrowser({ setCurrentPath(path); setCode(content); setReadOnly(false); - setSaveState('idle'); + setSaveState(SaveState.IDLE); setSaveError(null); setShowExplorerOnMobile(false); @@ -328,7 +337,7 @@ export function SiteFileBrowser({ setCurrentPath(null); setCode(''); setReadOnly(true); - setSaveState('idle'); + setSaveState(SaveState.IDLE); setSaveError(null); }, []); @@ -348,7 +357,7 @@ export function SiteFileBrowser({ setCurrentPath(null); setCode(message); setReadOnly(true); - setSaveState('idle'); + setSaveState(SaveState.IDLE); setSaveError(null); }, []); @@ -395,6 +404,7 @@ export function SiteFileBrowser({ onFileOpened={handleFileOpened} onSelectionCleared={handleClearSelection} onShowMessage={handleShowMessage} + documentRoot={documentRoot} />
@@ -418,7 +428,7 @@ export function SiteFileBrowser({ > {currentPath?.length ? currentPath - : `Browse files under ${WORDPRESS_ROOT_DIR}`} + : `Browse files under ${documentRoot}`}
import('../site-file-browser').then((m) => ({ default: m.SiteFileBrowser })) @@ -85,6 +85,9 @@ export function SiteInfoPanel({ return lastTab || 'settings'; }); + // Resolve documentRoot from playground client + const [documentRoot, setDocumentRoot] = useState(null); + // Save the tab when it changes const handleTabSelect = (tabName: string) => { setSiteLastTab(site.slug, tabName); @@ -106,6 +109,18 @@ export function SiteInfoPanel({ ); const playground = clientInfo?.client; + // Resolve documentRoot from playground + useEffect(() => { + if (!playground) { + setDocumentRoot(null); + return; + } + + void playground.documentRoot.then((root) => { + setDocumentRoot(root); + }); + }, [playground]); + function navigateTo(path: string) { if (siteViewHidden) { // Close the site manager so the site view is visible. @@ -444,11 +459,14 @@ export function SiteInfoPanel({
} > - + {documentRoot && ( + + )}
Date: Fri, 24 Oct 2025 01:03:53 +0200 Subject: [PATCH 18/31] Display a clickable download link when editing binary files --- .../file-explorer-sidebar.tsx | 32 ++++--- .../site-manager/site-file-browser/index.tsx | 85 ++++++++++++------- .../site-file-browser/style.module.css | 35 ++++++++ 3 files changed, 112 insertions(+), 40 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx index c28537fd13..7f7dec8627 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx @@ -42,12 +42,16 @@ const dirnameSafe = (path: string) => { }; const isProbablyTextBuffer = (buffer: Uint8Array) => { + // Assume that anything with a null byte in the first 4096 bytes is binary. + // This isn't a perfect test, but it catches a lot of binary files. const len = buffer.byteLength; for (let i = 0; i < Math.min(len, 4096); i++) { if (buffer[i] === 0) { return false; } } + + // Next, try to decode the buffer as UTF-8. If it fails, it's probably binary. try { new TextDecoder('utf-8', { fatal: true }).decode(buffer); return true; @@ -74,7 +78,7 @@ export type FileExplorerSidebarProps = { shouldFocus?: boolean ) => Promise | void; onSelectionCleared: () => Promise | void; - onShowMessage: (message: string) => Promise | void; + onShowMessage: (message: string | JSX.Element) => Promise | void; documentRoot: string; }; @@ -115,11 +119,14 @@ export function FileExplorerSidebar({ path.split('/').pop() || 'download' ); await onShowMessage( - [ - 'File too large to open (>1MB).', - `Download: ${url}`, - `Filename: ${filename}`, - ].join('\n') + <> +

File too large to open (>1MB).

+

+ + Download {filename} + +

+ ); return; } @@ -129,11 +136,14 @@ export function FileExplorerSidebar({ path.split('/').pop() || 'download' ); await onShowMessage( - [ - 'Binary file. Download instead:', - `Download: ${url}`, - `Filename: ${filename}`, - ].join('\n') + <> +

Binary file. Cannot be edited.

+

+ + Download {filename} + +

+ ); return; } diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx index 162645c016..ca5df80d1b 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx @@ -51,6 +51,9 @@ export function SiteFileBrowser({ const [saveError, setSaveError] = useState(null); const [showExplorerOnMobile, setShowExplorerOnMobile] = useState(false); + const [messageContent, setMessageContent] = useState< + string | JSX.Element | null + >(null); const editorRef = useRef(null); const saveTimeoutRef = useRef(null); @@ -108,6 +111,7 @@ export function SiteFileBrowser({ setSaveState(SaveState.IDLE); setSaveError(null); setShowExplorerOnMobile(false); + setMessageContent(null); } }, [client]); @@ -119,6 +123,7 @@ export function SiteFileBrowser({ setSaveState(SaveState.IDLE); setSaveError(null); skipNextSaveRef.current = true; + setMessageContent(null); hasAutoOpenedRef.current = false; }, [site.slug, documentRoot]); @@ -245,6 +250,7 @@ export function SiteFileBrowser({ skipNextSaveRef.current = true; setCurrentPath(path); setCode(content); + setMessageContent(null); setReadOnly(false); setSaveState(SaveState.IDLE); setSaveError(null); @@ -336,30 +342,45 @@ export function SiteFileBrowser({ skipNextSaveRef.current = true; setCurrentPath(null); setCode(''); + setMessageContent(null); setReadOnly(true); setSaveState(SaveState.IDLE); setSaveError(null); }, []); - const handleShowMessage = useCallback(async (message: string) => { - try { - await flushPendingSave(clientRef.current, { - saveTimeoutRef, - currentPathRef, - codeRef, - setSaveState, - setSaveError, - }); - } catch { - /* noop */ - } - skipNextSaveRef.current = true; - setCurrentPath(null); - setCode(message); - setReadOnly(true); - setSaveState(SaveState.IDLE); - setSaveError(null); - }, []); + const handleShowMessage = useCallback( + async (message: string | JSX.Element) => { + try { + await flushPendingSave(clientRef.current, { + saveTimeoutRef, + currentPathRef, + codeRef, + setSaveState, + setSaveError, + }); + } catch { + /* noop */ + } + skipNextSaveRef.current = true; + setCurrentPath(null); + + // If it's a string, show it in the code editor + // If it's JSX, show it in a separate message area + if (typeof message === 'string') { + setCode(message); + setMessageContent(null); + } else { + setCode(''); + setMessageContent(message); + } + + setReadOnly(true); + setSaveState(SaveState.IDLE); + setSaveError(null); + setShowExplorerOnMobile(false); + }, + [] + ); const handleManualSave = useCallback(() => { void flushPendingSave(clientRef.current, { @@ -446,16 +467,22 @@ export function SiteFileBrowser({
) : null} - {currentPath || code ? ( - + {currentPath || code || messageContent ? ( + messageContent ? ( +
+ {messageContent} +
+ ) : ( + + ) ) : (
Select a file to view or edit its contents. diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css b/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css index e0f744288d..c93d8e3aad 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css +++ b/packages/playground/website/src/components/site-manager/site-file-browser/style.module.css @@ -105,6 +105,41 @@ background: #ffffff; } +.message-area { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + padding: 16px 20px; + background: #ffffff; + overflow: auto; + font-family: 'SFMono-Regular', ui-monospace, SFMono-Regular, Menlo, Monaco, + Consolas, 'Liberation Mono', 'Courier New', monospace; + font-size: 13px; + line-height: 1.6; +} + +.message-area p { + margin: 0; + padding: 0; + display: block; +} + +.message-area p + p { + margin-top: 4px; +} + +.message-area a { + color: #2271b1; + text-decoration: underline; + cursor: pointer; +} + +.message-area a:hover { + color: #135e96; +} + .mobile-toggle { display: none; } From cd5a1547db600809acbc8273c3feb53c7d8a34e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 01:05:30 +0200 Subject: [PATCH 19/31] Reuse php-wasm/util fs helpers instead of reimplementing them --- .../file-explorer-sidebar.tsx | 29 ++----------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx index 7f7dec8627..3ebbc5a790 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx @@ -14,33 +14,10 @@ import { type FilePickerTreeHandle, } from '@wp-playground/components'; import { logger } from '@php-wasm/logger'; +import { dirname, normalizePath } from '@php-wasm/util'; export const MAX_INLINE_FILE_BYTES = 1024 * 1024; // 1MB -const normalizeFsPath = (path: string) => { - if (!path) { - return '/'; - } - let normalized = path.replace(/\\+/g, '/'); - if (!normalized.startsWith('/')) { - normalized = `/${normalized}`; - } - normalized = normalized.replace(/\/{2,}/g, '/'); - if (normalized.length > 1 && normalized.endsWith('/')) { - normalized = normalized.slice(0, -1); - } - return normalized || '/'; -}; - -const dirnameSafe = (path: string) => { - const normalized = normalizeFsPath(path); - if (normalized === '/') { - return '/'; - } - const index = normalized.lastIndexOf('/'); - return index <= 0 ? '/' : normalized.slice(0, index); -}; - const isProbablyTextBuffer = (buffer: Uint8Array) => { // Assume that anything with a null byte in the first 4096 bytes is binary. // This isn't a perfect test, but it catches a lot of binary files. @@ -95,9 +72,9 @@ export function FileExplorerSidebar({ const treeRef = useRef(null); const treeInitialPath = useMemo(() => { - return normalizeFsPath( + return normalizePath( currentPath - ? dirnameSafe(currentPath) + ? dirname(normalizePath(currentPath)) : selectedDirPath ?? documentRoot ); // Prevent tree from jumping unexpectedly when selectedDirPath changes. From 402d7f5e6e937dd3c558fc34154d50af09a8e46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 01:06:19 +0200 Subject: [PATCH 20/31] Rename isProbablyTextBuffer to seemsLikeBinary --- .../site-file-browser/file-explorer-sidebar.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx index 3ebbc5a790..58bc3c701f 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx @@ -18,22 +18,22 @@ import { dirname, normalizePath } from '@php-wasm/util'; export const MAX_INLINE_FILE_BYTES = 1024 * 1024; // 1MB -const isProbablyTextBuffer = (buffer: Uint8Array) => { +const seemsLikeBinary = (buffer: Uint8Array) => { // Assume that anything with a null byte in the first 4096 bytes is binary. // This isn't a perfect test, but it catches a lot of binary files. const len = buffer.byteLength; for (let i = 0; i < Math.min(len, 4096); i++) { if (buffer[i] === 0) { - return false; + return true; } } // Next, try to decode the buffer as UTF-8. If it fails, it's probably binary. try { new TextDecoder('utf-8', { fatal: true }).decode(buffer); - return true; - } catch { return false; + } catch { + return true; } }; @@ -85,7 +85,6 @@ export function FileExplorerSidebar({ null ); - // Helper function to handle file opening const handleOpenFile = async (path: string, shouldFocus: boolean) => { try { const data = await filesystem.readFileAsBuffer(path); @@ -107,7 +106,7 @@ export function FileExplorerSidebar({ ); return; } - if (!isProbablyTextBuffer(data)) { + if (seemsLikeBinary(data)) { const { url, filename } = createDownloadUrl( data, path.split('/').pop() || 'download' From bb80aa7d3d35ed22ac43f636994a7d9b97636a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 02:17:35 +0200 Subject: [PATCH 21/31] Remove 300ms delay when opening a file --- .../components/src/FilePickerTree/index.tsx | 44 +++++++++---------- .../site-file-browser/code-editor.tsx | 7 +++ .../site-manager/site-file-browser/index.tsx | 12 ++++- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/packages/playground/components/src/FilePickerTree/index.tsx b/packages/playground/components/src/FilePickerTree/index.tsx index 210ae28277..30eb1de66c 100644 --- a/packages/playground/components/src/FilePickerTree/index.tsx +++ b/packages/playground/components/src/FilePickerTree/index.tsx @@ -163,6 +163,7 @@ export const FilePickerTree = forwardRef< initialSelectedPath, onSelect = () => {}, onDoubleClickFile, + typeAheadEnabled = true, }, ref ) { @@ -1629,35 +1630,30 @@ const NodeRow: React.FC<{ return; } - // Files – vary the behavior between single clicks and double clicks - if (clickTimeoutRef.current !== null) { - // This is a double-click - if (typeof window !== 'undefined') { - window.clearTimeout(clickTimeoutRef.current); - } - clickTimeoutRef.current = null; - selectPath(path, false); - focusPath(path); + const wasWaitingForDoubleClick = clickTimeoutRef.current !== null; + if (wasWaitingForDoubleClick && typeof window !== 'undefined') { + window.clearTimeout(clickTimeoutRef.current); + } + clickTimeoutRef.current = null; + + if (wasWaitingForDoubleClick) { if (onDoubleClickFile) { onDoubleClickFile(path); } else { selectPath(path, true); } - } else { - // This is the first click, immediately update visual selection - selectPath(path, false); - focusPath(path); - // Wait for possible double-click before opening file - if (typeof window !== 'undefined') { - clickTimeoutRef.current = window.setTimeout(() => { - clickTimeoutRef.current = null; - // Single click confirmed: open file without moving focus - selectPath(path, true); - }, 300); // 300ms window for double-click - } else { - // No window object, execute immediately - selectPath(path, true); - } + return; + } + + // Single click: update selection, keep focus in the tree, and open the file. + selectPath(path, false); + focusPath(path); + selectPath(path, true); + + if (typeof window !== 'undefined') { + clickTimeoutRef.current = window.setTimeout(() => { + clickTimeoutRef.current = null; + }, 300); } }; diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx index b43d96040a..a380ff6b40 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx @@ -171,6 +171,7 @@ const clickBelowContentExtension = ViewPlugin.define( export type CodeEditorHandle = { focus: () => void; + blur: () => void; getCursorPosition: () => number | null; setCursorPosition: (pos: number) => void; }; @@ -209,6 +210,12 @@ export const CodeEditor = forwardRef( focus: () => { viewRef.current?.focus(); }, + blur: () => { + const view = viewRef.current; + if (view) { + (view.contentDOM as HTMLElement).blur(); + } + }, getCursorPosition: () => { if (!viewRef.current) { return null; diff --git a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx index ca5df80d1b..df46fe097e 100644 --- a/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-file-browser/index.tsx @@ -51,6 +51,8 @@ export function SiteFileBrowser({ const [saveError, setSaveError] = useState(null); const [showExplorerOnMobile, setShowExplorerOnMobile] = useState(false); + const [treeTypeAheadEnabled, setTreeTypeAheadEnabled] = + useState(true); const [messageContent, setMessageContent] = useState< string | JSX.Element | null >(null); @@ -264,6 +266,10 @@ export function SiteFileBrowser({ } if (shouldFocus) { editorRef.current?.focus(); + setTreeTypeAheadEnabled(false); + } else { + editorRef.current?.blur(); + setTreeTypeAheadEnabled(true); } }, 50); }, @@ -311,11 +317,13 @@ export function SiteFileBrowser({ if (savedPos !== undefined) { editorRef.current?.setCursorPosition(savedPos); } - editorRef.current?.focus(); + if (!treeTypeAheadEnabled) { + editorRef.current?.focus(); + } }, 100); return () => clearTimeout(timeout); - }, [isVisible, currentPath]); + }, [isVisible, currentPath, treeTypeAheadEnabled]); const handleClearSelection = useCallback(async () => { // Save cursor position before clearing From 2e500e144c885bd426253176efb5e171ebbe439d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 02:19:29 +0200 Subject: [PATCH 22/31] lint --- .../components/src/FilePickerTree/index.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/playground/components/src/FilePickerTree/index.tsx b/packages/playground/components/src/FilePickerTree/index.tsx index 30eb1de66c..de236fad71 100644 --- a/packages/playground/components/src/FilePickerTree/index.tsx +++ b/packages/playground/components/src/FilePickerTree/index.tsx @@ -163,7 +163,6 @@ export const FilePickerTree = forwardRef< initialSelectedPath, onSelect = () => {}, onDoubleClickFile, - typeAheadEnabled = true, }, ref ) { @@ -1508,8 +1507,8 @@ const NodeRow: React.FC<{ if (isRenaming) { setRenameValue(node.name); renameHandledRef.current = false; - if (typeof window !== 'undefined' && window.requestAnimationFrame) { - window.requestAnimationFrame(() => { + if (typeof window !== 'undefined' && requestAnimationFrame) { + requestAnimationFrame(() => { renameInputRef.current?.select(); }); } else { @@ -1631,8 +1630,8 @@ const NodeRow: React.FC<{ } const wasWaitingForDoubleClick = clickTimeoutRef.current !== null; - if (wasWaitingForDoubleClick && typeof window !== 'undefined') { - window.clearTimeout(clickTimeoutRef.current); + if (wasWaitingForDoubleClick && clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); } clickTimeoutRef.current = null; @@ -1650,11 +1649,9 @@ const NodeRow: React.FC<{ focusPath(path); selectPath(path, true); - if (typeof window !== 'undefined') { - clickTimeoutRef.current = window.setTimeout(() => { - clickTimeoutRef.current = null; - }, 300); - } + clickTimeoutRef.current = window.setTimeout(() => { + clickTimeoutRef.current = null; + }, 300); }; // Cleanup timeout on unmount @@ -1664,7 +1661,7 @@ const NodeRow: React.FC<{ clickTimeoutRef.current !== null && typeof window !== 'undefined' ) { - window.clearTimeout(clickTimeoutRef.current); + clearTimeout(clickTimeoutRef.current); } }; }, []); From ac748f31aded2caf80e10080cf20c4ddcbe46d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 11:44:40 +0200 Subject: [PATCH 23/31] Add Blueprint editor --- .../src/components/blueprint-editor/index.tsx | 1108 +++++++++++++++++ .../blueprint-editor/schema-utils.ts | 516 ++++++++ .../src/components/blueprint-editor/types.ts | 35 + .../site-manager/site-info-panel/index.tsx | 142 ++- .../site-info-panel/style.module.css | 14 + 5 files changed, 1812 insertions(+), 3 deletions(-) create mode 100644 packages/playground/website/src/components/blueprint-editor/index.tsx create mode 100644 packages/playground/website/src/components/blueprint-editor/schema-utils.ts create mode 100644 packages/playground/website/src/components/blueprint-editor/types.ts diff --git a/packages/playground/website/src/components/blueprint-editor/index.tsx b/packages/playground/website/src/components/blueprint-editor/index.tsx new file mode 100644 index 0000000000..7e8187520a --- /dev/null +++ b/packages/playground/website/src/components/blueprint-editor/index.tsx @@ -0,0 +1,1108 @@ +import { + autocompletion, + startCompletion, + closeBrackets, + completionKeymap, + closeBracketsKeymap, + type CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete'; +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from '@codemirror/commands'; +import { json } from '@codemirror/lang-json'; +import { + bracketMatching, + foldGutter, + foldKeymap, + indentOnInput, + indentUnit, + syntaxHighlighting, + defaultHighlightStyle, +} from '@codemirror/language'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { EditorState, type Extension } from '@codemirror/state'; +import { + EditorView, + keymap, + ViewUpdate, + lineNumbers, + highlightActiveLineGutter, + highlightActiveLine, + dropCursor, + rectangularSelection, + crosshairCursor, +} from '@codemirror/view'; +import { useEffect, useRef } from 'react'; +import { + filterSchemaByDiscriminator, + getCurrentContainerType, + getCurrentDiscriminatorValue, + getDiscriminatorValues, + getExistingKeysInCurrentObject, + getJsonPath, + getPropertyNameForValueCompletion, + mergeCompositeSchemas, + resolveSchemaRefs, +} from './schema-utils'; +import type { JSONSchema, JSONSchemaCompletionConfig } from './types'; + +interface JSONSchemaEditorProps { + config?: JSONSchemaCompletionConfig; + className?: string; +} + +const schemaCache = new Map(); + +export function clearSchemaCache(): void { + schemaCache.clear(); +} + +function schemaHasProperty( + schema: JSONSchema | undefined, + property: string, + rootSchema: JSONSchema, + seen = new WeakSet() +): boolean { + if (!schema || typeof schema !== 'object') { + return false; + } + + if (seen.has(schema)) { + return false; + } + + seen.add(schema); + + const resolved = resolveSchemaRefs(schema, rootSchema); + + if (resolved.properties && resolved.properties[property] !== undefined) { + return true; + } + + if ( + resolved.allOf && + resolved.allOf.some((sub) => + schemaHasProperty(sub, property, rootSchema, seen) + ) + ) { + return true; + } + + if ( + resolved.anyOf && + resolved.anyOf.some((sub) => + schemaHasProperty(sub, property, rootSchema, seen) + ) + ) { + return true; + } + + if ( + resolved.oneOf && + resolved.oneOf.some((sub) => + schemaHasProperty(sub, property, rootSchema, seen) + ) + ) { + return true; + } + + return false; +} + +function collectConstValuesFromSchema( + schema: JSONSchema | undefined, + rootSchema: JSONSchema, + values: Set, + seen = new WeakSet() +): void { + if (!schema || typeof schema !== 'object') { + return; + } + + if (seen.has(schema as object)) { + return; + } + seen.add(schema as object); + + const resolved = resolveSchemaRefs(schema, rootSchema); + if (resolved !== schema) { + collectConstValuesFromSchema(resolved, rootSchema, values, seen); + return; + } + + if (resolved.const !== undefined) { + values.add(String(resolved.const)); + } + + if (resolved.enum) { + for (const entry of resolved.enum) { + values.add(String(entry)); + } + } + + if (Array.isArray(resolved.allOf)) { + for (const subSchema of resolved.allOf) { + collectConstValuesFromSchema(subSchema, rootSchema, values, seen); + } + } + + if (Array.isArray(resolved.anyOf)) { + for (const subSchema of resolved.anyOf) { + collectConstValuesFromSchema(subSchema, rootSchema, values, seen); + } + } + + if (Array.isArray(resolved.oneOf)) { + for (const subSchema of resolved.oneOf) { + collectConstValuesFromSchema(subSchema, rootSchema, values, seen); + } + } +} + +function collectPropertyValuesFromSchema( + schema: JSONSchema | undefined, + property: string, + rootSchema: JSONSchema, + values: Set, + seen = new WeakSet() +): void { + if (!schema || typeof schema !== 'object') { + return; + } + + if (seen.has(schema as object)) { + return; + } + seen.add(schema as object); + + const resolved = resolveSchemaRefs(schema, rootSchema); + if (resolved !== schema) { + collectPropertyValuesFromSchema( + resolved, + property, + rootSchema, + values, + seen + ); + return; + } + + if (resolved.properties && resolved.properties[property]) { + collectConstValuesFromSchema( + resolved.properties[property], + rootSchema, + values + ); + } + + if (Array.isArray(resolved.allOf)) { + for (const subSchema of resolved.allOf) { + collectPropertyValuesFromSchema( + subSchema, + property, + rootSchema, + values, + seen + ); + } + } + + if (Array.isArray(resolved.anyOf)) { + for (const subSchema of resolved.anyOf) { + collectPropertyValuesFromSchema( + subSchema, + property, + rootSchema, + values, + seen + ); + } + } + + if (Array.isArray(resolved.oneOf)) { + for (const subSchema of resolved.oneOf) { + collectPropertyValuesFromSchema( + subSchema, + property, + rootSchema, + values, + seen + ); + } + } +} + +function filterSchemaByConstProperty( + schema: JSONSchema, + rootSchema: JSONSchema, + propertyName: string, + propertyValue: string, + seen = new WeakSet() +): JSONSchema | null { + if (!schema || typeof schema !== 'object') { + return null; + } + + if (seen.has(schema as object)) { + return null; + } + seen.add(schema as object); + + const resolved = resolveSchemaRefs(schema, rootSchema); + if (resolved !== schema) { + return filterSchemaByConstProperty( + resolved, + rootSchema, + propertyName, + propertyValue, + seen + ); + } + + const propertySchema = resolved.properties?.[propertyName]; + if (propertySchema) { + const constants = new Set(); + collectConstValuesFromSchema(propertySchema, rootSchema, constants); + if (constants.has(propertyValue)) { + return mergeCompositeSchemas(resolved, rootSchema); + } + } + + if (Array.isArray(resolved.anyOf)) { + for (const option of resolved.anyOf) { + const filtered = filterSchemaByConstProperty( + option, + rootSchema, + propertyName, + propertyValue, + seen + ); + if (filtered) { + return filtered; + } + } + } + + if (Array.isArray(resolved.oneOf)) { + for (const option of resolved.oneOf) { + const filtered = filterSchemaByConstProperty( + option, + rootSchema, + propertyName, + propertyValue, + seen + ); + if (filtered) { + return filtered; + } + } + } + + if (Array.isArray(resolved.allOf)) { + let matched = false; + for (const option of resolved.allOf) { + const filtered = filterSchemaByConstProperty( + option, + rootSchema, + propertyName, + propertyValue, + seen + ); + if (filtered) { + matched = true; + break; + } + } + if (matched) { + return mergeCompositeSchemas(resolved, rootSchema); + } + } + + return null; +} + +/** + * Format JSON document while preserving cursor position + * Returns the formatted text and new cursor position + */ +function formatJSON( + doc: EditorState['doc'], + cursorPos: number +): { formatted: string; newPos: number } { + const text = doc.toString(); + + try { + const parsed = JSON.parse(text); + const formatted = JSON.stringify(parsed, null, '\t'); + + let nonWhitespaceCount = 0; + for (let i = 0; i < cursorPos && i < text.length; i++) { + if (!/\s/.test(text[i])) { + nonWhitespaceCount++; + } + } + + let newPos = 0; + let count = 0; + for (let i = 0; i < formatted.length; i++) { + if (!/\s/.test(formatted[i])) { + count++; + if (count >= nonWhitespaceCount) { + newPos = i + 1; + break; + } + } + } + + return { formatted, newPos }; + } catch (e) { + return { formatted: text, newPos: cursorPos }; + } +} + +/** + * Format the editor content and update cursor position + */ +export function formatEditor(view: EditorView): void { + const doc = view.state.doc; + const cursorPos = view.state.selection.main.head; + + const { formatted, newPos } = formatJSON(doc, cursorPos); + + if (formatted !== doc.toString()) { + view.dispatch({ + changes: { from: 0, to: doc.length, insert: formatted }, + selection: { anchor: newPos }, + }); + } +} + +/** + * Fetch a JSON schema from a URL + */ +async function fetchSchema(url: string): Promise { + if (schemaCache.has(url)) { + return schemaCache.get(url)!; + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch schema: ${response.statusText}`); + } + const schema = await response.json(); + schemaCache.set(url, schema); + return schema; + } catch (error) { + console.error('Error fetching schema:', error); + return null; + } +} + +/** + * Extract the $schema URL from the JSON document + */ +function getSchemaUrl(doc: string): string | null { + try { + const parsed = JSON.parse(doc); + return parsed.$schema || null; + } catch (error) { + const match = doc.match(/"?\$schema"?\s*:\s*"([^"]+)"/); + return match ? match[1] : null; + } +} + +/** + * Check if cursor is in a position to suggest property keys + */ +function isInPropertyKeyPosition( + doc: EditorState['doc'], + pos: number +): boolean { + const textBefore = doc.sliceString(0, pos); + const trimmed = textBefore.trim(); + + if (trimmed.endsWith('{') || trimmed.endsWith(',')) { + return true; + } + + const lastChars = textBefore.slice(-20); + if (lastChars.match(/[{,]\s*"\w*$/)) { + return true; + } + + if (lastChars.match(/[{,]\s*"$/)) { + return true; + } + + return false; +} + +/** + * JSON Schema-based autocompletion source + */ +export async function jsonSchemaCompletion( + context: CompletionContext +): Promise { + const doc = context.state.doc; + const pos = context.pos; + const docText = doc.toString(); + const currentContainerType = getCurrentContainerType(doc, pos); + + const schemaUrl = getSchemaUrl(docText); + if (!schemaUrl) { + return null; + } + + const schema = await fetchSchema(schemaUrl); + if (!schema) { + return null; + } + + const valuePropertyName = getPropertyNameForValueCompletion(doc, pos); + + let path = getJsonPath(doc, pos); + const contextPath = path.slice(); + + if ( + valuePropertyName && + path.length > 0 && + path[path.length - 1].type === 'key' + ) { + path = path.slice(0, -1); + } + + // Navigate the path while applying discriminator filtering at each level + let currentSchema = schema; + currentSchema = resolveSchemaRefs(currentSchema, schema); + + for (const segment of path) { + if (segment.type === 'key' && currentSchema.properties) { + const next = currentSchema.properties[segment.key!]; + if (!next) return null; + currentSchema = resolveSchemaRefs(next, schema); + } else if (segment.type === 'array') { + if (currentSchema.type === 'array' && currentSchema.items) { + currentSchema = resolveSchemaRefs(currentSchema.items, schema); + + // Check if this schema has a discriminator defined at the top level + const hasTopLevelDiscriminator = + currentSchema.discriminator?.propertyName; + + // Check if this array items has a discriminator + if ( + hasTopLevelDiscriminator || + currentSchema.anyOf || + currentSchema.oneOf + ) { + // Look for discriminator at this level + const discriminator = + hasTopLevelDiscriminator || + (() => { + const firstOption = + currentSchema.anyOf?.[0] || + currentSchema.oneOf?.[0]; + if (firstOption) { + const resolved = resolveSchemaRefs( + firstOption, + schema + ); + return resolved.discriminator?.propertyName; + } + return undefined; + })(); + + if (discriminator) { + // Only try to find discriminator value if we're navigating THROUGH the array + // to a nested property. If we're AT the array level (completing properties + // of the array item itself), we'll handle discriminator logic later. + const isNavigatingDeeper = + path.length > path.indexOf(segment) + 1; + + if (isNavigatingDeeper) { + // Find the discriminator value at the ARRAY ITEM level, not at the current cursor position + // We need to find the opening brace of the array item, not the nested object + const textBefore = doc.sliceString(0, pos); + let braceDepth = 0; + let arrayItemStart = -1; + + // Walk backward from cursor, tracking brace depth + // We want to find the opening brace where we entered the array item (depth becomes -1) + for (let i = textBefore.length - 1; i >= 0; i--) { + const char = textBefore[i]; + if (char === '}') { + braceDepth++; + } else if (char === '{') { + if (braceDepth === -1) { + // This is the opening brace of the array item + arrayItemStart = i; + break; + } + braceDepth--; + } else if (char === '[' && braceDepth === 0) { + // We've gone back past the array opening without finding an item start + break; + } + } + + let discriminatorValue: string | null = null; + if (arrayItemStart !== -1) { + // Look for the discriminator within this array item object + const arrayItemText = + textBefore.substring(arrayItemStart); + const regex = new RegExp( + `"${discriminator}"\\s*:\\s*"([^"]+)"` + ); + const match = arrayItemText.match(regex); + discriminatorValue = match ? match[1] : null; + } + + if (discriminatorValue) { + // Filter to the matching schema + currentSchema = filterSchemaByDiscriminator( + currentSchema, + schema, + discriminator, + discriminatorValue + ); + } + } + } + } + } else if (currentSchema.items) { + currentSchema = resolveSchemaRefs(currentSchema.items, schema); + } + } else if (segment.type === 'object') { + continue; + } + } + + if (!currentSchema) { + return null; + } + + // Check for discriminator ONLY in the current schema level + // A valid discriminator must be present in the DIRECT anyOf/oneOf at this level + let discriminatorProp = currentSchema.discriminator?.propertyName; + if (!discriminatorProp && (currentSchema.anyOf || currentSchema.oneOf)) { + // Check if there's a discriminator defined at this level + const options = currentSchema.anyOf || currentSchema.oneOf; + if (options && options.length > 0) { + const firstOption = options[0]; + const resolved = resolveSchemaRefs(firstOption, schema); + const candidateDiscriminator = resolved.discriminator?.propertyName; + + // Verify this discriminator actually belongs to THIS level by checking + // that it's defined as a property in the anyOf/oneOf options themselves + if (candidateDiscriminator) { + const discriminatorPresent = + schemaHasProperty( + resolved, + candidateDiscriminator, + schema + ) || + options.some((opt) => + schemaHasProperty(opt, candidateDiscriminator, schema) + ); + + if (discriminatorPresent) { + discriminatorProp = candidateDiscriminator; + } + } + } + } + + if (valuePropertyName) { + if (valuePropertyName === discriminatorProp) { + let schemaWithDiscriminator = currentSchema; + if (currentSchema.anyOf || currentSchema.oneOf) { + const firstOption = + currentSchema.anyOf?.[0] || currentSchema.oneOf?.[0]; + if (firstOption) { + schemaWithDiscriminator = resolveSchemaRefs( + firstOption, + schema + ); + } + } + + const discriminatorValues = getDiscriminatorValues( + schemaWithDiscriminator, + discriminatorProp + ); + + if (discriminatorValues.length === 0) { + return null; + } + + const word = context.matchBefore(/"[^"]*$/); + const from = word ? word.from + 1 : pos; + const to = pos; + + const textBefore = doc.sliceString(0, pos); + const valueMatch = textBefore.match(/"[^"]*$/); + const typedText = valueMatch + ? valueMatch[0].substring(1).toLowerCase() + : ''; + + let filteredValues = discriminatorValues; + if (typedText) { + filteredValues = discriminatorValues.filter((value) => + value.toLowerCase().startsWith(typedText) + ); + } + + const options = filteredValues.map((value) => ({ + label: value, + type: 'constant', + apply: value, + boost: 10, + })); + + return { + from, + to, + options, + filter: false, + }; + } + + const candidateValues = new Set(); + collectPropertyValuesFromSchema( + currentSchema, + valuePropertyName, + schema, + candidateValues + ); + + if (candidateValues.size > 0) { + const word = context.matchBefore(/"[^"\\]*$/); + const from = word ? word.from + 1 : pos; + const to = pos; + const typedText = word ? word.text.substring(1).toLowerCase() : ''; + + const sortedValues = Array.from(candidateValues).sort((a, b) => + a.localeCompare(b) + ); + const filteredValues = typedText + ? sortedValues.filter((value) => + value.toLowerCase().startsWith(typedText) + ) + : sortedValues; + + const options = filteredValues.map((value) => ({ + label: value, + type: 'constant', + apply: value, + })); + + if (options.length > 0) { + return { + from, + to, + options, + filter: false, + }; + } + } + + return null; + } + + const inObjectContainer = currentContainerType === 'object'; + const inKeyPosition = + inObjectContainer && isInPropertyKeyPosition(doc, pos); + if (!inKeyPosition) { + return null; + } + + const word = context.matchBefore(/"\w*/); + if (!word && !context.explicit) { + return null; + } + + const from = word ? word.from : pos; + + let to = pos; + const textAfterCursor = doc.sliceString(pos, pos + 50); + const quoteMatch = textAfterCursor.match(/^(\w*)"/); + + if (quoteMatch) { + to = pos + quoteMatch[0].length; + } + + const isPluginDataContext = contextPath.some( + (segment) => segment.type === 'key' && segment.key === 'pluginData' + ); + const resourceValue = isPluginDataContext + ? getCurrentDiscriminatorValue(doc, pos, 'resource') + : null; + + let schemaCandidate = currentSchema; + let schemaAlreadyMerged = false; + + if (isPluginDataContext && resourceValue) { + const filtered = filterSchemaByConstProperty( + schemaCandidate, + schema, + 'resource', + resourceValue + ); + if (filtered) { + schemaCandidate = filtered; + schemaAlreadyMerged = true; + } + } + + const currentDiscriminatorValue = getCurrentDiscriminatorValue( + doc, + pos, + discriminatorProp + ); + + if (currentDiscriminatorValue && discriminatorProp) { + schemaCandidate = filterSchemaByDiscriminator( + schemaCandidate, + schema, + discriminatorProp, + currentDiscriminatorValue + ); + schemaAlreadyMerged = true; + } else if (discriminatorProp) { + const mergedSchema = mergeCompositeSchemas(schemaCandidate, schema); + const discriminatorProperty = + mergedSchema.properties?.[discriminatorProp]; + + if (discriminatorProperty) { + schemaCandidate = { + ...schemaCandidate, + properties: { + [discriminatorProp]: discriminatorProperty, + }, + required: schemaCandidate.required?.includes(discriminatorProp) + ? [discriminatorProp] + : [], + }; + } + } else if (!schemaAlreadyMerged) { + schemaCandidate = mergeCompositeSchemas(schemaCandidate, schema); + schemaAlreadyMerged = true; + } + + currentSchema = schemaCandidate; + + if (!currentSchema || !currentSchema.properties) { + return null; + } + + const existingKeys = getExistingKeysInCurrentObject(doc, pos); + const currentlyTypingKey = + word && word.text.length > 1 ? word.text.substring(1) : null; + const discriminatorMissing = Boolean( + discriminatorProp && !existingKeys.has(discriminatorProp) + ); + + const calculatedTo = to; + + let propertyKeys = Object.keys(currentSchema.properties).filter((key) => { + if (existingKeys.has(key) && key !== currentlyTypingKey) { + return false; + } + return true; + }); + + if ( + discriminatorProp && + discriminatorMissing && + currentSchema.properties[discriminatorProp] + ) { + propertyKeys = [discriminatorProp]; + } + + const options = propertyKeys.map((key) => { + const prop = currentSchema.properties![key]; + const resolvedProp = resolveSchemaRefs(prop, schema); + const effectiveProp = resolvedProp; + const required = + currentSchema.required && currentSchema.required.includes(key); + const isDiscriminator = key === discriminatorProp; + const isPluginData = key === 'pluginData'; + + let valueToInsert = ''; + const propTypes = Array.isArray(effectiveProp.type) + ? effectiveProp.type + : effectiveProp.type + ? [effectiveProp.type] + : []; + + const hasObjectShape = + propTypes.includes('object') || + !!effectiveProp.properties || + !!effectiveProp.allOf || + !!effectiveProp.anyOf || + !!effectiveProp.oneOf; + const hasArrayShape = + propTypes.includes('array') || !!effectiveProp.items; + const hasStringShape = propTypes.includes('string'); + const hasNumberShape = + propTypes.includes('number') || propTypes.includes('integer'); + const hasBooleanShape = propTypes.includes('boolean'); + const hasEnumValues = + Array.isArray(effectiveProp.enum) && effectiveProp.enum.length > 0; + + if (hasArrayShape) { + valueToInsert = '[]'; + } else if (hasObjectShape) { + valueToInsert = '{}'; + } else if (hasStringShape) { + valueToInsert = '""'; + } else if (hasNumberShape) { + valueToInsert = '0'; + } else if (hasBooleanShape) { + valueToInsert = 'false'; + } else if (effectiveProp.const !== undefined) { + valueToInsert = JSON.stringify(effectiveProp.const); + } else if (effectiveProp.enum && effectiveProp.enum.length > 0) { + valueToInsert = JSON.stringify(effectiveProp.enum[0]); + } else { + valueToInsert = '""'; + } + + if (isPluginData) { + valueToInsert = '{ "resource": "" }'; + } + + const keepCursorInString = valueToInsert === '""' && !isPluginData; + const keepCursorInObject = !isPluginData && hasObjectShape; + + let boost = 0; + if (isDiscriminator) { + boost = 10; + } else if (required) { + boost = 1; + } + + return { + label: key, + type: 'property', + detail: (prop.type as string) || (prop.enum ? 'enum' : 'any'), + info: prop.description || '', + apply: (view: EditorView) => { + const insertText = `"${key}": ${valueToInsert}`; + + let cursorOffset; + if (isPluginData) { + const resourcePrefix = '"pluginData": { "resource": "'; + const prefixIndex = insertText.indexOf(resourcePrefix); + cursorOffset = prefixIndex + resourcePrefix.length; + } else if (hasObjectShape) { + cursorOffset = insertText.length - 1; + } else if (hasArrayShape) { + cursorOffset = insertText.length - 1; + } else if (hasStringShape) { + cursorOffset = insertText.length - 1; + } else { + cursorOffset = insertText.length; + } + + view.dispatch({ + changes: { from, to: calculatedTo, insert: insertText }, + selection: { anchor: from + cursorOffset }, + }); + + setTimeout(() => { + formatEditor(view); + + if (isPluginData) { + const resourcePrefix = '"resource": "'; + const docText = view.state.doc.toString(); + const searchStart = from; + const resourceIndex = docText.indexOf( + resourcePrefix, + Math.max(0, searchStart - 10) + ); + if (resourceIndex !== -1) { + const anchor = + resourceIndex + resourcePrefix.length; + view.dispatch({ selection: { anchor } }); + startCompletion(view); + } + return; + } + + if (keepCursorInObject) { + const docText = view.state.doc.toString(); + const searchStart = Math.max(0, from - 20); + const pattern = `"${key}": {`; + const propertyIndex = docText.indexOf( + pattern, + searchStart + ); + if (propertyIndex !== -1) { + const anchor = propertyIndex + pattern.length; + view.dispatch({ selection: { anchor } }); + startCompletion(view); + return; + } + } + + if (keepCursorInString) { + const anchorAfterFormat = + view.state.selection.main.anchor; + if (anchorAfterFormat >= 2) { + const docText = view.state.doc.toString(); + const lastChar = docText[anchorAfterFormat - 1]; + const prevChar = docText[anchorAfterFormat - 2]; + if (lastChar === '"' && prevChar === '"') { + view.dispatch({ + selection: { + anchor: anchorAfterFormat - 1, + }, + }); + } + } + + if (hasEnumValues) { + startCompletion(view); + } + } + + if (isDiscriminator && prop.type === 'string') { + startCompletion(view); + } + }, 0); + }, + boost, + }; + }); + + const enforceDiscriminator = discriminatorMissing; + + let filteredOptions = options; + if (!enforceDiscriminator && word && word.text.length > 1) { + const typed = word.text.substring(1).toLowerCase(); + filteredOptions = options.filter((opt) => + opt.label.toLowerCase().startsWith(typed) + ); + } + + return { + from, + to, + options: filteredOptions, + filter: false, + }; +} + +const DEFAULT_DOC = `{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json" +}`; + +export function JSONSchemaEditor({ + config = {}, + className = '', +}: JSONSchemaEditorProps) { + const editorRef = useRef(null); + const viewRef = useRef(null); + + useEffect(() => { + if (!editorRef.current) return; + + const initialDoc = config.initialDoc || DEFAULT_DOC; + const autofocus = config.autofocus ?? true; + + const extensions: Extension[] = [ + // Line numbers and highlighting + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + // Folding + foldGutter(), + // Selection features + dropCursor(), + rectangularSelection(), + crosshairCursor(), + // Language support + json(), + syntaxHighlighting(defaultHighlightStyle), + // Indentation + indentUnit.of('\t'), + indentOnInput(), + // Bracket features + bracketMatching(), + closeBrackets(), + // History + history(), + // Selection highlighting + highlightSelectionMatches(), + // Keymaps + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + ...foldKeymap, + ...searchKeymap, + ...completionKeymap, + ...closeBracketsKeymap, + indentWithTab, + ]), + // Autocompletion with JSON schema + autocompletion({ + override: [jsonSchemaCompletion], + activateOnTyping: true, + closeOnBlur: false, + }), + ]; + + // Add onChange listener if provided + if (config.onChange) { + extensions.push( + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged) { + config.onChange!(update.state.doc.toString()); + } + }) + ); + } + + const view = new EditorView({ + doc: initialDoc, + extensions, + parent: editorRef.current, + }); + + viewRef.current = view; + + formatEditor(view); + + // Position cursor after the first key/value pair if it's the default schema + const doc = view.state.doc.toString(); + const schemaUrl = + '"https://playground.wordpress.net/blueprint-schema.json"'; + const schemaLineEnd = doc.indexOf(schemaUrl); + if (schemaLineEnd > 0) { + const cursorPos = schemaLineEnd + schemaUrl.length; + if (cursorPos <= view.state.doc.length) { + view.dispatch({ + selection: { anchor: cursorPos }, + }); + } + } + + if (autofocus) { + view.focus(); + } + + return () => { + view.destroy(); + viewRef.current = null; + }; + }, [config.autofocus, config.initialDoc]); + + return
; +} + +export default JSONSchemaEditor; diff --git a/packages/playground/website/src/components/blueprint-editor/schema-utils.ts b/packages/playground/website/src/components/blueprint-editor/schema-utils.ts new file mode 100644 index 0000000000..491fcec287 --- /dev/null +++ b/packages/playground/website/src/components/blueprint-editor/schema-utils.ts @@ -0,0 +1,516 @@ +import { + findNodeAtLocation, + findNodeAtOffset, + getLocation, + parseTree, + type Location, + type Node as JsonNode, +} from 'jsonc-parser'; +import type { CodeMirrorDoc, JSONSchema, PathSegment } from './types'; + +interface ParsedJsonDocument { + text: string; + tree: JsonNode | undefined; +} + +interface DocumentContext { + parsed: ParsedJsonDocument; + location: Location; + node?: JsonNode; + containerNode?: JsonNode; + objectNode?: JsonNode; +} + +let lastParsedDocument: ParsedJsonDocument | null = null; +let lastParsedText = ''; + +function getParsedJsonDocument(doc: CodeMirrorDoc): ParsedJsonDocument { + const text = doc.toString(); + + if (lastParsedDocument && lastParsedText === text) { + return lastParsedDocument; + } + + const tree = parseTree(text); + lastParsedDocument = { text, tree }; + lastParsedText = text; + + return lastParsedDocument; +} + +function clampOffset(text: string, pos: number): number { + return Math.max(0, Math.min(pos, text.length)); +} + +function getContainerType(containerNode?: JsonNode): 'object' | 'array' | null { + if (!containerNode) { + return null; + } + if (containerNode.type === 'object') { + return 'object'; + } + if (containerNode.type === 'array') { + return 'array'; + } + return null; +} + +function buildPathSegments(path: Array): PathSegment[] { + const segments: PathSegment[] = []; + + for (const segment of path) { + if (typeof segment === 'string') { + if (segment === '') { + continue; + } + segments.push({ + type: 'key', + key: segment, + depth: segments.length + 1, + }); + } else { + segments.push({ type: 'array', depth: segments.length + 1 }); + } + } + + return segments; +} + +function collectObjectKeys(objectNode?: JsonNode): string[] { + if (!objectNode || objectNode.type !== 'object' || !objectNode.children) { + return []; + } + + const keys: string[] = []; + + for (const child of objectNode.children) { + if ( + child.type !== 'property' || + !child.children || + child.children.length === 0 + ) { + continue; + } + + const keyNode = child.children[0]; + if (keyNode.type === 'string' && typeof keyNode.value === 'string') { + keys.push(keyNode.value); + } + } + + return keys; +} + +function getPropertyValueNode( + objectNode: JsonNode | undefined, + propertyName: string +): JsonNode | undefined { + if (!objectNode || objectNode.type !== 'object' || !objectNode.children) { + return undefined; + } + + for (const child of objectNode.children) { + if ( + child.type !== 'property' || + !child.children || + child.children.length < 2 + ) { + continue; + } + + const keyNode = child.children[0]; + if (keyNode.type === 'string' && keyNode.value === propertyName) { + return child.children[1]; + } + } + + return undefined; +} + +function getDocumentContext(doc: CodeMirrorDoc, pos: number): DocumentContext { + const parsed = getParsedJsonDocument(doc); + const text = parsed.text; + const tree = parsed.tree; + + const offset = clampOffset(text, pos); + const location = getLocation(text, offset); + const node = tree ? findNodeAtOffset(tree, offset, true) : undefined; + + let containerNode: JsonNode | undefined; + let objectNode: JsonNode | undefined; + + if (tree) { + if (location.isAtPropertyKey) { + const pathNode = findNodeAtLocation(tree, location.path); + if (pathNode?.type === 'object') { + containerNode = pathNode; + objectNode = pathNode; + } + } + + if (!containerNode) { + let current = node; + while (current) { + if (current.type === 'object') { + containerNode = current; + objectNode = current; + break; + } + if (current.type === 'array') { + containerNode = current; + break; + } + current = current.parent; + } + } + + if (!containerNode && location.path.length > 0) { + const pathNode = findNodeAtLocation(tree, location.path); + let current = pathNode; + while (current) { + if (current.type === 'object' || current.type === 'array') { + containerNode = current; + if (current.type === 'object') { + objectNode = current; + } + break; + } + current = current.parent; + } + } + + if (!objectNode && containerNode?.type !== 'object') { + let current = node; + while (current) { + if (current.type === 'object') { + objectNode = current; + break; + } + current = current.parent; + } + } + + if (!containerNode && tree.type === 'object') { + containerNode = tree; + } + if (!objectNode && containerNode?.type === 'object') { + objectNode = containerNode; + } + } + + return { parsed, location, node, containerNode, objectNode }; +} + +/** + * Resolve a $ref reference in a schema + */ +export function resolveRef(schema: JSONSchema, ref: string): JSONSchema | null { + if (!ref || !ref.startsWith('#/')) { + return null; + } + + const path = ref.substring(2).split('/'); + let current: unknown = schema; + + for (const segment of path) { + if (!current || typeof current !== 'object') { + return null; + } + current = (current as Record)[segment]; + } + + return current as JSONSchema; +} + +/** + * Resolve all $ref in a schema object (non-recursive, one level) + */ +export function resolveSchemaRefs( + schema: JSONSchema, + rootSchema: JSONSchema +): JSONSchema { + if (!schema || typeof schema !== 'object') { + return schema; + } + + if (schema.$ref) { + const resolved = resolveRef(rootSchema, schema.$ref); + if (resolved) { + return { ...resolved, ...schema, $ref: undefined }; + } + } + + return schema; +} + +/** + * Merge all schemas from anyOf/oneOf/allOf into a single schema with combined properties + * This function recursively merges nested composite schemas + */ +export function mergeCompositeSchemas( + schema: JSONSchema, + rootSchema: JSONSchema +): JSONSchema { + if (!schema || typeof schema !== 'object') { + return schema; + } + + let merged: JSONSchema = { ...schema }; + + if (schema.allOf && Array.isArray(schema.allOf)) { + merged.properties = merged.properties || {}; + merged.required = merged.required || []; + + for (const subSchema of schema.allOf) { + let resolved = resolveSchemaRefs(subSchema, rootSchema); + resolved = mergeCompositeSchemas(resolved, rootSchema); + if (resolved.properties) { + merged.properties = { + ...merged.properties, + ...resolved.properties, + }; + } + if (resolved.required) { + merged.required = [...merged.required, ...resolved.required]; + } + } + } + + if (schema.anyOf && Array.isArray(schema.anyOf)) { + merged.properties = merged.properties || {}; + + for (const subSchema of schema.anyOf) { + let resolved = resolveSchemaRefs(subSchema, rootSchema); + resolved = mergeCompositeSchemas(resolved, rootSchema); + if (resolved.properties) { + merged.properties = { + ...merged.properties, + ...resolved.properties, + }; + } + } + } + + if (schema.oneOf && Array.isArray(schema.oneOf)) { + merged.properties = merged.properties || {}; + + for (const subSchema of schema.oneOf) { + let resolved = resolveSchemaRefs(subSchema, rootSchema); + resolved = mergeCompositeSchemas(resolved, rootSchema); + if (resolved.properties) { + merged.properties = { + ...merged.properties, + ...resolved.properties, + }; + } + } + } + + return merged; +} + +/** + * Parse the current JSON path from the cursor position + */ +export function getJsonPath(doc: CodeMirrorDoc, pos: number): PathSegment[] { + const context = getDocumentContext(doc, pos); + const path = + context.location.isAtPropertyKey && context.location.path.length > 0 + ? context.location.path.slice(0, -1) + : context.location.path; + return buildPathSegments(path); +} + +/** + * Return the container type (object or array) at the cursor position + */ +export function getCurrentContainerType( + doc: CodeMirrorDoc, + pos: number +): 'object' | 'array' | null { + const context = getDocumentContext(doc, pos); + return getContainerType(context.containerNode); +} + +/** + * Extract all possible discriminator values from a schema with oneOf/anyOf + */ +export function getDiscriminatorValues( + schema: JSONSchema, + discriminatorProp: string +): string[] { + if (!schema || !discriminatorProp) { + return []; + } + + const values: string[] = []; + const checkSchema = (subSchema: JSONSchema): void => { + if (subSchema.properties && subSchema.properties[discriminatorProp]) { + const prop = subSchema.properties[discriminatorProp]; + if (prop.const !== undefined) { + values.push(String(prop.const)); + } else if (prop.enum) { + values.push(...prop.enum.map(String)); + } + } + }; + + if (schema.oneOf) { + schema.oneOf.forEach(checkSchema); + } + if (schema.anyOf) { + schema.anyOf.forEach(checkSchema); + } + + return [...new Set(values)]; +} + +/** + * Find the discriminator value in the current object being edited + */ +export function getCurrentDiscriminatorValue( + doc: CodeMirrorDoc, + pos: number, + discriminatorProp: string | undefined +): string | null { + if (!discriminatorProp) { + return null; + } + + const context = getDocumentContext(doc, pos); + const valueNode = getPropertyValueNode( + context.objectNode, + discriminatorProp + ); + + if ( + valueNode && + valueNode.type === 'string' && + typeof valueNode.value === 'string' + ) { + return valueNode.value; + } + + return null; +} + +/** + * Get all existing keys in the current object being edited + * Returns a Set of key names that are already present + */ +export function getExistingKeysInCurrentObject( + doc: CodeMirrorDoc, + pos: number +): Set { + const context = getDocumentContext(doc, pos); + return new Set(collectObjectKeys(context.objectNode)); +} + +/** + * Determine the property name for which a value is being completed + */ +export function getPropertyNameForValueCompletion( + doc: CodeMirrorDoc, + pos: number +): string | null { + const context = getDocumentContext(doc, pos); + + if (context.location.isAtPropertyKey) { + return null; + } + + const path = context.location.path; + if (path.length === 0) { + return null; + } + + const lastSegment = path[path.length - 1]; + return typeof lastSegment === 'string' ? lastSegment : null; +} + +/** + * Filter a schema to only include properties valid for a specific discriminator value + */ +export function filterSchemaByDiscriminator( + schema: JSONSchema, + rootSchema: JSONSchema, + discriminatorProp: string, + discriminatorValue: string +): JSONSchema { + if (!schema || !discriminatorProp || !discriminatorValue) { + return schema; + } + + const resolved = resolveSchemaRefs(schema, rootSchema); + + const findMatchingSchema = ( + schemas: JSONSchema[] | undefined + ): JSONSchema | null => { + if (!schemas) return null; + + for (const subSchema of schemas) { + const subResolved = resolveSchemaRefs(subSchema, rootSchema); + const prop = subResolved.properties?.[discriminatorProp]; + + if ( + prop?.const === discriminatorValue || + prop?.enum?.includes(discriminatorValue) + ) { + return subResolved; + } + + if (subResolved.oneOf || subResolved.anyOf) { + const nested = filterSchemaByDiscriminator( + subResolved, + rootSchema, + discriminatorProp, + discriminatorValue + ); + if ( + nested && + nested.properties && + Object.keys(nested.properties).length > 0 + ) { + return nested; + } + } + } + return null; + }; + + const matchingSchema = + findMatchingSchema(resolved.oneOf) || + findMatchingSchema(resolved.anyOf); + + return matchingSchema || resolved; +} + +/** + * Get schema properties for the current JSON path + */ +export function getSchemaForPath( + schema: JSONSchema, + path: PathSegment[] +): JSONSchema | null { + let current: JSONSchema = schema; + + current = resolveSchemaRefs(current, schema); + + for (const segment of path) { + if (segment.type === 'key' && current.properties) { + const next = current.properties[segment.key!]; + if (!next) return null; + current = resolveSchemaRefs(next, schema); + } else if (segment.type === 'array') { + if (current.type === 'array' && current.items) { + current = resolveSchemaRefs(current.items, schema); + } else if (current.items) { + current = resolveSchemaRefs(current.items, schema); + } + } else if (segment.type === 'object') { + continue; + } + } + + return current; +} diff --git a/packages/playground/website/src/components/blueprint-editor/types.ts b/packages/playground/website/src/components/blueprint-editor/types.ts new file mode 100644 index 0000000000..c99c35082e --- /dev/null +++ b/packages/playground/website/src/components/blueprint-editor/types.ts @@ -0,0 +1,35 @@ +import { Text } from '@codemirror/state'; + +export interface JSONSchema { + $schema?: string; + $ref?: string; + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JSONSchema; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + allOf?: JSONSchema[]; + const?: unknown; + enum?: unknown[]; + description?: string; + discriminator?: { + propertyName: string; + mapping?: Record; + }; + [key: string]: unknown; +} + +export interface PathSegment { + type: 'key' | 'array' | 'object'; + key?: string; + depth: number; +} + +export interface JSONSchemaCompletionConfig { + autofocus?: boolean; + initialDoc?: string; + onChange?: (doc: string) => void; +} + +export type { Text as CodeMirrorDoc }; diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index ac9e1e33c6..a36059e8a5 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -10,6 +10,7 @@ import { MenuGroup, MenuItem, TabPanel, + CheckboxControl, } from '@wordpress/components'; import { moreVertical, external, chevronLeft, edit } from '@wordpress/icons'; import { SiteLogs } from '../../log-modal'; @@ -31,14 +32,23 @@ import { ActiveSiteSettingsForm } from '../site-settings-form/active-site-settin import { getRelativeDate } from '../../../lib/get-relative-date'; import { setActiveModal } from '../../../lib/state/redux/slice-ui'; import { modalSlugs } from '../../layout'; -import { removeSite } from '../../../lib/state/redux/slice-sites'; +import { + removeSite, + updateSiteMetadata, +} from '../../../lib/state/redux/slice-sites'; import { BlueprintReflection } from '@wp-playground/blueprints'; -import { lazy, Suspense, useState, useEffect } from 'react'; +import { lazy, Suspense, useState, useEffect, useCallback } from 'react'; const SiteFileBrowser = lazy(() => import('../site-file-browser').then((m) => ({ default: m.SiteFileBrowser })) ); +const BlueprintEditor = lazy(() => + import('../../blueprint-editor').then((m) => ({ + default: m.JSONSchemaEditor, + })) +); + const LAST_TAB_STORAGE_KEY = 'playground-site-last-tabs'; function getSiteLastTab(siteSlug: string): string | null { @@ -88,11 +98,75 @@ export function SiteInfoPanel({ // Resolve documentRoot from playground client const [documentRoot, setDocumentRoot] = useState(null); + // Blueprint editing state for temporary playgrounds + const [blueprintCode, setBlueprintCode] = useState(''); + const [autoRecreate, setAutoRecreate] = useState(false); + const [isRecreating, setIsRecreating] = useState(false); + + // Initialize blueprint code using BlueprintReflection to handle bundles + useEffect(() => { + (async () => { + try { + const reflection = await BlueprintReflection.create( + site.metadata.originalBlueprint as any + ); + const declaration = reflection.getDeclaration() as any; + setBlueprintCode(JSON.stringify(declaration, null, '\t')); + } catch (error) { + // Fallback to original blueprint if reflection fails + setBlueprintCode( + JSON.stringify(site.metadata.originalBlueprint, null, '\t') + ); + } + })(); + }, [site.metadata.originalBlueprint]); + // Save the tab when it changes const handleTabSelect = (tabName: string) => { setSiteLastTab(site.slug, tabName); }; + // Handle blueprint recreation for temporary playgrounds + const handleRecreateFromBlueprint = useCallback(async () => { + try { + setIsRecreating(true); + // Parse the blueprint to validate it + const blueprint = JSON.parse(blueprintCode); + + // TODO: Implement actual playground recreation + // This would involve creating a new temporary site with the updated blueprint + + // For now, just update the original blueprint in the site metadata + dispatch( + updateSiteMetadata({ + slug: site.slug, + changes: { + originalBlueprint: blueprint, + }, + }) + ); + } catch { + alert('Invalid Blueprint JSON. Please check the syntax.'); + } finally { + setIsRecreating(false); + } + }, [blueprintCode, dispatch, site.slug]); + + const isTemporary = site.metadata.storage === 'none'; + + // Debounced auto-recreate when blueprint changes + useEffect(() => { + if (!autoRecreate || !isTemporary || !blueprintCode) { + return; + } + + const timeoutId = setTimeout(() => { + handleRecreateFromBlueprint(); + }, 2000); // 2 second debounce + + return () => clearTimeout(timeoutId); + }, [blueprintCode, autoRecreate, isTemporary, handleRecreateFromBlueprint]); + const removeSiteAndCloseMenu = async (onClose: () => void) => { // TODO: Replace with HTML-based dialog const proceed = window.confirm( @@ -131,7 +205,6 @@ export function SiteInfoPanel({ playground.goTo(path); } } - const isTemporary = site.metadata.storage === 'none'; const { opfsMountDescriptor } = usePlaygroundClientInfo(site.slug) || {}; @@ -412,6 +485,10 @@ export function SiteInfoPanel({ name: 'files', title: 'File browser', }, + { + name: 'blueprint', + title: 'Blueprint', + }, { name: 'logs', title: 'Logs', @@ -469,6 +546,65 @@ export function SiteInfoPanel({ )}
+ + } + > + + +
Date: Fri, 24 Oct 2025 12:24:58 +0200 Subject: [PATCH 24/31] Don't lose focus when typing in the Blueprint editor --- .../src/components/blueprint-editor/index.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/playground/website/src/components/blueprint-editor/index.tsx b/packages/playground/website/src/components/blueprint-editor/index.tsx index 7e8187520a..18d581a845 100644 --- a/packages/playground/website/src/components/blueprint-editor/index.tsx +++ b/packages/playground/website/src/components/blueprint-editor/index.tsx @@ -1100,7 +1100,31 @@ export function JSONSchemaEditor({ view.destroy(); viewRef.current = null; }; - }, [config.autofocus, config.initialDoc]); + // Only create the editor once, don't recreate on prop changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Handle document updates from parent without recreating the editor + useEffect(() => { + const view = viewRef.current; + if (!view || !config.initialDoc) { + return; + } + + const currentDoc = view.state.doc.toString(); + if (config.initialDoc === currentDoc) { + return; + } + + // Only update if the change came from outside (not from user typing) + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: config.initialDoc, + }, + }); + }, [config.initialDoc]); return
; } From 91d1331739fb6b52cdf83b3c9eb9eb6cc15663e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 24 Oct 2025 12:35:07 +0200 Subject: [PATCH 25/31] Recreate Playground after clicking the button --- .../components/playground-viewport/index.tsx | 41 +++++++++---- .../site-manager/site-info-panel/index.tsx | 59 +++++++++++++------ .../src/lib/state/redux/slice-sites.ts | 1 + 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index 8d3eabca10..c5d964f376 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -67,6 +67,15 @@ export const KeepAliveTemporarySitesViewport = () => { } return sites.map((site) => site.slug); }, [temporarySites, activeSite]); + + // Create a map of slug to site for easy lookup + const sitesBySlug = useMemo(() => { + const sites = [...temporarySites]; + if (activeSite) { + sites.push(activeSite); + } + return new Map(sites.map((site) => [site.slug, site])); + }, [temporarySites, activeSite]); /** * ## Critical data loss prevention mechanism * @@ -151,18 +160,24 @@ export const KeepAliveTemporarySitesViewport = () => {
)} - {slugsSeenSoFar.map((slug) => ( -
- {siteSlugsToRender.includes(slug) ? ( - - ) : null} -
- ))} + {slugsSeenSoFar.map((slug) => { + const site = sitesBySlug.get(slug); + const viewportKey = site + ? `${slug}-${site.metadata.whenCreated}` + : slug; + return ( +
+ {siteSlugsToRender.includes(slug) ? ( + + ) : null} +
+ ); + })} ); }; @@ -213,7 +228,7 @@ export const JustViewport = function JustViewport({ return (