diff --git a/package-lock.json b/package-lock.json index e744e4d1900..61ed74b5555 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9317,6 +9317,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "dev": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -9541,6 +9547,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typescript/ata": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@typescript/ata/-/ata-0.9.8.tgz", + "integrity": "sha512-+M815CeDRJS5H5ciWfhFCKp25nNfF+LFWawWAaBhNlquFb2wS5IIMDI+2bKWN3GuU6mpj+FzySsOD29M4nG8Xg==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.4.4" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -20428,24 +20443,33 @@ } }, "node_modules/monaco-editor": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.27.0.tgz", - "integrity": "sha512-UhwP78Wb8w0ZSYoKXQNTV/0CHObp6NS3nCt51QfKE6sKyBo5PBsvuDOHoI2ooBakc6uIwByRLHVeT7+yXQe2fQ==", - "dev": true + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", + "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", + "dev": true, + "dependencies": { + "@types/trusted-types": "^1.0.6" + } }, "node_modules/monaco-editor-webpack-plugin": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-4.2.0.tgz", - "integrity": "sha512-/P3sFiEgBl+Y50he4mbknMhbLJVop5gBUZiPS86SuHUDOOnQiQ5rL1jU5lwt1XKAwMEkhwZbUwqaHxTPkb1Utw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz", + "integrity": "sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA==", "dev": true, "dependencies": { - "loader-utils": "^2.0.0" + "loader-utils": "^2.0.2" }, "peerDependencies": { - "monaco-editor": "0.25.x || 0.26.x || 0.27.x || 0.28.x", + "monaco-editor": ">= 0.31.0", "webpack": "^4.5.0 || 5.x" } }, + "node_modules/monaco-editor/node_modules/@types/trusted-types": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", + "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", + "dev": true + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -29608,13 +29632,16 @@ "@svgr/webpack": "^7.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@types/uuid": "^10.0.0", + "@typescript/ata": "^0.9.8", "copy-webpack-plugin": "^11.0.0", "css-loader": "^7.1.0", + "es-module-lexer": "^1.7.0", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.4.0", "mini-css-extract-plugin": "^2.4.3", - "monaco-editor": "0.27.0", - "monaco-editor-webpack-plugin": "^4.2.0", + "monaco-editor": "^0.53.0", + "monaco-editor-webpack-plugin": "^7.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-refresh": "^0.17.0", diff --git a/packages/tools/playground/package.json b/packages/tools/playground/package.json index f97268bcfd3..e4d8fd134c0 100644 --- a/packages/tools/playground/package.json +++ b/packages/tools/playground/package.json @@ -19,13 +19,16 @@ "@svgr/webpack": "^7.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@types/uuid": "^10.0.0", + "@typescript/ata": "^0.9.8", "copy-webpack-plugin": "^11.0.0", "css-loader": "^7.1.0", + "es-module-lexer": "^1.7.0", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.4.0", "mini-css-extract-plugin": "^2.4.3", - "monaco-editor": "0.27.0", - "monaco-editor-webpack-plugin": "^4.2.0", + "monaco-editor": "^0.53.0", + "monaco-editor-webpack-plugin": "^7.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-refresh": "^0.17.0", diff --git a/packages/tools/playground/public/zipContent/index.html b/packages/tools/playground/public/zipContent/index.html deleted file mode 100644 index 43bf44c620f..00000000000 --- a/packages/tools/playground/public/zipContent/index.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - Babylon.js sample code - - - - - - - - - - - - - - - - - - - - - - - -
- - - diff --git a/packages/tools/playground/src/components/editor/activityBarComponent.tsx b/packages/tools/playground/src/components/editor/activityBarComponent.tsx new file mode 100644 index 00000000000..e912b62e24b --- /dev/null +++ b/packages/tools/playground/src/components/editor/activityBarComponent.tsx @@ -0,0 +1,173 @@ +/* eslint-disable jsdoc/require-jsdoc */ +// activityBarComponent.tsx +import * as React from "react"; +import type { GlobalState } from "../../globalState"; +import HistoryIcon from "./icons/history.svg"; +import FilesIcon from "./icons/files.svg"; +import SearchIcon from "./icons/search.svg"; +import { FileDialog } from "./fileDialog"; +import { LocalSessionDialog } from "./localSessionDialog"; +import { Utilities } from "../../tools/utilities"; +import { Icon } from "./iconComponent"; + +type DialogKind = "create" | "rename" | "duplicate"; +type DialogState = { + open: boolean; + type: DialogKind | null; + title: string; + initialValue: string; + targetPath?: string; +}; + +export type ActivityBarHandle = { + openCreateDialog: (suggestedName?: string) => void; + openRenameDialog: (targetPath: string, initialDisplay?: string) => void; + openDuplicateDialog: (targetPath: string, suggestedName?: string) => void; + toggleSessionDialog: () => void; +}; + +interface IActivityBarProps { + globalState: GlobalState; + + // Monaco owns mutations + onConfirmFileDialog: (type: DialogKind, filename: string, targetPath?: string) => void; + + // Lifted layout state + explorerOpen: boolean; + onToggleExplorer: () => void; + + sessionOpen: boolean; + onToggleSession: () => void; + + searchOpen: boolean; + onToggleSearch: () => void; +} + +export const ActivityBar = React.forwardRef(function activityBar( + { globalState, onConfirmFileDialog, explorerOpen, onToggleExplorer, sessionOpen, onToggleSession, searchOpen, onToggleSearch }, + ref +) { + const [theme, setTheme] = React.useState<"dark" | "light">(() => (Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light")); + + const [fileDialog, setFileDialog] = React.useState({ + open: false, + type: null, + title: "", + initialValue: "", + }); + + // helpers + const toDisplay = (path: string) => path.replace(/^\/?src\//, ""); + const toInternal = (displayPath: string) => (displayPath?.startsWith("/") ? displayPath.slice(1) : displayPath || ""); + const defaultNewFileName = (): string => { + const isTS = globalState.language !== "JS"; + const base = isTS ? "new.ts" : "new.js"; + let idx = 0; + let candidate = base; + const files = globalState.files || {}; + while (Object.prototype.hasOwnProperty.call(files, toInternal(candidate))) { + idx++; + const parts = base.split("."); + const ext = parts.pop(); + candidate = `${parts.join(".")}${idx}.${ext}`; + } + return candidate; + }; + const defaultDuplicateName = (path: string): string => { + const baseFull = path.replace(/\.(\w+)$/, ""); + const ext = (path.match(/\.(\w+)$/) || ["", ""])[1]; + let i = 1; + let candidate = `${baseFull}.copy${i}${ext ? "." + ext : ""}`; + const files = globalState.files || {}; + while (Object.prototype.hasOwnProperty.call(files, candidate)) { + i++; + candidate = `${baseFull}.copy${i}${ext ? "." + ext : ""}`; + } + return toDisplay(candidate); + }; + + // imperative API + React.useImperativeHandle(ref, () => ({ + openCreateDialog: (suggestedName?: string) => { + setFileDialog({ open: true, type: "create", title: "New file", initialValue: suggestedName ?? defaultNewFileName() }); + }, + openRenameDialog: (targetPath: string, initialDisplay?: string) => { + setFileDialog({ + open: true, + type: "rename", + title: "Rename file", + initialValue: initialDisplay ?? toDisplay(targetPath), + targetPath, + }); + }, + openDuplicateDialog: (targetPath: string, suggestedName?: string) => { + setFileDialog({ + open: true, + type: "duplicate", + title: "Duplicate file", + initialValue: suggestedName ?? defaultDuplicateName(targetPath), + targetPath, + }); + }, + toggleSessionDialog: () => onToggleSession(), + })); + + // theme sync + React.useEffect(() => { + const updateTheme = () => { + setTheme(Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"); + }; + globalState.onThemeChangedObservable.add(updateTheme); + return () => { + globalState.onThemeChangedObservable.removeCallback(updateTheme); + }; + }, [globalState]); + + // dialog handlers + const closeFileDialog = () => setFileDialog({ open: false, type: null, title: "", initialValue: "" }); + const confirmFileDialog = (filename: string) => { + if (!fileDialog.type) { + return; + } + onConfirmFileDialog(fileDialog.type, filename, fileDialog.targetPath); + closeFileDialog(); + }; + + return ( +
+ {/* Vertical Activity Bar (far left) */} +
+ + + +
+ + {/* File Dialog */} + + + {/* Local Session Dialog */} + +
+ ); +}); diff --git a/packages/tools/playground/src/components/editor/fileDialog.tsx b/packages/tools/playground/src/components/editor/fileDialog.tsx new file mode 100644 index 00000000000..b9dcfe7dbf7 --- /dev/null +++ b/packages/tools/playground/src/components/editor/fileDialog.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import type { GlobalState } from "../../globalState"; +import { Utilities } from "../../tools/utilities"; +import "../../scss/dialogs.scss"; + +interface IFileDialogProps { + globalState: GlobalState; + isOpen: boolean; + title: string; + initialValue?: string; + placeholder?: string; + submitLabel?: string; + onConfirm: (filename: string) => void; + onCancel: () => void; +} + +/** + * @param param0 Props for the file dialog + * @returns JSX.Element + */ +export const FileDialog: React.FC = ({ + globalState, + isOpen, + title, + initialValue = "", + placeholder = "Enter filename...", + submitLabel = "Create", + onConfirm, + onCancel, +}) => { + const [filename, setFilename] = React.useState(initialValue); + const [theme, setTheme] = React.useState<"dark" | "light">(() => { + return Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"; + }); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (isOpen) { + setFilename(initialValue); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + } + }, [isOpen, initialValue]); + + React.useEffect(() => { + const updateTheme = () => { + const newTheme = Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"; + setTheme(newTheme); + }; + + globalState.onThemeChangedObservable.add(updateTheme); + return () => { + globalState.onThemeChangedObservable.removeCallback(updateTheme); + }; + }, [globalState]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (filename.trim()) { + onConfirm(filename.trim()); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onCancel(); + } + }; + + if (!isOpen) { + return null; + } + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+
+ setFilename(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="dialog__input" + autoComplete="off" + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/packages/tools/playground/src/components/editor/fileExplorerComponent.tsx b/packages/tools/playground/src/components/editor/fileExplorerComponent.tsx new file mode 100644 index 00000000000..4b547b0f646 --- /dev/null +++ b/packages/tools/playground/src/components/editor/fileExplorerComponent.tsx @@ -0,0 +1,121 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import * as React from "react"; +import type { GlobalState } from "../../globalState"; + +type Node = { name: string; path?: string; kind: "file" | "dir"; children?: Map }; +function BuildTree(paths: string[]): Node { + const root: Node = { name: "", kind: "dir", children: new Map() }; + for (const full of paths) { + const parts = full.split("/"); + let cur = root; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + const key = part.toLowerCase() + (isLast ? "|f" : "|d"); + if (!cur.children!.has(key)) { + cur.children!.set(key, isLast ? { name: part, path: full, kind: "file" } : { name: part, kind: "dir", children: new Map() }); + } + cur = cur.children!.get(key)!; + } + } + return root; +} + +export const FileExplorer: React.FC<{ + globalState: GlobalState; + files: Record; + openFiles: string[]; + active: string | undefined; + onOpen: (path: string) => void; + onClose: (path: string) => void; + onCreate: (suggestedPath?: string) => void; + onRename: (oldPath: string, newPath: string) => void; + onDelete: (path: string) => void; +}> = ({ globalState, files, openFiles, active, onOpen, onClose, onRename, onDelete }) => { + const [tree, setTree] = React.useState(BuildTree(Object.keys(files))); + React.useEffect(() => { + const update = () => { + setTree(BuildTree(Object.keys(globalState.files))); + }; + globalState.onFilesChangedObservable.add(update); + globalState.onActiveFileChangedObservable.add(update); + + return () => { + globalState.onFilesChangedObservable.removeCallback(update); + globalState.onActiveFileChangedObservable.removeCallback(update); + }; + }, []); + const render = (node: Node, depth = 0) => { + if (node.kind === "file") { + const isActive = node.path === active; + const isOpen = openFiles.includes(node.path!); + const isEntry = node.path === globalState.entryFilePath; + return ( +
onOpen(node.path!)} + title={node.path} + > + + {node.name} + {isEntry && } + +
+ {isOpen && ( + + )} + + +
+
+ ); + } + // dir + const entries = Array.from(node.children!.values()).sort((a, b) => { + if (a.path === globalState.entryFilePath) { + return -1; + } + if (b.path === globalState.entryFilePath) { + return 1; + } + + if (a.kind !== b.kind) { + return a.kind === "dir" ? -1 : 1; + } + + return a.name.localeCompare(b.name); + }); + return
{entries.map((child) => render(child, depth + (node.name ? 1 : 0)))}
; + }; + + return
{render(tree)}
; +}; diff --git a/packages/tools/playground/src/components/editor/iconComponent.tsx b/packages/tools/playground/src/components/editor/iconComponent.tsx new file mode 100644 index 00000000000..baa23a2e1b1 --- /dev/null +++ b/packages/tools/playground/src/components/editor/iconComponent.tsx @@ -0,0 +1,30 @@ +// Icon.tsx +import * as React from "react"; + +type IconProps = { + size?: number; + strokeWidth?: number; + children: React.ReactElement; +}; + +/** + * + * @param param0 + * @returns + */ +export function Icon({ size = 20, strokeWidth, children }: IconProps) { + const vb = children.props.viewBox as unknown as string | undefined; + const native = vb ? Number(vb.split(" ")[2]) : 24; + const scale = size / native; + + return React.cloneElement(children, { + style: { + transformBox: "fill-box", + transformOrigin: "50% 50%", + transform: `scale(${scale})`, + display: "block", + ...((children.props.style as any) || {}), + }, + ...(strokeWidth ? { strokeWidth } : {}), + }); +} diff --git a/packages/tools/playground/src/components/editor/icons/caseSensitive.svg b/packages/tools/playground/src/components/editor/icons/caseSensitive.svg new file mode 100644 index 00000000000..9faa9b7c414 --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/caseSensitive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/files.svg b/packages/tools/playground/src/components/editor/icons/files.svg new file mode 100644 index 00000000000..8671a7b93f7 --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/history.svg b/packages/tools/playground/src/components/editor/icons/history.svg new file mode 100644 index 00000000000..53d41f7c7bb --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/newFile.svg b/packages/tools/playground/src/components/editor/icons/newFile.svg new file mode 100644 index 00000000000..99d8286cb0d --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/newFile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/regex.svg b/packages/tools/playground/src/components/editor/icons/regex.svg new file mode 100644 index 00000000000..1f45856932d --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/regex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/replace.svg b/packages/tools/playground/src/components/editor/icons/replace.svg new file mode 100644 index 00000000000..65bfaacf47e --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/replace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/replaceAll.svg b/packages/tools/playground/src/components/editor/icons/replaceAll.svg new file mode 100644 index 00000000000..f5a51538d14 --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/replaceAll.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/search.svg b/packages/tools/playground/src/components/editor/icons/search.svg new file mode 100644 index 00000000000..c6e88e26f9e --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/icons/wholeWord.svg b/packages/tools/playground/src/components/editor/icons/wholeWord.svg new file mode 100644 index 00000000000..d415932ad74 --- /dev/null +++ b/packages/tools/playground/src/components/editor/icons/wholeWord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/tools/playground/src/components/editor/localSessionDialog.tsx b/packages/tools/playground/src/components/editor/localSessionDialog.tsx new file mode 100644 index 00000000000..8d4fe7e1b4d --- /dev/null +++ b/packages/tools/playground/src/components/editor/localSessionDialog.tsx @@ -0,0 +1,226 @@ +import * as React from "react"; +import type { GlobalState } from "../../globalState"; +import type { FileChange, RevisionContext, SnippetRevision } from "../../tools/localSession"; +import { ListRevisionContexts, LoadFileRevisionsForToken, MaxRevisions, RemoveFileRevisionForToken } from "../../tools/localSession"; +import { Utilities } from "../../tools/utilities"; +import type { V2Manifest } from "../../tools/snippet"; +import "../../scss/dialogs.scss"; + +interface ILocalSessionDialogProps { + globalState: GlobalState; + isOpen: boolean; + onCancel: () => void; +} + +/** + * + * @param param0 + * @returns + */ +export const LocalSessionDialog: React.FC = ({ globalState, isOpen, onCancel }) => { + const [fileRevisions, setFileRevisions] = React.useState([]); + const [contexts, setContexts] = React.useState([]); + + const [selectedToken, setSelectedToken] = React.useState(""); + const [theme, setTheme] = React.useState<"dark" | "light">(() => { + return Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"; + }); + + React.useEffect(() => { + if (!isOpen) { + return; + } + + const ctxs = ListRevisionContexts(globalState); + setContexts(ctxs); + const defaultToken = globalState.currentSnippetToken || "local-session"; + const effective = ctxs.find((c) => c.token === defaultToken)?.token ?? ctxs[0]?.token ?? defaultToken; + setSelectedToken(effective); + + const revs = LoadFileRevisionsForToken(globalState, effective); + setFileRevisions(revs); + }, [isOpen, globalState]); + + React.useEffect(() => { + const updateTheme = () => { + const newTheme = Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"; + setTheme(newTheme); + }; + globalState.onThemeChangedObservable.add(updateTheme); + return () => { + globalState.onThemeChangedObservable.removeCallback(updateTheme); + }; + }, [globalState]); + + const onSelectContext = (token: string) => { + setSelectedToken(token); + const revs = LoadFileRevisionsForToken(globalState, token); + setFileRevisions(revs); + }; + + const fileChangeBadge = (c: FileChange) => { + const symbol = c.type === "added" ? "+" : c.type === "removed" ? "–" : "∼"; + const sizeText = (() => { + const fmt = (n: number | null) => (n == null ? "—" : `${n} B`); + return `${fmt(c.beforeSize)} → ${fmt(c.afterSize)}`; + })(); + const title = `${c.type.toUpperCase()} • ${c.file} • ${sizeText}`; + return ( + + {symbol} {c.file} + + ); + }; + + const formatDate = (timestamp: number) => + new Date(timestamp).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const restoreRevision = (manifest: V2Manifest) => { + globalState.onV2HydrateRequiredObservable.notifyObservers(manifest); + onCancel(); + }; + + const removeRevision = (index: number) => { + RemoveFileRevisionForToken(globalState, selectedToken, index); + const revs = LoadFileRevisionsForToken(globalState, selectedToken); + setFileRevisions(revs); + setContexts(ListRevisionContexts(globalState)); + }; + + if (!isOpen) { + return null; + } + const hasRevisions = fileRevisions.length > 0; + + return ( +
+
e.stopPropagation()}> +
+
+

Playground Session History

+ + {/* Context select */} +
+ + +
+
+ + +
+ +
+ {hasRevisions ? ( +
+ + + + + + + + + + + {fileRevisions.map((revision, index) => { + const changes = revision.filesChanged ?? []; + const maxInline = 1; + const shown = changes.slice(0, maxInline); + const notShown = changes.slice(maxInline); + const hiddenCount = Math.max(0, changes.length - shown.length); + + return ( + + + + + + + ); + })} + +
TitleFilesDateAction
+ {revision.title} + {revision.link && ( + + + {revision.link} + + + )} + +
+ {shown.map(fileChangeBadge)} + {hiddenCount > 0 && ( + `${f.type.toUpperCase()} • ${f.file}`).join("\n")}> + +{hiddenCount} more + + )} + {changes.length === 0 && No file changes} +
+
+ {formatDate(revision.date)} + + + +
+
+ ) : ( +
+
📝
+

No revisions found

+

This session doesn't have any saved revisions yet.

+
+ )} +
+ +
+

+ Up to {MaxRevisions} local sessions are retained per snippet context, or local context if unsaved, and are stored when the Playground is run with changes to + the code. +

+
+
+
+ ); +}; diff --git a/packages/tools/playground/src/components/editor/monacoComponent.tsx b/packages/tools/playground/src/components/editor/monacoComponent.tsx new file mode 100644 index 00000000000..de3ba8d247b --- /dev/null +++ b/packages/tools/playground/src/components/editor/monacoComponent.tsx @@ -0,0 +1,773 @@ +// monacoComponent.tsx +import * as React from "react"; +import { MonacoManager } from "../../tools/monaco/monacoManager"; +import { Utilities } from "../../tools/utilities"; +import type { GlobalState } from "../../globalState"; +import type { Observer } from "core/Misc"; + +import { SplitContainer } from "shared-ui-components/split/splitContainer"; +import { Splitter } from "shared-ui-components/split/splitter"; +import { ControlledSize, SplitDirection } from "shared-ui-components/split/splitContext"; + +import type { ActivityBarHandle } from "./activityBarComponent"; +import { ActivityBar } from "./activityBarComponent"; +import { FileExplorer } from "./fileExplorerComponent"; +import AddFileIcon from "./icons/newFile.svg"; +import { SearchPanel } from "./searchPanelComponent"; + +import "../../scss/monaco.scss"; +import "../../scss/editor.scss"; + +interface IMonacoComponentProps { + className?: string; + refObject: React.RefObject; + globalState: GlobalState; +} + +type CtxMenuState = { open: boolean; x: number; y: number; path: string | null }; +type DialogKind = "create" | "rename" | "duplicate"; + +interface IComponentState { + files: string[]; + active: string; + order: string[]; + /** Local (non-serialized) ordering of open editor tabs */ + tabOrder: string[]; + ctx: CtxMenuState; + theme: "dark" | "light"; + dragOverIndex: number; + explorerOpen: boolean; + searchOpen: boolean; + sessionOpen: boolean; +} + +/** + * + */ +export class MonacoComponent extends React.Component { + private readonly _mutationObserver: MutationObserver; + private _monacoManager: MonacoManager; + private _draggingPath: string | null = null; + private _menuRef: React.RefObject = React.createRef(); + private _monacoRef: React.RefObject = React.createRef(); + private _tabsHostRef = React.createRef(); + private _tabsContentRef = React.createRef(); + private _disposableObservers: Observer[] = []; + private _activityBarRef = React.createRef(); + + public constructor(props: IMonacoComponentProps) { + super(props); + const gs = props.globalState; + const files = Object.keys(gs.files || {}); + const order = gs.filesOrder?.length ? gs.filesOrder.slice() : files.slice(); + + if (!gs.openEditors || gs.openEditors.length === 0) { + const defaultFile = gs.activeFilePath || gs.entryFilePath || files[0]; + if (defaultFile) { + gs.openEditors = [defaultFile]; + gs.activeEditorPath = defaultFile; + } + } + + this.state = { + files, + active: gs.activeFilePath, + order, + tabOrder: (gs.openEditors || []).slice(), + ctx: { open: false, x: 0, y: 0, path: null }, + theme: this._getCurrentTheme(), + dragOverIndex: -1, + explorerOpen: false, + sessionOpen: false, + searchOpen: false, + }; + + this._monacoManager = new MonacoManager(gs); + this._mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + if ((node as HTMLElement).tagName === "TEXTAREA") { + (node as HTMLTextAreaElement).contentEditable = "true"; + } + (node as HTMLElement).querySelectorAll?.("textarea").forEach((textArea) => ((textArea as HTMLTextAreaElement).contentEditable = "true")); + } + } + } + }); + } + + override componentDidMount() { + const gs = this.props.globalState; + + this._disposableObservers.push( + gs.onEditorFullcreenRequiredObservable.add(async () => { + const editorDiv = this.props.refObject.current! as HTMLElement & { + webkitRequestFullscreen?: (opts?: FullscreenOptions) => Promise | void; + msRequestFullscreen?: () => Promise | void; + mozRequestFullScreen?: () => Promise | void; + }; + + if (editorDiv.requestFullscreen) { + await editorDiv.requestFullscreen(); + } else if (editorDiv.webkitRequestFullscreen) { + await editorDiv.webkitRequestFullscreen(); + } + }) + ); + + this._disposableObservers.push(gs.onManifestChangedObservable.add(() => this.setState((s) => ({ ...s })))); + + this._disposableObservers.push( + gs.onFilesChangedObservable.add(() => { + const existing = new Set(Object.keys(gs.files)); + const filtered = gs.openEditors.filter((p) => existing.has(p)); + if (filtered.length !== gs.openEditors.length) { + gs.openEditors = filtered; + gs.onOpenEditorsChangedObservable?.notifyObservers(); + } + + if (gs.activeEditorPath && !existing.has(gs.activeEditorPath)) { + gs.activeEditorPath = filtered[0] ?? gs.entryFilePath ?? Object.keys(gs.files)[0]; + gs.onActiveEditorChangedObservable?.notifyObservers(); + if (gs.activeEditorPath) { + this._monacoManager.switchActiveFile(gs.activeEditorPath); + } + } + + const f = Object.keys(gs.files || {}); + const nextOrder = this._mergeOrder(this.state.order, f); + this.setState({ files: f, order: nextOrder }, this._scrollActiveIntoView); + }) + ); + + this._disposableObservers.push( + gs.onActiveFileChangedObservable.add(() => { + const p = this.props.globalState.activeFilePath; + if (!p) { + return; + } + if (!gs.openEditors.includes(p)) { + gs.openEditors = [...gs.openEditors, p]; + gs.onOpenEditorsChangedObservable?.notifyObservers(); + } + if (gs.activeEditorPath !== p) { + gs.activeEditorPath = p; + gs.onActiveEditorChangedObservable?.notifyObservers(); + } + this.setState((s) => ({ ...s })); + }) + ); + + this._disposableObservers.push( + gs.onOpenEditorsChangedObservable.add(() => { + // Reconcile local tab order with new open editors set + const open = gs.openEditors || []; + this.setState((s) => ({ + ...s, + tabOrder: this._mergeTabOrder(s.tabOrder, open), + })); + }) + ); + this._disposableObservers.push( + gs.onActiveEditorChangedObservable.add(() => { + const open = gs.openEditors || []; + this.setState((s) => ({ + ...s, + tabOrder: this._mergeTabOrder(s.tabOrder, open), + })); + }) + ); + + this._disposableObservers.push( + gs.onFilesOrderChangedObservable?.add(() => { + const ord = gs.filesOrder?.slice() || []; + this.setState({ order: this._mergeOrder(ord, Object.keys(gs.files || {})) }, this._scrollActiveIntoView); + }) + ); + + this._disposableObservers.push(gs.onThemeChangedObservable.add(() => this.setState({ theme: this._getCurrentTheme() }))); + + window.addEventListener("click", this._closeCtxMenu, { capture: true }); + window.addEventListener("keydown", this._handleKeyDown, { capture: true }); + + const hostElement = this._monacoRef.current!; + this._mutationObserver.observe(hostElement, { childList: true, subtree: true }); + void this._monacoManager.setupMonacoAsync(hostElement); + requestAnimationFrame(this._scrollActiveIntoView); + } + + override componentWillUnmount(): void { + this._mutationObserver.disconnect(); + window.removeEventListener("click", this._closeCtxMenu, { capture: true }); + window.removeEventListener("keydown", this._handleKeyDown, { capture: true }); + for (const d of this._disposableObservers) { + d.remove(); + } + this._disposableObservers = []; + this._monacoManager?.dispose(); + } + + override componentDidUpdate(_prevProps: any, prevState: IComponentState): void { + if (this.state.ctx.open && this._menuRef.current) { + const { x, y } = this.state.ctx; + const el = this._menuRef.current; + el.style.left = x + "px"; + el.style.top = y + "px"; + el.style.position = "fixed"; + } + const currentTheme = this._getCurrentTheme(); + if (this.state.theme !== currentTheme) { + this.setState({ theme: currentTheme }); + } + if (prevState.active !== this.state.active || prevState.files.length !== this.state.files.length || prevState.order.join("|") !== this.state.order.join("|")) { + this._scrollActiveIntoView(); + } + } + + private _getCurrentTheme(): "dark" | "light" { + return Utilities.ReadStringFromStore("theme", "Light") === "Dark" ? "dark" : "light"; + } + + private _mergeOrder(order: string[], files: string[]) { + const set = new Set(files); + const kept: string[] = []; + const seen = new Set(); + for (const p of order) { + if (set.has(p) && !seen.has(p)) { + kept.push(p); + seen.add(p); + } + } + const extras = files.filter((p) => !seen.has(p)); + return kept.concat(extras); + } + + private _toDisplay(path: string): string { + return path.replace(/^\/?src\//, ""); + } + private _toInternal(displayPath: string): string { + if (!displayPath) { + return displayPath; + } + return displayPath.startsWith("/") ? displayPath.slice(1) : displayPath; + } + + private _scrollActiveIntoView = () => { + const content = this._tabsContentRef.current; + if (!content) { + return; + } + const el = content.querySelector(`.pg-tab[data-path="${CSS.escape(this.props.globalState.activeEditorPath || "")}"]`); + el?.scrollIntoView({ inline: "nearest", block: "nearest", behavior: "smooth" }); + }; + + // ---------- Monaco-owned file ops ---------- + private _commitOrder(next: string[]) { + const deduped = this._dedupeOrder(next); + this.setState({ order: deduped }); + this.props.globalState.filesOrder = deduped.slice(); + this.props.globalState.onFilesOrderChangedObservable?.notifyObservers(); + } + private _dedupeOrder(arr: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const p of arr) { + if (!seen.has(p)) { + seen.add(p); + out.push(p); + } + } + return out; + } + + /** + * Merge existing local tab order with the current set of open editors. + * - Preserves relative order of already tracked tabs. + * - Appends any new open editors at the end. + * - Removes tabs that were closed. + * @param current Current local tab order array + * @param openEditors Current open editors from global state + * @returns New merged tab order array + */ + private _mergeTabOrder(current: string[], openEditors: string[]): string[] { + const openSet = new Set(openEditors); + const kept = current.filter((p) => openSet.has(p)); + for (const p of openEditors) { + if (!kept.includes(p)) { + kept.push(p); + } + } + return kept; + } + + private _openEditor = (path: string) => { + const gs = this.props.globalState; + if (!gs.openEditors.includes(path)) { + gs.openEditors = [...gs.openEditors, path]; + gs.onOpenEditorsChangedObservable?.notifyObservers(); + } + gs.activeEditorPath = path; + gs.onActiveEditorChangedObservable?.notifyObservers(); + this._monacoManager.switchActiveFile(path); + this.setState((s) => ({ tabOrder: this._mergeTabOrder(s.tabOrder, gs.openEditors) })); + }; + + private _closeEditor = (path: string) => { + const gs = this.props.globalState; + const idx = gs.openEditors.indexOf(path); + if (idx === -1) { + return; + } + + const wasActive = gs.activeEditorPath === path; + const next = gs.openEditors.filter((p) => p !== path); + gs.openEditors = next; + gs.onOpenEditorsChangedObservable?.notifyObservers(); + this.setState((s) => ({ tabOrder: s.tabOrder.filter((p) => p !== path) })); + + if (wasActive) { + const fallback = next[idx - 1] ?? next[idx] ?? Object.keys(gs.files)[0] ?? undefined; + gs.activeEditorPath = fallback; + gs.onActiveEditorChangedObservable?.notifyObservers(); + if (fallback) { + this._monacoManager.switchActiveFile(fallback); + } + } + }; + + private _closeOthers = (path: string) => { + const gs = this.props.globalState; + gs.openEditors = [path]; + gs.onOpenEditorsChangedObservable?.notifyObservers(); + if (gs.activeEditorPath !== path) { + gs.activeEditorPath = path; + gs.onActiveEditorChangedObservable?.notifyObservers(); + this._monacoManager.switchActiveFile(path); + } + this.setState({ tabOrder: [path] }); + }; + + private _closeAll = () => { + const gs = this.props.globalState; + gs.openEditors = []; + gs.activeEditorPath = undefined; + gs.onOpenEditorsChangedObservable?.notifyObservers(); + gs.onActiveEditorChangedObservable?.notifyObservers(); + const entry = gs.entryFilePath; + if (entry) { + this._openEditor(entry); + } + this.setState({ tabOrder: entry ? [entry] : [] }); + }; + + private _removeFile = (path: string) => { + if (this.props.globalState.entryFilePath === path) { + alert("You can’t delete the entry file."); + return; + } + const disp = this._toDisplay(path); + if (!confirm(`Delete ${disp}?`)) { + return; + } + this._monacoManager.removeFile(path); + const next = this.state.order.filter((p: string) => p !== path); + this._commitOrder(next); + }; + + private _setEntry = (path: string) => { + this.props.globalState.entryFilePath = path; + this.props.globalState.onManifestChangedObservable.notifyObservers(); + }; + + // Dialog confirm path (from ActivityBar) + private _confirmFileDialog = (type: DialogKind, filename: string, targetPath?: string) => { + const internal = this._toInternal(filename.trim()); + const filesMap = this.props.globalState.files || {}; + const exists = Object.prototype.hasOwnProperty.call(filesMap, internal); + + if (type === "create") { + if (!internal) { + return alert("Please enter a file name."); + } + if (exists) { + return alert("A file with that name already exists."); + } + this._monacoManager.addFile(internal, `// ${filename}`); + + if (Object.prototype.hasOwnProperty.call(this.props.globalState.files || {}, internal)) { + const next = this._dedupeOrder(this.state.order.concat(internal)); + this._commitOrder(next); + this._monacoManager.switchActiveFile(internal); + } + return; + } + + if (type === "rename" && targetPath) { + if (internal === targetPath || this._toDisplay(targetPath) === filename.trim()) { + return; + } + if (exists) { + return alert("A file with that name already exists."); + } + + this._monacoManager.renameFile(targetPath, internal); + const order = this.state.order.map((p: string) => (p === targetPath ? internal : p)); + this._commitOrder(order); + if (this.props.globalState.entryFilePath === targetPath) { + this.props.globalState.entryFilePath = internal; + this.props.globalState.onManifestChangedObservable.notifyObservers(); + } + return; + } + + if (type === "duplicate" && targetPath) { + if (exists) { + return alert("A file with that name already exists."); + } + const content = this.props.globalState.files[targetPath] ?? ""; + this._monacoManager.addFile(internal, content); + + if (Object.prototype.hasOwnProperty.call(this.props.globalState.files || {}, internal)) { + const next = this.state.order.slice(); + const idxInOrder = next.indexOf(targetPath); + const insertAt = idxInOrder === -1 ? next.length : idxInOrder + 1; + next.splice(insertAt, 0, internal); + this._commitOrder(this._dedupeOrder(next)); + this._monacoManager.switchActiveFile(internal); + } + return; + } + }; + + // ---------- Keyboard ---------- + private _handleKeyDown = (e: KeyboardEvent) => { + // New file + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "g") { + e.preventDefault(); + e.stopPropagation(); + this._activityBarRef.current?.openCreateDialog(); + return; + } + // Open search + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "f") { + e.preventDefault(); + e.stopPropagation(); + this.setState({ + searchOpen: true, + explorerOpen: false, + sessionOpen: false, + }); + return; + } + // Open File Explorer + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "e") { + e.preventDefault(); + e.stopPropagation(); + this.setState({ + searchOpen: false, + explorerOpen: true, + sessionOpen: false, + }); + return; + } + // Toggle Activity Bar + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") { + e.preventDefault(); + e.stopPropagation(); + this.setState((s) => ({ + searchOpen: false, + explorerOpen: !s.explorerOpen, + sessionOpen: false, + })); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + const handleSecondKey = (e2: KeyboardEvent) => { + if (e2.key.toLowerCase() === "w") { + e2.preventDefault(); + const activeTab = this.props.globalState.activeEditorPath; + if (activeTab) { + this._closeOthers(activeTab); + } + } + if ((e2.metaKey || e2.ctrlKey) && e2.key.toLowerCase() === "w") { + e2.preventDefault(); + this._closeAll(); + } + window.removeEventListener("keydown", handleSecondKey); + }; + window.addEventListener("keydown", handleSecondKey); + } + }; + + // ---------- Context menu ---------- + private _openCtxMenu = (e: React.MouseEvent, path: string) => { + e.preventDefault(); + this.setState({ ctx: { open: true, x: e.clientX, y: e.clientY, path } }); + }; + private _closeCtxMenu = (e?: MouseEvent) => { + if (!this.state.ctx.open) { + return; + } + if (e && (e.target as HTMLElement).closest(".pg-tab-menu")) { + return; + } + this.setState({ ctx: { open: false, x: 0, y: 0, path: null } }); + }; + + // ---------- Drag & drop (tabs) ---------- + private _onDragStart = (e: React.DragEvent, path: string) => { + this._draggingPath = path; + e.dataTransfer.setData("text/plain", path); + e.dataTransfer.effectAllowed = "move"; + const target = e.target as HTMLElement; + const rect = target.getBoundingClientRect(); + e.dataTransfer.setDragImage(target, rect.width / 2, rect.height / 2); + }; + private _onDragOver = (e: React.DragEvent, targetPath: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (this._draggingPath && this._draggingPath !== targetPath) { + const targetIndex = this.state.tabOrder.indexOf(targetPath); + if (targetIndex !== -1) { + this.setState({ dragOverIndex: targetIndex }); + } + } + }; + private _onDrop = (e: React.DragEvent, targetPath: string) => { + e.preventDefault(); + const src = this._draggingPath || e.dataTransfer.getData("text/plain"); + this._draggingPath = null; + this.setState({ dragOverIndex: -1 }); + if (!src || src === targetPath) { + return; + } + // Reorder open editor tabs (visual tab order) instead of full file order. + const order = [...this.state.tabOrder]; + const from = order.indexOf(src); + const to = order.indexOf(targetPath); + if (from === -1 || to === -1 || from === to) { + return; + } + const [moved] = order.splice(from, 1); + order.splice(to, 0, moved); + this.setState({ tabOrder: order, dragOverIndex: -1 }); + }; + private _onDragEnd = () => { + this._draggingPath = null; + this.setState({ dragOverIndex: -1 }); + }; + private _onMouseDownTab = (e: React.MouseEvent, path: string) => { + if (e.button === 1) { + e.preventDefault(); + if (this.props.globalState.entryFilePath === path) { + return; + } + this._removeFile(path); + } + }; + + public override render() { + const { theme, explorerOpen, searchOpen, sessionOpen } = this.state; + const entry = this.props.globalState.entryFilePath; + + return ( +
+
+ {/* Far-left Activity Bar */} + this.setState((s) => ({ searchOpen: !s.searchOpen, explorerOpen: false, sessionOpen: false }))} + explorerOpen={explorerOpen} + onToggleExplorer={() => this.setState((s) => ({ explorerOpen: !s.explorerOpen, sessionOpen: false, searchOpen: false }))} + sessionOpen={sessionOpen} + onToggleSession={() => this.setState((s) => ({ sessionOpen: !s.sessionOpen, explorerOpen: false, searchOpen: false }))} + /> + + {/* Split between Explorer (left) and Main (right) */} + + {/* Left: Explorer panel (can be hidden) */} + {explorerOpen ? ( +
+
+ EXPLORER +
+ +
+
+ this._activityBarRef.current?.openCreateDialog()} + onRename={(oldPath) => this._activityBarRef.current?.openRenameDialog(oldPath, this._toDisplay(oldPath))} + onDelete={(p) => this._removeFile(p)} + /> +
+ ) : searchOpen ? ( +
+
+ SEARCH +
+
+ { + const p = path; + this._openEditor(p); + const ed = this._monacoManager.editorHost.editor; + if (ed) { + ed.revealRangeInCenter(range, 0 /** ScrollType::Smooth */); + ed.setSelection(range); + ed.focus(); + } + }} + /> +
+ ) : ( +
+ )} + {explorerOpen || searchOpen ? :
} + + {/* Right: Tabs (top) + Monaco (below) */} +
+ {/* Tabs Bar */} +
+
{ + this._activityBarRef.current?.openCreateDialog(); + e.stopPropagation(); + }} + ref={this._tabsHostRef} + > +
+ {this.state.tabOrder + .filter((p) => (this.props.globalState.openEditors || []).includes(p)) + .map((p) => { + const isActive = p === this.props.globalState.activeEditorPath; + const display = this._toDisplay(p); + const isEntry = p === entry; + return ( +
this._openEditor(p)} + onContextMenu={(e) => this._openCtxMenu(e, p)} + onMouseDown={(e) => this._onMouseDownTab(e, p)} + onDragStart={(e) => this._onDragStart(e, p)} + onDragOver={(e) => this._onDragOver(e, p)} + onDrop={(e) => this._onDrop(e, p)} + onDragEnd={this._onDragEnd} + title={`${display}${isEntry ? " (entry)" : ""}${isActive ? " (active)" : ""}`} + > + {isEntry && ( + + {"★"} + + )} + {display} + +
+ ); + })} +
+
+
+ + {/* Editor host */} +
+
+ +
+ + {/* Tab context menu */} + {this.state.ctx.open && + this.state.ctx.path && + (() => { + const isEntryTarget = this.state.ctx.path === entry; + return ( +
e.stopPropagation()}> + + + +
+ + +
+ +
+ +
+ ); + })()} +
+ ); + } +} diff --git a/packages/tools/playground/src/components/editor/searchPanelComponent.tsx b/packages/tools/playground/src/components/editor/searchPanelComponent.tsx new file mode 100644 index 00000000000..7e0f374a7cd --- /dev/null +++ b/packages/tools/playground/src/components/editor/searchPanelComponent.tsx @@ -0,0 +1,351 @@ +// components/searchPanel.tsx +/* eslint-disable jsdoc/require-jsdoc */ +import * as React from "react"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { debounce } from "ts-debounce"; +import RegexIcon from "./icons/regex.svg"; +import WholeWordIcon from "./icons/wholeWord.svg"; +import CaseSensitiveIcon from "./icons/caseSensitive.svg"; +import ReplaceIcon from "./icons/replace.svg"; +import ReplaceAllIcon from "./icons/replaceAll.svg"; +import SearchIcon from "./icons/search.svg"; +import { Icon } from "./iconComponent"; + +import "../../scss/search.scss"; + +type Match = { + filePath: string; + range: monaco.IRange; + lineText: string; + previewStartCol: number; + previewEndCol: number; +}; + +type ResultsByFile = Record; + +function EscapeRegExp(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export const SearchPanel: React.FC<{ + onOpenAt: (path: string, range: monaco.IRange) => void; +}> = ({ onOpenAt }) => { + const [query, setQuery] = React.useState(""); + const [replacement, setReplacement] = React.useState(""); + const [useRegex, setUseRegex] = React.useState(false); + const [matchCase, setMatchCase] = React.useState(false); + const [wholeWord, setWholeWord] = React.useState(false); + + const [results, setResults] = React.useState({}); + const [isSearching, setIsSearching] = React.useState(false); + const [expanded, setExpanded] = React.useState>({}); + + const filesCount = Object.keys(results).length; + const matchesCount = React.useMemo(() => Object.values(results).reduce((s, arr) => s + arr.length, 0), [results]); + + // Build a preview window around the match for nice context + const buildMatchPreview = React.useCallback((model: monaco.editor.ITextModel, rng: monaco.IRange) => { + const line = model.getLineContent(rng.startLineNumber); + // Show ~40 chars around the match + const context = 40; + const start = Math.max(1, rng.startColumn - context); + const end = Math.min(model.getLineMaxColumn(rng.startLineNumber), rng.endColumn + context); + + return { + lineText: line.slice(start - 1, end - 1), + previewStartCol: rng.startColumn - start + 1, + previewEndCol: rng.endColumn - start + 1, + }; + }, []); + + const compilePattern = React.useCallback(() => { + if (!query) { + return null; + } + + if (useRegex || wholeWord) { + const source = useRegex ? query : `\\b${EscapeRegExp(query)}\\b`; + const flags = matchCase ? "g" : "gi"; + try { + return new RegExp(source, flags); + } catch { + return null; + } + } + + return null; + }, [query, useRegex, matchCase, wholeWord]); + + const doSearch = React.useCallback(() => { + const q = query.trim(); + if (!q) { + setResults({}); + return; + } + setIsSearching(true); + + const models = monaco.editor.getModels(); + + const byFile: ResultsByFile = {}; + const rx = compilePattern(); + + for (const model of models) { + const uriStr = model.uri.toString(); + if (!uriStr.startsWith("file:///pg/")) { + continue; + } + let found: monaco.editor.FindMatch[] = []; + + if (rx) { + // Regex path + found = model.findMatches(rx.source, false, true, matchCase, null, true, 10000); + } else { + // Plain substring path + found = model.findMatches(q, false, false, matchCase, null, true, 10000); + } + + if (!found.length) { + continue; + } + + const filePath = model.uri.path || uriStr; + const matches: Match[] = found.map((mm) => { + const rng = mm.range; + const { lineText, previewStartCol, previewEndCol } = buildMatchPreview(model, rng); + return { filePath, range: rng, lineText, previewStartCol, previewEndCol }; + }); + + byFile[filePath] = matches; + } + + // Expand sections that have matches (default: expanded) + const nextExpanded: Record = {}; + for (const f of Object.keys(byFile)) { + nextExpanded[f] = true; + } + + setExpanded(nextExpanded); + setResults(byFile); + setIsSearching(false); + }, [query, matchCase, buildMatchPreview, compilePattern]); + + const debouncedSearch = React.useMemo(() => debounce(doSearch, 250, { maxWait: 600 }), [doSearch]); + + React.useEffect(() => { + // eslint-disable-next-line + debouncedSearch(); + return () => { + try { + debouncedSearch.cancel(); + } catch {} + }; + }, [debouncedSearch, query, matchCase, useRegex, wholeWord]); + + const runSearchNow = React.useCallback(() => { + try { + debouncedSearch.cancel(); + } catch {} + doSearch(); + }, [debouncedSearch, doSearch]); + + // Replace helpers + const replaceInFile = React.useCallback( + (filePath: string) => { + const model = monaco.editor.getModels().find((m) => (m.uri.path || m.uri.toString()) === filePath); + if (!model || !results[filePath]?.length) { + return; + } + + const rx = compilePattern(); + const edits: monaco.editor.IIdentifiedSingleEditOperation[] = []; + + // Apply bottom-up so earlier edits don't disturb later ranges + const matches = [...results[filePath]].sort((a, b) => + a.range.startLineNumber === b.range.startLineNumber ? b.range.startColumn - a.range.startColumn : b.range.startLineNumber - a.range.startLineNumber + ); + + for (const m of matches) { + const text = model.getValueInRange(m.range); + let newText = replacement; + if (rx) { + try { + // allow $1 backrefs when regex toggle is on or whole-word (regex) is used + const realRx = new RegExp(rx.source, matchCase ? "g" : "gi"); + newText = text.replace(realRx, replacement); + } catch { + // fallback: literal replacement + newText = replacement; + } + } + edits.push({ range: m.range, text: newText, forceMoveMarkers: true }); + } + + if (edits.length) { + model.pushEditOperations([], edits, () => null); + } + + // Trigger a refresh + runSearchNow(); + }, + [results, replacement, compilePattern, matchCase, runSearchNow] + ); + + const replaceAll = React.useCallback(() => { + // Confirm with window.alert + if (!window.confirm(`Are you sure you want to replace all occurrences over ${Object.keys(results).length} files?`)) { + return; + } + for (const file of Object.keys(results)) { + replaceInFile(file); + } + }, [results, replaceInFile]); + + return ( +
+
+
+