diff --git a/.github/actions/prepare-playground/action.yml b/.github/actions/prepare-playground/action.yml index 0a2e1c94f4..859454033e 100644 --- a/.github/actions/prepare-playground/action.yml +++ b/.github/actions/prepare-playground/action.yml @@ -5,7 +5,7 @@ runs: steps: - name: Fetch trunk shell: bash - run: git fetch origin trunk --depth=1 + run: git fetch origin trunk --depth=1 --recurse-submodules - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/build-website.yml b/.github/workflows/build-website.yml index 58ef6f8ae0..00e29886d6 100644 --- a/.github/workflows/build-website.yml +++ b/.github/workflows/build-website.yml @@ -29,7 +29,9 @@ jobs: environment: name: playground-wordpress-net-wp-cloud steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - run: npm run build - run: tar -czf wasm-wordpress-net.tar.gz dist/packages/playground/wasm-wordpress-net diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f85003f768..7b0eed0ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - run: npx nx affected --target=lint - run: npx nx affected --target=typecheck @@ -26,6 +28,8 @@ jobs: needs: [lint-and-typecheck] steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - run: node --expose-gc node_modules/nx/bin/nx affected --target=test --configuration=ci test-e2e: @@ -34,6 +38,8 @@ jobs: # Run as root to allow node to bind to port 80 steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - run: sudo ./node_modules/.bin/cypress install --force - run: sudo CYPRESS_CI=1 npx nx e2e playground-website --configuration=ci --verbose @@ -49,6 +55,8 @@ jobs: needs: [lint-and-typecheck] steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - name: Install Playwright Browsers run: sudo npx playwright install --with-deps @@ -70,6 +78,8 @@ jobs: part: ['chromium', 'firefox', 'webkit'] steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - name: Download dist uses: actions/download-artifact@v4 @@ -104,6 +114,8 @@ jobs: needs: [lint-and-typecheck] steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - run: npx nx affected --target=build --parallel=3 --verbose @@ -128,6 +140,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: true - uses: ./.github/actions/prepare-playground - run: npm run build:docs - uses: actions/upload-pages-artifact@v1 diff --git a/.github/workflows/publish-npm-packages.yml b/.github/workflows/publish-npm-packages.yml index eb744c272b..5ba59b18ac 100644 --- a/.github/workflows/publish-npm-packages.yml +++ b/.github/workflows/publish-npm-packages.yml @@ -40,6 +40,7 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} clean: true persist-credentials: false + submodules: true - name: Config git user run: | git config --global user.name "deployment_bot" diff --git a/.github/workflows/refresh-sqlite-integration.yml b/.github/workflows/refresh-sqlite-integration.yml index 6088e40f83..dc82866ca5 100644 --- a/.github/workflows/refresh-sqlite-integration.yml +++ b/.github/workflows/refresh-sqlite-integration.yml @@ -23,11 +23,12 @@ jobs: concurrency: group: check-version-and-run-build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} clean: true persist-credentials: false + submodules: true - uses: ./.github/actions/prepare-playground - name: 'Refresh the SQLite bundle' shell: bash diff --git a/.github/workflows/refresh-wordpress-major-and-beta.yml b/.github/workflows/refresh-wordpress-major-and-beta.yml index a4336d79c3..40b412d06f 100644 --- a/.github/workflows/refresh-wordpress-major-and-beta.yml +++ b/.github/workflows/refresh-wordpress-major-and-beta.yml @@ -28,11 +28,12 @@ jobs: concurrency: group: check-version-and-run-build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} clean: true persist-credentials: false + submodules: true - name: 'Install bun' run: | curl -fsSL https://bun.sh/install | bash diff --git a/.github/workflows/refresh-wordpress-nightly.yml b/.github/workflows/refresh-wordpress-nightly.yml index ed1ea9abde..df415dce13 100644 --- a/.github/workflows/refresh-wordpress-nightly.yml +++ b/.github/workflows/refresh-wordpress-nightly.yml @@ -21,11 +21,12 @@ jobs: environment: name: wordpress-assets steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} clean: true persist-credentials: false + submodules: true - uses: ./.github/actions/prepare-playground - name: 'Install bun' run: | diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index fc9c29a2ab..9313c6799c 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -31,14 +31,15 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: + submodules: true ref: trunk clean: true persist-credentials: false - name: Fetch trunk shell: bash - run: git fetch origin trunk --depth=1 + run: git fetch origin trunk --depth=1 --recurse-submodules - name: 'Install bun (for the changelog)' run: | curl -fsSL https://bun.sh/install | bash diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..3a4bb43c06 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "isomorphic-git"] + path="isomorphic-git" + url=git@github.com:adamziel/isomorphic-git.git diff --git a/README.md b/README.md index 75b8bd2135..3d19e4f660 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ The vanilla `git clone` command will take ages. Here's a faster alternative that only pull the latest revision of the trunk branch: ``` -git clone -b trunk --single-branch --depth 1 git@github.com:WordPress/wordpress-playground.git +git clone -b trunk --single-branch --depth 1 --recurse-submodules git@github.com:WordPress/wordpress-playground.git ``` ## Running WordPress Playground locally @@ -92,7 +92,7 @@ git clone -b trunk --single-branch --depth 1 git@github.com:WordPress/wordpress- You also can run WordPress Playground locally as follows: ```bash -git clone -b trunk --single-branch --depth 1 git@github.com:WordPress/wordpress-playground.git +git clone -b trunk --single-branch --depth 1 --recurse-submodules git@github.com:WordPress/wordpress-playground.git cd wordpress-playground npm install npm run dev diff --git a/isomorphic-git b/isomorphic-git new file mode 160000 index 0000000000..cdca7e5dbf --- /dev/null +++ b/isomorphic-git @@ -0,0 +1 @@ +Subproject commit cdca7e5dbf9bc4654eab3465ceab64a54ab30a76 diff --git a/package-lock.json b/package-lock.json index b8f8ca9bc2..ce6214df52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,21 +20,27 @@ "@types/react-transition-group": "4.4.11", "@types/wicg-file-system-access": "2023.10.5", "ajv": "8.12.0", + "async-lock": "1.4.1", "axios": "1.6.1", "classnames": "^2.3.2", "comlink": "^4.4.1", + "crc-32": "1.2.2", + "diff3": "0.0.4", "express": "4.19.2", "file-saver": "^2.0.5", "fs-extra": "11.1.1", "ini": "4.1.2", "octokit": "3.1.1", "octokit-plugin-create-pull-request": "5.1.1", + "pako": "1.0.10", "react": "^18.2.25", "react-dom": "^18.2.25", "react-hook-form": "7.53.0", "react-modal": "^3.16.1", "react-redux": "8.1.3", "react-transition-group": "4.4.5", + "sha.js": "2.4.11", + "sha1": "1.1.1", "unzipper": "0.10.11", "vite-plugin-api": "1.0.4", "wouter": "3.3.5", @@ -19795,6 +19801,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "license": "MIT" @@ -21490,6 +21502,15 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -22589,6 +22610,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "dev": true, @@ -22669,6 +22702,15 @@ "dev": true, "license": "MIT" }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/crypto-random-string": { "version": "2.0.0", "dev": true, @@ -24328,6 +24370,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/diff3": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.4.tgz", + "integrity": "sha512-f1rQ7jXDn/3i37hdwRk9ohqcvLRH3+gEIgmA6qEM280WUOh7cOr3GXV8Jm5sPwUs46Nzl48SE8YNLGJoaLuodg==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "dev": true, @@ -36467,6 +36515,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/pako": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz", + "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "dev": true, @@ -41320,6 +41374,32 @@ "version": "1.2.0", "license": "ISC" }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, + "node_modules/sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha512-dZBS6OrMjtgVkopB1Gmo4RQCDKiZsqcpAQpkV/aaj+FCrCg8r4I4qMkDPQjBgLIxlmu9k4nUbWq6ohXahOneYA==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": ">= 0.0.1", + "crypt": ">= 0.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, diff --git a/package.json b/package.json index d3763de549..4a6fed2903 100644 --- a/package.json +++ b/package.json @@ -61,21 +61,27 @@ "@types/react-transition-group": "4.4.11", "@types/wicg-file-system-access": "2023.10.5", "ajv": "8.12.0", + "async-lock": "1.4.1", "axios": "1.6.1", "classnames": "^2.3.2", "comlink": "^4.4.1", + "crc-32": "1.2.2", + "diff3": "0.0.4", "express": "4.19.2", "file-saver": "^2.0.5", "fs-extra": "11.1.1", "ini": "4.1.2", "octokit": "3.1.1", "octokit-plugin-create-pull-request": "5.1.1", + "pako": "1.0.10", "react": "^18.2.25", "react-dom": "^18.2.25", "react-hook-form": "7.53.0", "react-modal": "^3.16.1", "react-redux": "8.1.3", "react-transition-group": "4.4.5", + "sha.js": "2.4.11", + "sha1": "1.1.1", "unzipper": "0.10.11", "vite-plugin-api": "1.0.4", "wouter": "3.3.5", diff --git a/packages/docs/site/docs/developers/23-architecture/18-host-your-own-playground.md b/packages/docs/site/docs/developers/23-architecture/18-host-your-own-playground.md index f5969e9f85..cdc822ea38 100644 --- a/packages/docs/site/docs/developers/23-architecture/18-host-your-own-playground.md +++ b/packages/docs/site/docs/developers/23-architecture/18-host-your-own-playground.md @@ -58,7 +58,7 @@ The most flexible and customizable method is to build the site locally. Create a shallow clone of the Playground repository, or your own fork. ```sh -git clone -b trunk --single-branch --depth 1 git@github.com:WordPress/wordpress-playground.git +git clone -b trunk --single-branch --depth 1 --recurse-submodules git@github.com:WordPress/wordpress-playground.git ``` Enter the `wordpress-playground` directory. diff --git a/packages/docs/site/docs/main/contributing/code.md b/packages/docs/site/docs/main/contributing/code.md index fa1c084d83..b35c447767 100644 --- a/packages/docs/site/docs/main/contributing/code.md +++ b/packages/docs/site/docs/main/contributing/code.md @@ -26,7 +26,7 @@ Be sure to review the following resources before you begin: [Fork the Playground repository](https://github.com/WordPress/wordpress-playground/fork) and clone it to your local machine. To do that, copy and paste these commands into your terminal: ```bash -git clone -b trunk --single-branch --depth 1 +git clone -b trunk --single-branch --depth 1 --recurse-submodules # replace `YOUR-GITHUB-USERNAME` with your GitHub username: git@github.com:YOUR-GITHUB-USERNAME/wordpress-playground.git diff --git a/packages/playground/components/index.html b/packages/playground/components/index.html index 840d9d8632..f36f85e15f 100644 --- a/packages/playground/components/index.html +++ b/packages/playground/components/index.html @@ -7,6 +7,6 @@
- + diff --git a/packages/playground/components/src/FilePickerControl/PathPreview.tsx b/packages/playground/components/src/FilePickerControl/PathPreview.tsx new file mode 100644 index 0000000000..2e1f516465 --- /dev/null +++ b/packages/playground/components/src/FilePickerControl/PathPreview.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import css from './style.module.css'; + +export function PathPreview({ path }: { path: string }) { + if (!path) { + return ( +
+ Select a path +
+ ); + } + + const segments = path.split('/'); + let pathPreviewEnd = (segments.length > 2 ? '/' : '') + segments.pop(); + if (pathPreviewEnd.length > 10) { + pathPreviewEnd = pathPreviewEnd.substring(pathPreviewEnd.length - 10); + } + const pathPreviewStart = path.substring( + 0, + path.length - pathPreviewEnd.length + ); + return ( +
+ ); +} diff --git a/packages/playground/components/src/FilePickerControl/index.tsx b/packages/playground/components/src/FilePickerControl/index.tsx new file mode 100644 index 0000000000..707752a6b3 --- /dev/null +++ b/packages/playground/components/src/FilePickerControl/index.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Button, Modal } from '@wordpress/components'; +import { PathPreview } from './PathPreview'; +import css from './style.module.css'; +import type { FileNode } from '../FilePickerTree'; +import FilePickerTree from '../FilePickerTree'; + +export function FilePickerControl({ + value = '', + onChange, + files = [], + isLoading = false, + error = undefined, +}: { + value?: string; + onChange: (selectedPath: string) => void; + files?: FileNode[]; + isLoading?: boolean; + error?: string; +}) { + const [isOpen, setOpen] = useState(false); + const openModal = () => setOpen(true); + const closeModal = () => setOpen(false); + + const [lastSelectedPath, setLastSelectedPath] = useState( + value || null + ); + function handleSubmit(event?: React.FormEvent) { + event?.preventDefault(); + onChange(lastSelectedPath || ''); + closeModal(); + } + + return ( + <> + + {isOpen && ( + +
+ +
+ +
+ +
+ )} + + ); +} diff --git a/packages/playground/components/src/FilePickerControl/style.module.css b/packages/playground/components/src/FilePickerControl/style.module.css new file mode 100644 index 0000000000..210ebe6b06 --- /dev/null +++ b/packages/playground/components/src/FilePickerControl/style.module.css @@ -0,0 +1,91 @@ +.control { + border: 1px solid #ddd; + border-radius: 4px; + padding: 2px; + display: flex; + align-items: center; + width: 230px; + gap: 8px; + cursor: pointer; + + .browse-label { + display: inline-flex; + text-decoration: none; + font-family: inherit; + font-weight: normal; + font-size: 13px; + margin: 0; + border: 0; + cursor: pointer; + -webkit-appearance: none; + background: none; + transition: box-shadow 0.1s linear; + align-items: center; + box-sizing: border-box; + padding: 6px 12px; + border-radius: 2px; + border: 1px solid #ddd; + } + + .selected-path { + width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .path-preview { + overflow: hidden; + color: #000 !important; + } +} + +.modal :global(.components-modal__content) { + margin-bottom: 80px; + padding: 0; +} + +.modal-footer { + position: absolute; + left: 0; + bottom: 0; + flex-direction: column; + flex-wrap: nowrap; + min-height: 80px; + padding: 16px 24px; + gap: 8px; + box-sizing: border-box; + border-top: 1px solid #ddd; + background: #fff; + display: flex; + align-items: flex-end; + justify-content: center; + width: 100%; +} + +.path-mapping-button { + height: 30px; +} + +.path-preview { + display: flex; + align-items: center; + justify-content: center; +} + +.path-preview::before, +.path-preview::after { + overflow: hidden; + white-space: pre; +} + +.path-preview::before { + content: attr(data-content-start); + text-overflow: ellipsis; + flex-grow: 1; +} + +.path-preview::after { + content: attr(data-content-end); + flex-shrink: 0; +} diff --git a/packages/playground/components/src/FilePickerTree/index.tsx b/packages/playground/components/src/FilePickerTree/index.tsx new file mode 100644 index 0000000000..eabfff1de4 --- /dev/null +++ b/packages/playground/components/src/FilePickerTree/index.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + __experimentalTreeGrid as TreeGrid, + __experimentalTreeGridRow as TreeGridRow, + __experimentalTreeGridCell as TreeGridCell, + Button, + Spinner, +} from '@wordpress/components'; +import { Icon, chevronRight, chevronDown } from '@wordpress/icons'; +import '@wordpress/components/build-style/style.css'; +import css from './style.module.css'; +import classNames from 'classnames'; +import { folder, file } from '../icons'; + +export type FileNode = { + name: string; + type: 'file' | 'folder'; + children?: FileNode[]; +}; + +export type FilePickerControlProps = { + files: FileNode[]; + initialPath?: string; + onSelect?: (path: string) => void; + isLoading?: boolean; + error?: string; +}; + +type ExpandedNodePaths = Record; + +const FilePickerTree: React.FC = ({ + isLoading = false, + error = undefined, + files, + initialPath, + onSelect = () => {}, +}) => { + const [expanded, setExpanded] = useState(() => { + if (!initialPath) { + return {}; + } + const expanded: ExpandedNodePaths = {}; + const pathParts = initialPath.split('/'); + for (let i = 0; i < pathParts.length; i++) { + const pathSoFar = pathParts.slice(0, i + 1).join('/'); + expanded[pathSoFar] = true; + } + return expanded; + }); + const [selectedPath, setSelectedPath] = useState(() => + initialPath ? initialPath : null + ); + + const expandNode = (path: string, isOpen: boolean) => { + setExpanded((prevState) => ({ + ...prevState, + [path]: isOpen, + })); + }; + + const selectPath = (path: string) => { + setSelectedPath(path); + onSelect(path); + }; + + const generatePath = (node: FileNode, parentPath = ''): string => { + return parentPath + ? `${parentPath}/${node.name}`.replaceAll(/\/+/g, '/') + : node.name; + }; + + const [searchBuffer, setSearchBuffer] = useState(''); + const searchBufferTimeoutRef = useRef(null); + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key.length === 1 && event.key.match(/\S/)) { + const newSearchBuffer = searchBuffer + event.key.toLowerCase(); + setSearchBuffer(newSearchBuffer); + // Clear the buffer after 1 second + if (searchBufferTimeoutRef.current) { + clearTimeout(searchBufferTimeoutRef.current); + } + searchBufferTimeoutRef.current = setTimeout(() => { + setSearchBuffer(''); + }, 1000); + + if (thisContainerRef.current) { + const buttons = Array.from( + thisContainerRef.current.querySelectorAll( + '.file-node-button' + ) + ); + const activeElement = document.activeElement; + let startIndex = 0; + if ( + activeElement && + buttons.includes(activeElement as HTMLButtonElement) + ) { + startIndex = buttons.indexOf( + activeElement as HTMLButtonElement + ); + } + for (let i = 0; i < buttons.length; i++) { + const index = (startIndex + i) % buttons.length; + const button = buttons[index]; + if ( + button.textContent + ?.toLowerCase() + .trim() + .startsWith(newSearchBuffer) + ) { + (button as HTMLButtonElement).focus(); + break; + } + } + } + } else { + // Clear the buffer for any non-letter key press + setSearchBuffer(''); + if (searchBufferTimeoutRef.current) { + clearTimeout(searchBufferTimeoutRef.current); + } + } + } + + const thisContainerRef = useRef(null); + + useEffect(() => { + // automatically focus the first button when the files are loaded + if (thisContainerRef.current) { + const firstButton = initialPath + ? thisContainerRef.current.querySelector( + `[data-path="${initialPath}"]` + ) + : thisContainerRef.current.querySelector('.file-node-button'); + if (firstButton) { + (firstButton as HTMLButtonElement).focus(); + } + } + }, [files.length > 0]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Error loading files

+

{error}

+
+ ); + } + + return ( +
+ + {files.map((file, index) => ( + + ))} + +
+ ); +}; + +const NodeRow: React.FC<{ + node: FileNode; + level: number; + position: number; + setSize: number; + expandedNodePaths: ExpandedNodePaths; + expandNode: (path: string, isOpen: boolean) => void; + selectPath: (path: string) => void; + selectedNode: string | null; + generatePath: (node: FileNode, parentPath?: string) => string; + parentPath?: string; + parentMapping?: string; +}> = ({ + node, + level, + position, + setSize, + expandedNodePaths, + expandNode, + selectPath, + generatePath, + parentPath = '', + selectedNode, +}) => { + const path = generatePath(node, parentPath); + const isExpanded = expandedNodePaths[path]; + + const toggleOpen = () => expandNode(path, !isExpanded); + + const handleKeyDown = (event: any) => { + if (event.key === 'ArrowLeft') { + if (isExpanded) { + toggleOpen(); + } else { + ( + document.querySelector( + `[data-path="${parentPath}"]` + ) as HTMLButtonElement + )?.focus(); + } + event.preventDefault(); + event.stopPropagation(); + } else if (event.key === 'ArrowRight') { + if (isExpanded) { + if (node.children?.length) { + const firstChildPath = generatePath(node.children[0], path); + ( + document.querySelector( + `[data-path="${firstChildPath}"]` + ) as HTMLButtonElement + )?.focus(); + } + } else { + toggleOpen(); + } + event.preventDefault(); + event.stopPropagation(); + } else if (event.key === 'Space') { + expandNode(path, !isExpanded); + } else if (event.key === 'Enter') { + const form = event.currentTarget?.closest('form'); + if (form) { + setTimeout(() => { + form.dispatchEvent(new Event('submit', { bubbles: true })); + }); + } + } + }; + return ( + <> + + + {() => ( + + )} + + + {isExpanded && + node.children && + node.children.map((child, index) => ( + + ))} + + ); +}; + +const FileName: React.FC<{ + node: FileNode; + level: number; + isOpen?: boolean; +}> = ({ node, level, isOpen }) => { + const indent: string[] = []; + for (let i = 0; i < level; i++) { + indent.push('    '); + } + return ( + <> + + {node.type === 'folder' ? ( + + ) : ( +
 
+ )} + + {node.name} + + ); +}; + +export default FilePickerTree; diff --git a/packages/playground/components/src/FilePickerTree/style.module.css b/packages/playground/components/src/FilePickerTree/style.module.css new file mode 100644 index 0000000000..7bfa7b81d0 --- /dev/null +++ b/packages/playground/components/src/FilePickerTree/style.module.css @@ -0,0 +1,90 @@ +.file-picker-tree { + width: 100%; + + tr:nth-child(even) { + background-color: #f7f7f7; + } + + &:active { + color: var(--wp-admin-theme-color); + } + + /* &:focus { + outline: 2px solid var(--wp-admin-theme-color); + } */ +} + +.file-node-button { + display: inline-flex; + text-decoration: none; + font-family: inherit; + font-weight: normal; + font-size: 13px; + margin: 0; + border: 0; + cursor: pointer; + -webkit-appearance: none; + background: none; + transition: box-shadow 0.1s linear; + height: 30px; + align-items: center; + box-sizing: border-box; + padding: 6px 12px; + border-radius: 2px; + + width: 100%; + white-space: nowrap; + color: var(--wp-components-color-foreground, #1e1e1e); + background: transparent; + padding: 6px; + + font-family: -apple-system, 'system-ui', 'Segoe UI', Roboto, Oxygen-Sans, + Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; + font-size: 13px; + font-weight: 400; + + &:focus, + .selected { + background-color: var(--wp-admin-theme-color); + color: #fff; + outline: 0 !important; + box-shadow: none !important; + } +} + +.file-name { + margin-left: 10px; +} + +.path-picker-control { + border: 1px solid #ddd; + border-radius: 4px; + padding: 2px; + display: flex; + align-items: center; + width: 230px; + gap: 8px; + cursor: pointer; +} + +.selected-path { + width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.error-container, +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; +} + +.loading-container :global(.components-spinner) { + width: 40px; + height: 40px; +} diff --git a/packages/playground/components/src/PathMappingControl/PathMappingControl.tsx b/packages/playground/components/src/PathMappingControl/PathMappingControl.tsx deleted file mode 100644 index be3d1e1b66..0000000000 --- a/packages/playground/components/src/PathMappingControl/PathMappingControl.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - __experimentalTreeGrid as TreeGrid, - __experimentalTreeGridRow as TreeGridRow, - __experimentalTreeGridCell as TreeGridCell, - __experimentalInputControl as InputControl, - Button, - SelectControl, -} from '@wordpress/components'; -import { Icon, chevronRight, chevronDown } from '@wordpress/icons'; -import '@wordpress/components/build-style/style.css'; -import './style.css'; - -type FileNode = { - name: string; - type: 'file' | 'folder'; - children?: FileNode[]; -}; - -type PathMappingFormProps = { - files: FileNode[]; - initialState: MappingNodeStates; - onMappingChange?: (mapping: PathMapping) => void; -}; -type PathMapping = Record; -type MappingNodeStates = Record; - -type MappingNodeState = { - isOpen?: boolean; - pathType?: string; - playgroundPath?: string; -}; - -const PathMappingControl: React.FC = ({ - files, - initialState = {}, - onMappingChange = () => {}, -}) => { - const [state, setState] = useState(initialState); - - const updatePathMapping = ( - path: string, - state: Partial - ) => { - setState((prevState) => ({ - ...prevState, - [path]: { - ...prevState[path], - ...state, - }, - })); - }; - - useEffect(() => { - const pathMapping: PathMapping = {}; - Object.keys(state).forEach((path) => { - if (state[path].playgroundPath) { - pathMapping[path] = state[path].playgroundPath!; - } - }); - onMappingChange(pathMapping); - }, [state]); - - const generatePath = (node: FileNode, parentPath = ''): string => { - return parentPath - ? `${parentPath}/${node.name}`.replaceAll(/\/+/g, '/') - : node.name; - }; - - return ( - - - {() => <>File/Folder} - - {() => <>Absolute path in Playground} - - - {files.map((file, index) => ( - - ))} - - ); -}; - -const NodeRow: React.FC<{ - node: FileNode; - level: number; - position: number; - setSize: number; - nodeStates: MappingNodeStates; - updateNodeState: (path: string, state: Partial) => void; - generatePath: (node: FileNode, parentPath?: string) => string; - parentPath?: string; - parentMapping?: string; -}> = ({ - node, - level, - position, - setSize, - nodeStates, - updateNodeState, - generatePath, - parentPath = '', - parentMapping = '', -}) => { - const path = generatePath(node, parentPath); - const nodeState = nodeStates[path] || { - isOpen: false, - playgroundPath: '', - pathType: '', - }; - const nodeMapping = computeMapping({ - node, - nodeState, - parentMapping, - }); - - const toggleOpen = () => { - updateNodeState(path, { isOpen: !nodeState.isOpen }); - }; - - const handlePathChange = (value: string | undefined) => { - updateNodeState(path, { playgroundPath: value }); - }; - - const handlePathSelectChange = (pathType: string) => { - switch (pathType) { - case 'plugin': - updateNodeState(path, { - pathType, - playgroundPath: - '/wordpress/wp-content/plugins/' + node.name, - }); - break; - case 'theme': - updateNodeState(path, { - pathType, - playgroundPath: '/wordpress/wp-content/themes/' + node.name, - }); - break; - case 'wp-content': - updateNodeState(path, { - pathType, - playgroundPath: '/wordpress/wp-content', - }); - break; - default: - updateNodeState(path, { pathType, playgroundPath: '' }); - break; - } - }; - - const handleKeyDown = (event: any) => { - if (event.key === 'ArrowLeft') { - if (nodeState.isOpen) { - toggleOpen(); - } else { - if (node.children?.length) { - ( - document.querySelector( - `[data-path="${parentPath}"]` - ) as HTMLButtonElement - )?.focus(); - } - } - event.preventDefault(); - event.stopPropagation(); - } else if (event.key === 'ArrowRight') { - if (nodeState.isOpen) { - if (node.children?.length) { - const firstChildPath = generatePath(node.children[0], path); - ( - document.querySelector( - `[data-path="${firstChildPath}"]` - ) as HTMLButtonElement - )?.focus(); - } - } else { - toggleOpen(); - } - event.preventDefault(); - event.stopPropagation(); - } - }; - return ( - <> - - - {() => ( - - )} - - - {() => ( - <> - {!parentMapping && ( - - )} - {(parentMapping || nodeState.pathType !== '') && ( - - )} - - )} - - - {nodeState.isOpen && - node.children && - node.children.map((child, index) => ( - - ))} - - ); -}; - -function computeMapping({ - node, - nodeState, - parentMapping, -}: { - node: FileNode; - nodeState: MappingNodeState; - parentMapping: string; -}): string { - if (parentMapping) { - return `${parentMapping}/${node.name}`.replace(/\/+/g, '/'); - } - if (nodeState.playgroundPath) { - return nodeState.playgroundPath; - } - return ''; -} - -const FileName: React.FC<{ - node: FileNode; - level: number; - isOpen?: boolean; -}> = ({ node, level, isOpen }) => { - const indent: string[] = []; - for (let i = 0; i < level; i++) { - indent.push('    '); - } - return ( - <> - - {node.type === 'folder' ? ( - - ) : ( -
 
- )} - {node.name} - - ); -}; - -export default PathMappingControl; diff --git a/packages/playground/components/src/PathMappingControl/demo.tsx b/packages/playground/components/src/PathMappingControl/demo.tsx deleted file mode 100644 index 0b4c47ae24..0000000000 --- a/packages/playground/components/src/PathMappingControl/demo.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import PathMappingControl from './PathMappingControl'; - -const fileStructure = [ - { - name: '/', - type: 'folder' as const, - children: [ - { - name: 'Documents', - type: 'folder' as const, - children: [ - { name: 'Resume.pdf', type: 'file' as const }, - { name: 'CoverLetter.docx', type: 'file' as const }, - ], - }, - { - name: 'Pictures', - type: 'folder' as const, - children: [ - { - name: 'Vacation', - type: 'folder' as const, - children: [ - { name: 'beach.png', type: 'file' as const }, - ], - }, - ], - }, - { name: 'todo.txt', type: 'file' as const }, - ], - }, -]; -export default function PathMappingControlDemo() { - const [mapping, setMapping] = React.useState({}); - return ( -
- - setMapping(newMapping)} - /> -

Mapping:

-
{JSON.stringify(mapping, null, 2)}
-
- ); -} diff --git a/packages/playground/components/src/PathMappingControl/index.ts b/packages/playground/components/src/PathMappingControl/index.ts deleted file mode 100644 index f8bdd6ae2b..0000000000 --- a/packages/playground/components/src/PathMappingControl/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import PathMappingControl from './PathMappingControl'; - -export default PathMappingControl; diff --git a/packages/playground/components/src/PathMappingControl/style.css b/packages/playground/components/src/PathMappingControl/style.css deleted file mode 100644 index 51d21b97eb..0000000000 --- a/packages/playground/components/src/PathMappingControl/style.css +++ /dev/null @@ -1,29 +0,0 @@ -.path-mapping-control { - .file-node-button { - display: inline-flex; - text-decoration: none; - font-family: inherit; - font-weight: normal; - font-size: 13px; - margin: 0; - border: 0; - cursor: pointer; - -webkit-appearance: none; - background: none; - transition: box-shadow 0.1s linear; - height: 36px; - align-items: center; - box-sizing: border-box; - padding: 6px 12px; - border-radius: 2px; - - width: 100%; - white-space: nowrap; - color: var(--wp-components-color-foreground, #1e1e1e); - background: transparent; - padding: 6px; - } - .directory-expand-button { - color: var(--wp-components-color-foreground, #1e1e1e) !important; - } -} diff --git a/packages/playground/components/src/demos/GitBrowserDemo.tsx b/packages/playground/components/src/demos/GitBrowserDemo.tsx new file mode 100644 index 0000000000..7b3c6135a8 --- /dev/null +++ b/packages/playground/components/src/demos/GitBrowserDemo.tsx @@ -0,0 +1,289 @@ +import React, { useEffect, useMemo } from 'react'; +import { + FileTree, + listDescendantFiles, + listFiles, + sparseCheckout, +} from '@wp-playground/storage'; +import { + Button, + Flex, + FlexItem, + SelectControl, + __experimentalInputControl as InputControl, + Spinner, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { FilePickerControl } from '../FilePickerControl'; +import { PromiseState, usePromise } from '../hooks/use-promise'; +import { joinPaths } from '@php-wasm/util'; + +export default function GitBrowserDemo() { + const [repoUrl, setRepoUrl] = React.useState( + 'http://127.0.0.1:5263/proxy.php/https://github.com/WordPress/wordpress-playground.git' + ); + const [branch, setBranch] = React.useState('HEAD'); + + const [filesPromise, setFilesPromise] = React.useState>( + () => Promise.resolve([]) + ); + const loadFiles = () => { + const promise = listFiles(repoUrl, branch); + setFilesPromise(promise); + }; + const files = usePromise(filesPromise); + useEffect(() => { + loadFiles(); + }, []); + + const [pathMappings, setPathMappings] = React.useState([ + { gitPath: '', wpPath: '' }, + ]); + + const [filesToCheckout, setFilesToCheckout] = React.useState< + Record + >({}); + useEffect(() => { + const newFilesToCheckout = pathMappings.flatMap( + ({ gitPath, wpPath }) => { + if (!gitPath || !files.data || !wpPath) { + return []; + } + return listDescendantFiles(files.data, gitPath).map( + (filePath) => [ + filePath, + joinPaths( + wpPath, + filePath.substring(gitPath.length) + ).replace(/\/$/g, ''), + ] + ); + } + ); + setFilesToCheckout(Object.fromEntries(newFilesToCheckout)); + }, [pathMappings, files.data]); + + const [checkedOutFiles, setCheckedOutFiles] = React.useState< + Record + >({}); + + async function doSparseCheckout() { + const result = await sparseCheckout( + repoUrl, + branch, + Object.keys(filesToCheckout) + ); + const checkedOutFiles: Record = {}; + for (const filename in result) { + checkedOutFiles[filename] = new TextDecoder().decode( + result[filename] + ); + } + setCheckedOutFiles(checkedOutFiles); + } + return ( + + + + setRepoUrl(value as string)} + /> + setBranch(value as string)} + /> + + + + {files?.isResolved && ( + +

Path mappings

+ {pathMappings.map((pathMapping, index) => ( + { + const newPathMappings = pathMappings + .slice(0, index) + .concat(pathMappings.slice(index + 1)); + setPathMappings(newPathMappings); + }} + onChange={(value) => { + const newPathMappings = [...pathMappings]; + newPathMappings[index] = value; + setPathMappings(newPathMappings); + }} + /> + ))} + +
+ )} + {files?.isResolved && Object.keys(filesToCheckout).length > 0 && ( + +

Selected path:

+
+						{JSON.stringify(
+							pathMappings
+								.filter(({ gitPath }) => gitPath)
+								.map(({ gitPath }) => gitPath),
+							null,
+							2
+						)}
+					
+

Repository files to checkout:

+
{JSON.stringify(filesToCheckout, null, 2)}
+ +

Checked out files:

+
{JSON.stringify(checkedOutFiles, null, 2)}
+
+ )} +
+ ); +} + +interface PathMapping { + gitPath: string; + wpPath: string; +} + +function PathMappingRow({ + value, + files, + onChange, + onRemove, +}: { + value: PathMapping; + files: PromiseState; + onChange: (value: PathMapping) => void; + onRemove: () => void; +}) { + const basePath = '/wordpress'; + const [selectedPathType, setSelectedPathType] = React.useState< + '' | 'mu-plugin' | 'plugin' | 'theme' | 'custom' + >(''); + const [customPath, setCustomPath] = React.useState(value.wpPath); + useEffect(() => { + const givenWpPath = value.wpPath || ''; + if (!givenWpPath) { + return; + } + const segments = givenWpPath.split('/'); + const lastWpSegment = segments.pop() || ''; + const lastGitSegment = lastWpSegment.split('/').pop(); + if (lastGitSegment !== lastWpSegment) { + console.log('custom path', { + lastGitSegment, + lastWpSegment, + }); + setSelectedPathType('custom'); + setCustomPath(value.wpPath); + return; + } + + const beginning = segments.join('/'); + switch (beginning) { + case `${basePath}/wp-content/plugins`: + setSelectedPathType('plugin'); + break; + case `${basePath}/wp-content/themes`: + setSelectedPathType('theme'); + break; + case `${basePath}/wp-content/mu-plugins`: + setSelectedPathType('mu-plugin'); + break; + default: + console.log('custom path', { + beginning, + value, + }); + setSelectedPathType('custom'); + setCustomPath(value.wpPath); + break; + } + }, []); + + const effectiveWpPath = useMemo(() => { + if (!value.gitPath || (!selectedPathType && !customPath)) { + return ''; + } + const lastSegment = value.gitPath.split('/').pop(); + switch (selectedPathType) { + case 'plugin': + return `${basePath}/wp-content/plugins/${lastSegment}`; + case 'theme': + return `${basePath}/wp-content/themes/${lastSegment}`; + case 'mu-plugin': + return `${basePath}/wp-content/mu-plugins/${lastSegment}`; + case 'custom': + return customPath; + default: + return ''; + } + }, [selectedPathType, customPath, value.gitPath]); + + useEffect(() => { + onChange({ ...value, wpPath: effectiveWpPath }); + }, [effectiveWpPath]); + + return ( + + + onChange({ ...value, gitPath: path })} + /> + + + setSelectedPathType(value as any)} + options={[ + { label: '--- Select ---', value: '' }, + { label: 'Plugin', value: 'plugin' }, + { label: 'Theme', value: 'theme' }, + { label: 'mu-plugin', value: 'mu-plugin' }, + { label: 'Custom path', value: 'custom' }, + ]} + /> + {selectedPathType === 'custom' && ( + setCustomPath(value as string)} + /> + )} + + + + + + ); +} diff --git a/packages/playground/components/src/demos.tsx b/packages/playground/components/src/demos/index.tsx similarity index 81% rename from packages/playground/components/src/demos.tsx rename to packages/playground/components/src/demos/index.tsx index c4bbd445fe..2dbbf0a2a6 100644 --- a/packages/playground/components/src/demos.tsx +++ b/packages/playground/components/src/demos/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import PathMappingControlDemo from './PathMappingControl/demo'; +import GitBrowserDemo from './GitBrowserDemo'; import { createRoot } from 'react-dom/client'; @@ -7,8 +7,8 @@ import { createRoot } from 'react-dom/client'; const components = [ { - name: 'PathMappingControl', - component: PathMappingControlDemo, + name: 'GitBrowserDemo', + component: GitBrowserDemo, }, ]; diff --git a/packages/playground/components/src/hooks/use-promise.ts b/packages/playground/components/src/hooks/use-promise.ts new file mode 100644 index 0000000000..b15c622481 --- /dev/null +++ b/packages/playground/components/src/hooks/use-promise.ts @@ -0,0 +1,42 @@ +import { useEffect, useState, useRef } from 'react'; + +export interface PromiseState { + isLoading: boolean; + error: Error | null; + data: T | null; + isResolved: boolean; +} + +export function usePromise(promise: Promise): PromiseState { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [isResolved, setIsResolved] = useState(false); + const lastPromise = useRef | null>(null); + + useEffect(() => { + setIsLoading(true); + setIsResolved(false); + lastPromise.current = promise; + async function handlePromise() { + try { + const result = await promise; + if (lastPromise.current === promise) { + setData(result); + } + } catch (error) { + if (lastPromise.current === promise) { + setError(error as Error); + } + } finally { + if (lastPromise.current === promise) { + setIsLoading(false); + setIsResolved(true); + } + } + } + handlePromise(); + }, [promise]); + + return { isLoading, error, data, isResolved }; +} diff --git a/packages/playground/website/src/components/site-manager/icons.tsx b/packages/playground/components/src/icons.tsx similarity index 87% rename from packages/playground/website/src/components/site-manager/icons.tsx rename to packages/playground/components/src/icons.tsx index aef46a86af..ef10a6e585 100644 --- a/packages/playground/website/src/components/site-manager/icons.tsx +++ b/packages/playground/components/src/icons.tsx @@ -1,6 +1,6 @@ -import { SiteLogo } from '../../lib/site-metadata'; +import React from 'react'; -export const Logo = (props?: React.SVGProps) => { +export const playgroundLogo = (props?: React.SVGProps) => { return ( ) => { ); }; -export const TemporaryStorageIcon = (props?: React.SVGProps) => { +export const temporaryStorage = (props?: React.SVGProps) => { return ( ) => { ); }; -export const FolderIcon = ( +export const folder = ( ); +export const file = ( + + + +); + export const ClockIcon = (props?: React.SVGProps) => ( ) => ( ); -export const LayoutIcon = ( +export const layout = ( ); -export function getLogoDataURL(logo: SiteLogo): string { +export function getLogoDataURL(logo: { mime: string; data: string }): string { return `data:${logo.mime};base64,${logo.data}`; } diff --git a/packages/playground/components/src/index.ts b/packages/playground/components/src/index.ts index 8c9ac6966d..838008a0b2 100644 --- a/packages/playground/components/src/index.ts +++ b/packages/playground/components/src/index.ts @@ -1,3 +1 @@ -import PathMappingControlDemo from './PathMappingControl'; - -export { PathMappingControlDemo }; +export * from './icons'; diff --git a/packages/playground/components/tsconfig.lib.json b/packages/playground/components/tsconfig.lib.json index c0ea0dc8df..861f2ea23d 100644 --- a/packages/playground/components/tsconfig.lib.json +++ b/packages/playground/components/tsconfig.lib.json @@ -3,8 +3,12 @@ "compilerOptions": { "outDir": "../../../dist/out-tsc", "declaration": true, - "types": ["node"] + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts" + ] }, - "include": ["src/**/*.ts", "src/index.ts"], + "files": [], + "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/packages/playground/components/tsconfig.spec.json b/packages/playground/components/tsconfig.spec.json index eb23daacbc..b36c1840f8 100644 --- a/packages/playground/components/tsconfig.spec.json +++ b/packages/playground/components/tsconfig.spec.json @@ -2,8 +2,15 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "@nx/react/typings/cssmodule.d.ts" + ] }, + "files": [], "include": [ "vite.config.ts", "src/**/*.test.ts", diff --git a/packages/playground/components/vite.config.ts b/packages/playground/components/vite.config.ts index d3ae193a4e..44da88e73a 100644 --- a/packages/playground/components/vite.config.ts +++ b/packages/playground/components/vite.config.ts @@ -23,6 +23,12 @@ export default defineConfig({ }), ], + css: { + modules: { + localsConvention: 'camelCaseOnly', + }, + }, + // Configuration for building your library. // See: https://vitejs.dev/guide/build.html#library-mode build: { diff --git a/packages/playground/php-cors-proxy/project.json b/packages/playground/php-cors-proxy/project.json index 6455a0638c..f4b07ef08a 100644 --- a/packages/playground/php-cors-proxy/project.json +++ b/packages/playground/php-cors-proxy/project.json @@ -4,6 +4,13 @@ "sourceRoot": "packages/playground/php-cors-proxy", "projectType": "library", "targets": { + "start": { + "executor": "nx:run-commands", + "options": { + "commands": ["php -S 127.0.0.1:5263"], + "cwd": "packages/playground/php-cors-proxy" + } + }, "test": { "executor": "nx:run-commands", "options": { diff --git a/packages/playground/php-cors-proxy/proxy.php b/packages/playground/php-cors-proxy/proxy.php index a9b3567b8a..59760b741f 100644 --- a/packages/playground/php-cors-proxy/proxy.php +++ b/packages/playground/php-cors-proxy/proxy.php @@ -72,7 +72,14 @@ function set_cors_headers() { // Set options to stream data curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); -curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use($targetUrl) { +$httpcode_sent = false; +curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use($targetUrl, &$httpcode_sent, $ch) { + if(!$httpcode_sent) { + // Set the response code from the target server + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + http_response_code($httpCode); + $httpcode_sent = true; + } $len = strlen($header); $colonPos = strpos($header, ':'); $name = strtolower(substr($header, 0, $colonPos)); @@ -105,8 +112,8 @@ function set_cors_headers() { }); curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($curl, $data) { echo $data; - ob_flush(); - flush(); + @ob_flush(); + @flush(); return strlen($data); }); @@ -127,10 +134,8 @@ function set_cors_headers() { http_response_code(502); echo "Bad Gateway – curl_exec error: " . curl_error($ch); } else { - // Set the response code from the target server $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - http_response_code($httpCode); + @http_response_code($httpCode); } - // Close cURL session curl_close($ch); diff --git a/packages/playground/storage/src/index.ts b/packages/playground/storage/src/index.ts index 16ce715fa8..d2b72d84a5 100644 --- a/packages/playground/storage/src/index.ts +++ b/packages/playground/storage/src/index.ts @@ -2,3 +2,5 @@ export * from './lib/github'; export * from './lib/changeset'; export * from './lib/playground'; export * from './lib/browser-fs'; +export * from './lib/git-sparse-checkout'; +export * from './lib/paths'; diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts new file mode 100644 index 0000000000..cfd6eb4bd2 --- /dev/null +++ b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts @@ -0,0 +1,62 @@ +import { listRefs, sparseCheckout, listFiles } from './git-sparse-checkout'; + +describe('listRefs', () => { + it('should return the latest commit hash for a given ref', async () => { + const refs = await listRefs( + 'https://github.com/WordPress/wordpress-playground', + 'refs/heads/trunk' + ); + expect(refs).toEqual({ + 'refs/heads/trunk': expect.stringMatching(/^[a-f0-9]{40}$/), + }); + }); +}); + +describe('sparseCheckout', () => { + it('should retrieve the requested files from a git repo', async () => { + const files = await sparseCheckout( + 'https://github.com/WordPress/wordpress-playground.git', + 'refs/heads/trunk', + ['README.md'] + ); + expect(files).toEqual({ + 'README.md': expect.any(Uint8Array), + }); + expect(files['README.md'].length).toBeGreaterThan(0); + }); +}); + +describe('listFiles', () => { + it('should list the files in a git repo', async () => { + const files = await listFiles( + 'https://github.com/WordPress/wordpress-playground.git', + 'refs/heads/trunk' + ); + expect(files).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'packages', + type: 'folder', + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'playground', + type: 'folder', + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'storage', + type: 'folder', + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'package.json', + type: 'file', + }), + ]), + }), + ]), + }), + ]), + }), + ]) + ); + }); +}); diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts new file mode 100644 index 0000000000..c6d3cbfb10 --- /dev/null +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -0,0 +1,393 @@ +/* + * Import internal data parsers and structures from isomorphic-git. These + * exports are not available in the npm version of isomorphic-git, which is why + * we use one from the git repository. + * + * This file heavily relies on isomorphic-git internals to parse Git data formats + * such as PACK, trees, deltas, etc. + */ +import './isomorphic-git.d.ts'; +import { GitPktLine } from 'isomorphic-git/src/models/GitPktLine.js'; +import { GitTree } from 'isomorphic-git/src/models/GitTree.js'; +import { GitAnnotatedTag } from 'isomorphic-git/src/models/GitAnnotatedTag.js'; +import { GitCommit } from 'isomorphic-git/src/models/GitCommit.js'; +import { GitPackIndex } from 'isomorphic-git/src/models/GitPackIndex.js'; +import { collect } from 'isomorphic-git/src/internal-apis.js'; +import { parseUploadPackResponse } from 'isomorphic-git/src/wire/parseUploadPackResponse.js'; +import { ObjectTypeError } from 'isomorphic-git/src/errors/ObjectTypeError.js'; +import { Buffer } from 'buffer'; + +/** + * A polyfill for the Buffer class. We need it because isomorphic-git uses it internally. + * The isomorphic-git version released via npm shipes a Buffer implementation, but we're + * using a version cloned from the git repository which assumes a global Buffer is available. + */ +if (typeof window !== 'undefined') { + window.Buffer = Buffer; +} + +/** + * Downloads specific files from a git repository. + * It uses the git protocol over HTTP to fetch the files. It only uses + * three HTTP requests regardless of the number of paths requested. + * + * @param repoUrl The URL of the git repository. + * @param fullyQualifiedBranchName The full name of the branch to fetch from (e.g., 'refs/heads/main'). + * @param filesPaths An array of all the file paths to fetch from the repository. Does **not** accept + * patterns, wildcards, directory paths. All files must be explicitly listed. + * @returns A record where keys are file paths and values are the retrieved file contents. + */ +export async function sparseCheckout( + repoUrl: string, + fullyQualifiedBranchName: string, + filesPaths: string[] +) { + const refs = await listRefs(repoUrl, fullyQualifiedBranchName); + const commitHash = refs[fullyQualifiedBranchName]; + const treesIdx = await fetchWithoutBlobs(repoUrl, commitHash); + const objects = await resolveObjects(treesIdx, commitHash, filesPaths); + + const blobsIdx = await fetchObjects( + repoUrl, + filesPaths.map((path) => objects[path].oid) + ); + + const fetchedPaths: Record = {}; + await Promise.all( + filesPaths.map(async (path) => { + fetchedPaths[path] = await extractGitObjectFromIdx( + blobsIdx, + objects[path].oid + ); + }) + ); + return fetchedPaths; +} + +export type FileTreeFile = { + name: string; + type: 'file'; +}; +export type FileTreeFolder = { + name: string; + type: 'folder'; + children: FileTree[]; +}; +export type FileTree = FileTreeFile | FileTreeFolder; + +/** + * Lists all files in a git repository. + * + * See https://git-scm.com/book/en/v2/Git-Internals-Git-Objects for more information. + * + * @param repoUrl The URL of the git repository. + * @param fullyQualifiedBranchName The full name of the branch to fetch from (e.g., 'refs/heads/main'). + * @returns A list of all files in the repository. + */ +export async function listFiles( + repoUrl: string, + fullyQualifiedBranchName: string +): Promise { + const refs = await listRefs(repoUrl, fullyQualifiedBranchName); + if (!(fullyQualifiedBranchName in refs)) { + throw new Error(`Branch ${fullyQualifiedBranchName} not found`); + } + const commitHash = refs[fullyQualifiedBranchName]; + const treesIdx = await fetchWithoutBlobs(repoUrl, commitHash); + const rootTree = await resolveAllObjects(treesIdx, commitHash); + if (!rootTree?.object) { + return []; + } + + return gitTreeToFileTree(rootTree); +} + +function gitTreeToFileTree(tree: GitTree): FileTree[] { + return tree.object + .map((branch) => { + if (branch.type === 'blob') { + return { + name: branch.path, + type: 'file', + } as FileTreeFile; + } else if (branch.type === 'tree' && branch.object) { + return { + name: branch.path, + type: 'folder', + children: gitTreeToFileTree(branch as any as GitTree), + } as FileTreeFolder; + } + return undefined; + }) + .filter((entry) => !!entry?.name) as FileTree[]; +} + +/** + * Retrieves a list of refs from a git repository. + * + * See https://git-scm.com/book/en/v2/Git-Internals-Git-References for more information. + * + * @param repoUrl The URL of the git repository. For example: https://github.com/WordPress/gutenberg.git + * @param fullyQualifiedBranchPrefix The prefix of the refs to fetch. For example: refs/heads/my-feature-branch + * @returns A map of refs to their corresponding commit hashes. + */ +export async function listRefs( + repoUrl: string, + fullyQualifiedBranchPrefix: string +) { + const packbuffer = Buffer.from( + await collect([ + GitPktLine.encode(`command=ls-refs\n`), + GitPktLine.encode(`agent=git/2.37.3\n`), + GitPktLine.encode(`object-format=sha1\n`), + GitPktLine.delim(), + GitPktLine.encode(`peel\n`), + GitPktLine.encode(`ref-prefix ${fullyQualifiedBranchPrefix}\n`), + GitPktLine.flush(), + ]) + ); + + const response = await fetch(repoUrl + '/git-upload-pack', { + method: 'POST', + headers: { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + 'Git-Protocol': 'version=2', + }, + body: packbuffer, + }); + + const refs: Record = {}; + for await (const line of parseGitResponseLines(response)) { + const spaceAt = line.indexOf(' '); + const ref = line.slice(0, spaceAt); + const name = line.slice(spaceAt + 1, line.length - 1); + refs[name] = ref; + } + return refs; +} + +async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { + const packbuffer = Buffer.from( + await collect([ + GitPktLine.encode( + `want ${commitHash} multi_ack_detailed no-done side-band-64k thin-pack ofs-delta agent=git/2.37.3 filter \n` + ), + GitPktLine.encode(`filter blob:none\n`), + GitPktLine.encode(`shallow ${commitHash}\n`), + GitPktLine.encode(`deepen 1\n`), + GitPktLine.flush(), + GitPktLine.encode(`done\n`), + GitPktLine.encode(`done\n`), + ]) + ); + + const response = await fetch(repoUrl + '/git-upload-pack', { + method: 'POST', + headers: { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + }, + body: packbuffer, + }); + + const iterator = streamToIterator(response.body!); + const parsed = await parseUploadPackResponse(iterator); + const packfile = Buffer.from(await collect(parsed.packfile)); + const idx = await GitPackIndex.fromPack({ + pack: packfile, + }); + const originalRead = idx.read as any; + idx.read = async function ({ oid, ...rest }: { oid: string }) { + const result = await originalRead.call(this, { oid, ...rest }); + result.oid = oid; + return result; + }; + return idx; +} + +async function resolveAllObjects(idx: GitPackIndex, commitHash: string) { + const commit = await idx.read({ + oid: commitHash, + }); + readObject(commit); + + const rootItem = await idx.read({ oid: commit.object.tree }); + const items = [rootItem]; + while (items.length > 0) { + const tree = items.pop(); + const readItem = await idx.read({ oid: tree.oid }); + readObject(readItem); + tree.object = readItem.object; + if (readItem.type === 'tree') { + for (const subitem of readItem.object) { + if (subitem.type === 'tree') { + items.push(subitem); + } + } + } + } + return rootItem; +} + +async function resolveObjects( + idx: GitPackIndex, + commitHash: string, + paths: string[] +) { + const commit = await idx.read({ + oid: commitHash, + }); + readObject(commit); + + const rootTree = await idx.read({ oid: commit.object.tree }); + readObject(rootTree); + + // Resolve refs to fetch + const resolvedOids: Record = {}; + for (const path of paths) { + let currentObject = rootTree; + const segments = path.split('/'); + for (const segment of segments) { + if (currentObject.type !== 'tree') { + throw new Error(`Path not found in the repo: ${path}`); + } + + let found = false; + for (const item of currentObject.object) { + if (item.path === segment) { + try { + currentObject = await idx.read({ oid: item.oid }); + readObject(currentObject); + } catch (e) { + currentObject = item; + } + found = true; + break; + } + } + if (!found) { + throw new Error(`Path not found in the repo: ${path}`); + } + } + resolvedOids[path] = currentObject; + } + return resolvedOids; +} + +// Request oid for each resolvedRef +async function fetchObjects(url: string, objectHashes: string[]) { + const packbuffer = Buffer.from( + await collect([ + ...objectHashes.map((objectHash) => + GitPktLine.encode( + `want ${objectHash} multi_ack_detailed no-done side-band-64k thin-pack ofs-delta agent=git/2.37.3 \n` + ) + ), + GitPktLine.flush(), + GitPktLine.encode(`done\n`), + ]) + ); + + const response = await fetch(url + '/git-upload-pack', { + method: 'POST', + headers: { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + }, + body: packbuffer, + }); + + const iterator = streamToIterator(response.body!); + const parsed = await parseUploadPackResponse(iterator); + const packfile = Buffer.from(await collect(parsed.packfile)); + return await GitPackIndex.fromPack({ + pack: packfile, + }); +} + +async function extractGitObjectFromIdx(idx: GitPackIndex, objectHash: string) { + const tree = await idx.read({ oid: objectHash }); + readObject(tree); + + if (tree.type === 'blob') { + return tree.object; + } + + const files: Record = {}; + for (const { path, oid, type } of tree.object) { + if (type === 'blob') { + const object = await idx.read({ oid }); + readObject(object); + files[path] = object.object; + } else if (type === 'tree') { + files[path] = await extractGitObjectFromIdx(idx, oid); + } + } + return files; +} + +function readObject(result: any) { + if (!(result.object instanceof Buffer)) { + return; + } + switch (result.type) { + case 'commit': + result.object = GitCommit.from(result.object).parse(); + break; + case 'tree': + result.object = (GitTree.from(result.object) as any).entries(); + break; + case 'blob': + result.object = new Uint8Array(result.object); + result.format = 'content'; + break; + case 'tag': + result.object = GitAnnotatedTag.from(result.object).parse(); + break; + default: + throw new ObjectTypeError( + result.oid, + result.type, + 'blob|commit|tag|tree' + ); + } +} + +async function* parseGitResponseLines(response: Response) { + const text = await response.text(); + let at = 0; + + while (at <= text.length) { + const lineLength = parseInt(text.substring(at, at + 4), 16); + if (lineLength === 0) { + break; + } + const line = text.substring(at + 4, at + lineLength); + yield line; + at += lineLength; + } +} + +function streamToIterator(stream: any) { + // Use native async iteration if it's available. + if (stream[Symbol.asyncIterator]) { + return stream; + } + const reader = stream.getReader(); + return { + next() { + return reader.read(); + }, + return() { + reader.releaseLock(); + return {}; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} diff --git a/packages/playground/storage/src/lib/isomorphic-git.d.ts b/packages/playground/storage/src/lib/isomorphic-git.d.ts new file mode 100644 index 0000000000..7b95b48ecf --- /dev/null +++ b/packages/playground/storage/src/lib/isomorphic-git.d.ts @@ -0,0 +1,90 @@ +declare module 'isomorphic-git/src/models/GitPktLine.js' { + export class GitPktLine { + static encode(data: string): Buffer; + static decode(data: Buffer): string; + static flush(): Buffer; + static delim(): Buffer; + } +} + +declare module 'isomorphic-git/src/models/GitTree.js' { + export class GitTree { + static from(buffer: Buffer): GitTree; + type: 'tree' | 'blob'; + oid: string; + format: 'content'; + object: Array<{ + mode: string; + path: string; + oid: string; + type?: 'blob' | 'tree'; + object?: GitTree; + }>; + } +} + +declare module 'isomorphic-git/src/models/GitAnnotatedTag.js' { + export class GitAnnotatedTag { + static from(buffer: Buffer): GitAnnotatedTag; + parse(): { + object: { + object: GitTree; + }; + type: string; + tag: string; + tagger: { + name: string; + email: string; + timestamp: number; + timezoneOffset: number; + }; + message: string; + signature?: string; + }; + } +} + +declare module 'isomorphic-git/src/models/GitCommit.js' { + export class GitCommit { + static from(buffer: Buffer): GitCommit; + parse(): { + tree: string; + parent: string[]; + author: { + name: string; + email: string; + timestamp: number; + timezoneOffset: number; + }; + committer: { + name: string; + email: string; + timestamp: number; + timezoneOffset: number; + }; + message: string; + gpgsig?: string; + }; + } +} + +declare module 'isomorphic-git/src/models/GitPackIndex.js' { + export class GitPackIndex { + static fromPack({ pack }: { pack: Buffer }): Promise; + read({ oid }: { oid: string }): Promise; + } +} + +declare module 'isomorphic-git/src/internal-apis.js' { + export function collect(data: any[]): Promise; +} + +declare module 'isomorphic-git/src/wire/parseUploadPackResponse.js' { + export function parseUploadPackResponse(data: Buffer): any; // Replace 'any' with a more specific type if known +} + +declare module 'isomorphic-git/src/errors/ObjectTypeError.js' { + export class ObjectTypeError extends Error { + constructor(message: string, expected: string, actual: string); + } +} diff --git a/packages/playground/storage/src/lib/paths.ts b/packages/playground/storage/src/lib/paths.ts new file mode 100644 index 0000000000..5b4a7fa5a8 --- /dev/null +++ b/packages/playground/storage/src/lib/paths.ts @@ -0,0 +1,43 @@ +import { FileTree } from './git-sparse-checkout'; + +export function listDescendantFiles(files: FileTree[], selectedPath: string) { + if (!selectedPath) { + return []; + } + + // Calculate the list of files to checkout based on the mapping + const descendants: string[] = []; + const segments = selectedPath.split('/'); + let currentTree: FileTree[] | null = files; + for (const segment of segments) { + const file = currentTree?.find( + (file) => file.name === segment + ) as FileTree; + if (file?.type === 'folder') { + currentTree = file.children; + } else if (file) { + return [file.name]; + } else { + return []; + } + } + + const stack = [{ tree: currentTree, path: selectedPath }]; + while (stack.length > 0) { + const { tree, path } = stack.pop() as { + tree: FileTree[]; + path: string; + }; + for (const file of tree) { + if (file.type === 'folder') { + stack.push({ + tree: file.children, + path: `${path}/${file.name}`, + }); + } else { + descendants.push(`${path}/${file.name}`); + } + } + } + return descendants; +} diff --git a/packages/playground/storage/src/vitest-setup-file.ts b/packages/playground/storage/src/vitest-setup-file.ts index f5efaf08d6..327bbcd160 100644 --- a/packages/playground/storage/src/vitest-setup-file.ts +++ b/packages/playground/storage/src/vitest-setup-file.ts @@ -4,4 +4,4 @@ * * @see tests.setupFiles in vite.config.ts */ -import '@php-wasm/node-polyfills'; +// import '@php-wasm/node-polyfills'; diff --git a/packages/playground/storage/vite.config.ts b/packages/playground/storage/vite.config.ts index 83f0286cc8..43ea5f91ab 100644 --- a/packages/playground/storage/vite.config.ts +++ b/packages/playground/storage/vite.config.ts @@ -1,4 +1,5 @@ /// +import { defineConfig } from 'vite'; // eslint-disable-next-line @nx/enforce-module-boundaries import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; // eslint-disable-next-line @nx/enforce-module-boundaries @@ -6,7 +7,7 @@ import ignoreWasmImports from '../ignore-wasm-imports'; // eslint-disable-next-line @nx/enforce-module-boundaries import { getExternalModules } from '../../vite-extensions/vite-external-modules'; -export default { +export default defineConfig({ base: '/', cacheDir: '../../../node_modules/.vite/packages-playground-storage', @@ -51,4 +52,4 @@ export default { setupFiles: ['./src/vitest-setup-file.ts'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], }, -}; +}); diff --git a/packages/playground/website/src/components/site-manager/sidebar/index.tsx b/packages/playground/website/src/components/site-manager/sidebar/index.tsx index dac4c66c95..8af6ca184d 100644 --- a/packages/playground/website/src/components/site-manager/sidebar/index.tsx +++ b/packages/playground/website/src/components/site-manager/sidebar/index.tsx @@ -10,7 +10,7 @@ import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, } from '@wordpress/components'; -import { ClockIcon, WordPressIcon } from '../icons'; +import { ClockIcon, WordPressIcon } from '@wp-playground/components'; import { setActiveSite, useActiveSite, 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 9a3ec290ea..33b4181993 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 @@ -1,6 +1,6 @@ import classNames from 'classnames'; import css from './style.module.css'; -import { getLogoDataURL, WordPressIcon } from '../icons'; +import { getLogoDataURL, WordPressIcon } from '@wp-playground/components'; import { Button, Flex, diff --git a/packages/playground/website/src/components/site-manager/storage-type/index.tsx b/packages/playground/website/src/components/site-manager/storage-type/index.tsx index cf5d86363a..cfad5be4f8 100644 --- a/packages/playground/website/src/components/site-manager/storage-type/index.tsx +++ b/packages/playground/website/src/components/site-manager/storage-type/index.tsx @@ -1,5 +1,5 @@ import { Icon } from '@wordpress/components'; -import { ClockIcon, FolderIcon, LayoutIcon } from '../icons'; +import { ClockIcon, folder, layout } from '@wp-playground/components'; import css from './style.module.css'; import { SiteStorageType } from '../../../lib/site-metadata'; @@ -8,14 +8,14 @@ export function StorageType({ type }: { type: SiteStorageType }) { case 'local-fs': return (
- + Local
); case 'opfs': return (
- + Browser
); diff --git a/packages/playground/website/src/styles.css b/packages/playground/website/src/styles.css index 2f0366e00e..11cbc1db63 100644 --- a/packages/playground/website/src/styles.css +++ b/packages/playground/website/src/styles.css @@ -26,6 +26,15 @@ body { font-weight: 300; } +:root { + --wp-admin-theme-color: #007cba; + --wp-admin-theme-color--rgb: 0, 124, 186; + --wp-admin-theme-color-darker-10: #006ba1; + --wp-admin-theme-color-darker-10--rgb: 0, 107, 161; + --wp-admin-theme-color-darker-20: #005a87; + --wp-admin-theme-color-darker-20--rgb: 0, 90, 135; +} + .components-button.is-link { text-decoration: none !important; } diff --git a/packages/playground/website/tsconfig.app.json b/packages/playground/website/tsconfig.app.json index 2edc05ecaf..8a7d890c28 100644 --- a/packages/playground/website/tsconfig.app.json +++ b/packages/playground/website/tsconfig.app.json @@ -30,6 +30,7 @@ "demos/terminal.ts", "demos/terminal-component.tsx", "demos/php-blueprints.ts", - "./cypress.config.ts" + "./cypress.config.ts", + "../components/src/icons.tsx" ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index c716380372..658b6a1796 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -73,7 +73,8 @@ ], "@wp-playground/wordpress-builds": [ "packages/playground/wordpress-builds/src/index.ts" - ] + ], + "isomorphic-git": ["./isomorphic-git/src"] } }, "exclude": ["node_modules", "tmp"]