Skip to content

Commit 495a9cd

Browse files
authored
[Website] File browser and code editor (WordPress#2813)
1 parent 7348036 commit 495a9cd

File tree

21 files changed

+2431
-532
lines changed

21 files changed

+2431
-532
lines changed

.github/workflows/ci.yml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,24 @@ jobs:
236236
with:
237237
name: playwright-dist
238238

239+
test-e2e-components:
240+
runs-on: ubuntu-latest
241+
steps:
242+
- uses: actions/checkout@v4
243+
with:
244+
submodules: true
245+
- uses: ./.github/actions/prepare-playground
246+
- name: Install Playwright Browsers
247+
run: npx playwright install --with-deps chromium
248+
- name: Run Playwright tests for components
249+
run: npx nx e2e playground-components
250+
- uses: actions/upload-artifact@v4
251+
if: ${{ !cancelled() }}
252+
with:
253+
name: playwright-components-report
254+
path: packages/playground/components/playwright-report/
255+
if-no-files-found: ignore
256+
239257
test-built-npm-packages:
240258
runs-on: ubuntu-latest
241259
steps:
@@ -357,7 +375,7 @@ jobs:
357375
github.ref == 'refs/heads/trunk' &&
358376
github.event_name == 'push'
359377
# Add a dependency to the build job
360-
needs: [test-unit-asyncify, test-e2e, build]
378+
needs: [test-unit-asyncify, test-e2e, test-e2e-components, build]
361379
name: 'Deploy doc site'
362380

363381
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment

CHANGELOG.md

Lines changed: 188 additions & 224 deletions
Large diffs are not rendered by default.

packages/docs/site/docs/main/changelog.md

Lines changed: 188 additions & 224 deletions
Large diffs are not rendered by default.

packages/php-wasm/universal/src/lib/php.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,7 +1147,9 @@ export class PHP implements Disposable {
11471147
* @param path - The directory path to create.
11481148
*/
11491149
mkdir(path: string) {
1150-
return FSHelpers.mkdir(this[__private__dont__use].FS, path);
1150+
const result = FSHelpers.mkdir(this[__private__dont__use].FS, path);
1151+
this.dispatchEvent({ type: 'filesystem.write' });
1152+
return result;
11511153
}
11521154

11531155
/**
@@ -1187,7 +1189,13 @@ export class PHP implements Disposable {
11871189
* @param data - The data to write to the file.
11881190
*/
11891191
writeFile(path: string, data: string | Uint8Array) {
1190-
return FSHelpers.writeFile(this[__private__dont__use].FS, path, data);
1192+
const result = FSHelpers.writeFile(
1193+
this[__private__dont__use].FS,
1194+
path,
1195+
data
1196+
);
1197+
this.dispatchEvent({ type: 'filesystem.write' });
1198+
return result;
11911199
}
11921200

11931201
/**
@@ -1197,7 +1205,9 @@ export class PHP implements Disposable {
11971205
* @param path - The file path to remove.
11981206
*/
11991207
unlink(path: string) {
1200-
return FSHelpers.unlink(this[__private__dont__use].FS, path);
1208+
const result = FSHelpers.unlink(this[__private__dont__use].FS, path);
1209+
this.dispatchEvent({ type: 'filesystem.write' });
1210+
return result;
12011211
}
12021212

12031213
/**
@@ -1208,7 +1218,13 @@ export class PHP implements Disposable {
12081218
* @param newPath The new path.
12091219
*/
12101220
mv(fromPath: string, toPath: string) {
1211-
return FSHelpers.mv(this[__private__dont__use].FS, fromPath, toPath);
1221+
const result = FSHelpers.mv(
1222+
this[__private__dont__use].FS,
1223+
fromPath,
1224+
toPath
1225+
);
1226+
this.dispatchEvent({ type: 'filesystem.write' });
1227+
return result;
12121228
}
12131229

12141230
/**
@@ -1218,7 +1234,13 @@ export class PHP implements Disposable {
12181234
* @param options Options for the removal.
12191235
*/
12201236
rmdir(path: string, options: RmDirOptions = { recursive: true }) {
1221-
return FSHelpers.rmdir(this[__private__dont__use].FS, path, options);
1237+
const result = FSHelpers.rmdir(
1238+
this[__private__dont__use].FS,
1239+
path,
1240+
options
1241+
);
1242+
this.dispatchEvent({ type: 'filesystem.write' });
1243+
return result;
12221244
}
12231245

12241246
/**

packages/php-wasm/universal/src/lib/universal-php.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export interface PHPRuntimeBeforeExitEvent {
3131
type: 'runtime.beforeExit';
3232
}
3333

34+
/**
35+
* Emitted when a filesystem write operation occurs (writeFile, mkdir, rmdir, mv, unlink).
36+
* This event is used to trigger journal flushing for persistent storage.
37+
*/
38+
export interface PHPFilesystemWriteEvent {
39+
type: 'filesystem.write';
40+
}
41+
3442
/**
3543
* Represents an event related to the PHP instance.
3644
* This is intentionally not an extension of CustomEvent
@@ -40,7 +48,8 @@ export type PHPEvent =
4048
| PHPRequestEndEvent
4149
| PHPRequestErrorEvent
4250
| PHPRuntimeInitializedEvent
43-
| PHPRuntimeBeforeExitEvent;
51+
| PHPRuntimeBeforeExitEvent
52+
| PHPFilesystemWriteEvent;
4453

4554
/**
4655
* A callback function that handles PHP events.

packages/php-wasm/web/src/lib/directory-handle-mount.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,11 @@ export function journalFSEventsToOpfs(
313313
}
314314
}
315315
php.addEventListener('request.end', flushJournal);
316+
php.addEventListener('filesystem.write', flushJournal);
316317
return function () {
317318
unbindJournal();
318319
php.removeEventListener('request.end', flushJournal);
320+
php.removeEventListener('filesystem.write', flushJournal);
319321
};
320322
}
321323

packages/playground/blueprints/public/blueprint-schema-validator.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,11 @@ const schema11 = {
11021102
description:
11031103
'The path to the directory in the git repository. Defaults to the repo root.',
11041104
},
1105+
'.git': {
1106+
type: 'boolean',
1107+
description:
1108+
'When true, include a `.git` directory with Git metadata (experimental).',
1109+
},
11051110
},
11061111
required: ['resource', 'url', 'ref'],
11071112
additionalProperties: false,
@@ -4031,7 +4036,7 @@ const schema25 = {
40314036
'.git': {
40324037
type: 'boolean',
40334038
description:
4034-
'When true, include a .git directory in the cloned files',
4039+
'When true, include a `.git` directory with Git metadata (experimental).',
40354040
},
40364041
},
40374042
required: ['resource', 'url', 'ref'],

packages/playground/blueprints/public/blueprint-schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1357,7 +1357,7 @@
13571357
},
13581358
".git": {
13591359
"type": "boolean",
1360-
"description": "When true, include a .git directory in the cloned files"
1360+
"description": "When true, include a `.git` directory with Git metadata (experimental)."
13611361
}
13621362
},
13631363
"required": ["resource", "url", "ref"],

packages/playground/blueprints/src/lib/steps/import-wxr.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ async function importWithDefaultImporter(
6868
* when rewriting links in the WXR payload, so we populate the flag here
6969
* just as the web request layer would.
7070
*/
71-
HTTPS: (await playground.absoluteUrl).startsWith('https://') ? 'on' : '',
71+
HTTPS: (await playground.absoluteUrl).startsWith('https://')
72+
? 'on'
73+
: '',
7274
},
7375
code: `<?php
7476
define('WP_LOAD_IMPORTERS', true);

packages/playground/components/e2e/tests/playground-file-picker.spec.ts

Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ declare global {
1111
__filePickerHarness?: {
1212
filesystem: HarnessFilesystem;
1313
reload: () => void;
14+
lastSelectedPath: string | null;
15+
lastDoubleClickedPath: string | null;
1416
};
1517
}
1618
}
@@ -120,6 +122,18 @@ const fileExists = (page: Page, path: string) =>
120122

121123
const isDir = (page: Page, path: string) => callFilesystem(page, 'isDir', path);
122124

125+
const getLastSelectedPath = (page: Page): Promise<string | null> => {
126+
return page.evaluate(
127+
() => window.__filePickerHarness?.lastSelectedPath ?? null
128+
);
129+
};
130+
131+
const getLastDoubleClickedPath = (page: Page): Promise<string | null> => {
132+
return page.evaluate(
133+
() => window.__filePickerHarness?.lastDoubleClickedPath ?? null
134+
);
135+
};
136+
123137
test.beforeEach(async ({ page }) => {
124138
page.on('pageerror', (error) => {
125139
console.error('pageerror', error);
@@ -215,14 +229,17 @@ test('arrow up moves focus to the previous visible node', async ({ page }) => {
215229
await expectFocused(page, 'wordpress');
216230
});
217231

218-
test.skip('type-ahead search focuses the first matching node', async ({
219-
page,
220-
}) => {
232+
test('type-ahead search focuses the first matching node', async ({ page }) => {
221233
await collapseNode(page, 'wordpress');
222234
await expandNode(page, 'wordpress');
235+
await expandNode(page, 'wordpress/workspace');
223236
const root = nodeButton(page, 'wordpress');
224237
await root.focus();
225-
await root.press('notes');
238+
await page.keyboard.press('n');
239+
await page.keyboard.press('o');
240+
await page.keyboard.press('t');
241+
await page.keyboard.press('e');
242+
await page.keyboard.press('s');
226243
await expectFocused(page, 'wordpress/workspace/notes.txt');
227244
});
228245

@@ -405,3 +422,166 @@ test.skip('invalid rename on a new file removes the placeholder entry', async ({
405422
fileExists(page, '/wordpress/workspace/new-file (1).php')
406423
).resolves.toBe(false);
407424
});
425+
426+
test('newly created files appear at top of files list', async ({ page }) => {
427+
await expandToPath(page, 'wordpress/workspace');
428+
await nodeButton(page, 'wordpress/workspace').click({
429+
button: 'right',
430+
});
431+
await page.getByRole('menuitem', { name: 'Create file' }).click();
432+
433+
// Wait for the rename input to appear - new files are named 'untitled.php' by default
434+
const pendingPath = 'wordpress/workspace/untitled.php';
435+
const input = renameInput(page, pendingPath);
436+
await expect(input).toBeVisible();
437+
438+
// Verify it's shown in edit mode (rename input visible and focused)
439+
await expect(input).toBeFocused();
440+
441+
// The file element should be present (as a form while renaming, not a button)
442+
const fileNode = nodeLocator(page, pendingPath);
443+
await expect(fileNode).toBeVisible();
444+
445+
// Complete the rename to verify the file persists
446+
await input.press('Enter');
447+
448+
// Now it should be a button after renaming is complete
449+
const untitledButton = nodeButton(page, pendingPath);
450+
await expect(untitledButton).toBeVisible();
451+
});
452+
453+
test('context menu auto-focuses first item', async ({ page }) => {
454+
await nodeButton(page, 'wordpress').click({ button: 'right' });
455+
await expect(page.getByRole('menu')).toBeVisible();
456+
457+
// The first menu item should be focused
458+
const firstMenuItem = page.getByRole('menuitem', { name: 'Create file' });
459+
await expect(firstMenuItem).toBeFocused();
460+
});
461+
462+
test('single click on file triggers onSelect but not onDoubleClickFile', async ({
463+
page,
464+
}) => {
465+
await expandToPath(page, 'wordpress/workspace');
466+
const file = nodeButton(page, 'wordpress/workspace/index.php');
467+
468+
// Single click the file
469+
await file.click();
470+
471+
// Wait a bit to ensure single-click timeout completes
472+
await page.waitForTimeout(350);
473+
474+
// onSelect should have been called
475+
const selected = await getLastSelectedPath(page);
476+
expect(selected).toBe('/wordpress/workspace/index.php');
477+
478+
// onDoubleClickFile should NOT have been called
479+
const doubleClicked = await getLastDoubleClickedPath(page);
480+
expect(doubleClicked).toBeNull();
481+
});
482+
483+
test('double click on file triggers onDoubleClickFile', async ({ page }) => {
484+
await expandToPath(page, 'wordpress/workspace');
485+
const file = nodeButton(page, 'wordpress/workspace/index.php');
486+
487+
// Double click the file
488+
await file.dblclick();
489+
490+
// Wait for double-click handler
491+
await page.waitForTimeout(100);
492+
493+
// onDoubleClickFile should have been called
494+
const doubleClicked = await getLastDoubleClickedPath(page);
495+
expect(doubleClicked).toBe('/wordpress/workspace/index.php');
496+
});
497+
498+
test('pressing Enter on file triggers onDoubleClickFile', async ({ page }) => {
499+
await expandToPath(page, 'wordpress/workspace');
500+
const file = nodeButton(page, 'wordpress/workspace/index.php');
501+
502+
// Focus and press Enter
503+
await file.focus();
504+
await file.press('Enter');
505+
506+
// Wait for Enter handler
507+
await page.waitForTimeout(100);
508+
509+
// onDoubleClickFile should have been called
510+
const doubleClicked = await getLastDoubleClickedPath(page);
511+
expect(doubleClicked).toBe('/wordpress/workspace/index.php');
512+
});
513+
514+
test('pressing Enter on folder toggles expansion without triggering doubleClick', async ({
515+
page,
516+
}) => {
517+
await collapseNode(page, 'wordpress');
518+
await expandNode(page, 'wordpress');
519+
await collapseNode(page, 'wordpress/workspace');
520+
521+
const folder = nodeButton(page, 'wordpress/workspace');
522+
await folder.focus();
523+
524+
// Press Enter to expand
525+
await folder.press('Enter');
526+
await expect(folder).toHaveAttribute('data-expanded', 'true');
527+
528+
// onDoubleClickFile should NOT have been called (it's a folder)
529+
const doubleClicked = await getLastDoubleClickedPath(page);
530+
expect(doubleClicked).toBeNull();
531+
});
532+
533+
test('rename input is not affected by type-ahead search', async ({ page }) => {
534+
// First, create a folder with name "123" that could trigger type-ahead
535+
await expandToPath(page, 'wordpress/workspace');
536+
await nodeButton(page, 'wordpress/workspace').click({ button: 'right' });
537+
await page.getByRole('menuitem', { name: 'Create directory' }).click();
538+
539+
// Find the rename input dynamically (don't hardcode the path as it may be "New Folder (1)" etc)
540+
// Wait for any visible focused input field in the tree
541+
const folderInput = page.locator('input[class*="renameInput"]').first();
542+
await expect(folderInput).toBeVisible();
543+
await expect(folderInput).toBeFocused();
544+
545+
// Rename the new folder to "123"
546+
await folderInput.fill('123');
547+
await folderInput.press('Enter');
548+
549+
// Wait for the folder to appear with the new name
550+
await expect(nodeButton(page, 'wordpress/workspace/123')).toBeVisible();
551+
552+
// Now try to rename a file and type "1" which matches the folder name
553+
await nodeButton(page, 'wordpress/workspace/index.php').click({
554+
button: 'right',
555+
});
556+
await page.getByRole('menuitem', { name: 'Rename' }).click();
557+
558+
const fileInput = renameInput(page, 'wordpress/workspace/index.php');
559+
await expect(fileInput).toBeVisible();
560+
await expect(fileInput).toBeFocused();
561+
562+
// Clear the input and type "1" which would normally trigger type-ahead to folder "123"
563+
await fileInput.fill('');
564+
await page.keyboard.press('1');
565+
566+
// The rename input should still be visible and focused (not closed)
567+
await expect(fileInput).toBeVisible();
568+
await expect(fileInput).toBeFocused();
569+
570+
// The input should contain "1"
571+
await expect(fileInput).toHaveValue('1');
572+
573+
// The folder "123" should NOT be focused (type-ahead should be disabled during rename)
574+
await expect(nodeButton(page, 'wordpress/workspace/123')).not.toBeFocused();
575+
576+
// Complete the rename with a valid filename
577+
await fileInput.fill('1test.php');
578+
await fileInput.press('Enter');
579+
580+
// Verify the file was renamed successfully
581+
await expect(
582+
nodeButton(page, 'wordpress/workspace/1test.php')
583+
).toBeVisible();
584+
await expect(
585+
nodeLocator(page, 'wordpress/workspace/index.php')
586+
).toHaveCount(0);
587+
});

0 commit comments

Comments
 (0)