mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-01-11 20:56:29 +00:00
Some checks are pending
testing-integration / test-sqlite (push) Waiting to run
testing-integration / test-mariadb (v10.6) (push) Waiting to run
testing-integration / test-mariadb (v11.8) (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing / security-check (push) Blocked by required conditions
- Replace the [Monaco Editor](https://microsoft.github.io/monaco-editor/) with [CodeMirror 6](https://codemirror.net/). This editor is used to facilitate the 'Add file' and 'Edit file' functionality. - Rationale: - Monaco editor is a great and powerful editor, however for Forgejo's purpose it acts more like a small IDE than a code editor and is doing too much. In my limited user research the usage of editing files via the web UI is largely for small changes that does not need the features that Monaco editor provides. - Monaco editor has no mobile support, Codemirror is very usable on mobile. - Monaco editor pulls in large dependencies (for language support) and by replacing it with Codemirror the amount of time that webpack needs to build the frontend is reduced by 50% (~30s -> ~15s). - The binary of Forgejo (build with `bindata` tag) is reduced by 2MiB. - Codemirror is much more lightweight and should be more usable on less powerful hardware, most notably the lazy loading is much faster as codemirror uses less javascript. - Because Codemirror is modular it is much easier to change the behavior of the code editor if we wish to. - Drawbacks: - Codemirror is quite modular and as seen in `package.json` and in `codeeditor.ts` we have to supply a lot more of its features to have feature parity with Monaco editor. - Monaco editor has great integrated language support (features that an lsp would provide), Codemirror only has such language support to an extend. - Monaco editor has its famous command palette (known by many as its also available in VSCode), this is not available in code mirror. - Good to note: - All features that was added on top of the monaco editor (such as dynamically changing language support depending on the filename) still works and the theme is based on the VSCode colors which largely resembles the monaco editor. - The code editor is still lazy-loaded (this is painfully clear by reading how imports are passed around in `codeeditor.ts`). - This change was privately tested by a few people, a few bugs were found (and fixed) but no major drawbacks were noted for their usage of the web editor. - There's a "search" button in the top bar, so that search can be used on mobile. It is otherwise only accessible via <kbd>Ctrl</kbd>+<kbd>f</kbd>. Co-authored-by: Beowulf <beowulf@beocode.eu> Co-authored-by: Gusted <postmaster@gusted.xyz> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10559 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Reviewed-by: 0ko <0ko@noreply.codeberg.org> Co-committed-by: Beowulf <beowulf@beocode.eu>
217 lines
7.9 KiB
TypeScript
217 lines
7.9 KiB
TypeScript
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// @watch start
|
|
// templates/repo/editor/edit.tmpl
|
|
// web_src/css/features/codeeditor.css
|
|
// web_src/js/features/codeeditor.ts
|
|
// web_src/js/features/codemirror*
|
|
// web_src/js/features/repo-editor.js
|
|
// web_src/js/features/repo-settings.js
|
|
// @watch end
|
|
|
|
import {expect, type Page} from '@playwright/test';
|
|
import {test} from './utils_e2e.ts';
|
|
|
|
test.use({user: 'user1'});
|
|
|
|
async function enterFilename(page: Page, filename: string) {
|
|
const filenameInput = page.getByPlaceholder('Name your file…');
|
|
await filenameInput.fill(filename);
|
|
}
|
|
|
|
async function pressEnter(page: Page) {
|
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
await page.waitForTimeout(5);
|
|
await page.keyboard.press('Enter', {delay: 5});
|
|
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
await page.waitForTimeout(10);
|
|
}
|
|
|
|
async function type(page: Page, text: string) {
|
|
await page.keyboard.type(text, {delay: 10});
|
|
}
|
|
|
|
test('New file editor', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/_new/master', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
await enterFilename(page, `f.txt`);
|
|
|
|
const editor = page.locator('.cm-content');
|
|
const backingTextArea = page.locator('#edit_area');
|
|
|
|
await editor.click();
|
|
await type(page, 'This');
|
|
await pressEnter(page);
|
|
await type(page, 'is');
|
|
await pressEnter(page);
|
|
await type(page, 'Frogejo!');
|
|
|
|
const expected = 'This\nis\nFrogejo!';
|
|
await expect(async () => {
|
|
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
|
expect(internal).toStrictEqual(expected);
|
|
}).toPass();
|
|
await expect(backingTextArea).toHaveValue(expected);
|
|
});
|
|
|
|
test('New file with autocomplete and indent', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/_new/master', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
await enterFilename(page, 'f.html');
|
|
|
|
const editor = page.locator('.cm-content');
|
|
const backingTextArea = page.locator('#edit_area');
|
|
|
|
await expect(editor).toHaveAttribute('data-language', 'html', {timeout: 3000});
|
|
|
|
await editor.click();
|
|
await type(page, '<html>');
|
|
await pressEnter(page);
|
|
await type(page, '<hea');
|
|
await page.locator('.cm-tooltip-autocomplete').waitFor({state: 'visible'});
|
|
await pressEnter(page);
|
|
await type(page, '>');
|
|
await pressEnter(page);
|
|
await type(page, '<title>Frogejo is the future');
|
|
|
|
const expected = '<html>\n <head>\n <title>Frogejo is the future</title>\n </head>\n</html>';
|
|
await expect(async () => {
|
|
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
|
expect(internal).toStrictEqual(expected);
|
|
}).toPass();
|
|
await expect(backingTextArea).toHaveValue(expected);
|
|
});
|
|
|
|
test('Preview for markdown file', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/_new/master?value=%23%20Frogejo', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
await enterFilename(page, 'f.md');
|
|
|
|
const editor = page.locator('.cm-content');
|
|
const preview = page.locator('button[data-tab="preview"]');
|
|
|
|
await expect(editor).toHaveAttribute('data-language', 'markdown', {timeout: 3000});
|
|
|
|
await preview.click();
|
|
await expect(preview).toHaveClass(/(^|\s)active(\s|$)/);
|
|
await expect(page.getByRole('heading', {name: 'Frogejo'})).toBeVisible();
|
|
});
|
|
|
|
test('Set from query', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/_new/master?value=This\\nis\\\\nFrogejo!', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
await expect(async () => {
|
|
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
|
expect(internal).toStrictEqual('This\nis\\nFrogejo!');
|
|
}).toPass();
|
|
});
|
|
|
|
test('Search in file', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/_new/master?value=This\\nis\\nFrogejo!\\nthIs', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
const editor = page.locator('.cm-content');
|
|
const searchField = page.locator('.fj-search input[name="search"]');
|
|
const toggleCase = page.locator('label[for="search_case_sensitive"]');
|
|
const toggleRegex = page.locator('label[for="search_regexp"]');
|
|
const toggleByWord = page.locator('label[for="search_by_word"]');
|
|
const nextButton = page.locator('button[aria-label="Next find"]');
|
|
|
|
await expect(async () => {
|
|
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
|
expect(internal).toStrictEqual('This\nis\nFrogejo!\nthIs');
|
|
}).toPass();
|
|
|
|
await editor.click();
|
|
|
|
// Open search
|
|
await page.keyboard.press('ControlOrMeta+F', {delay: 5});
|
|
await expect(searchField).toBeFocused();
|
|
|
|
const searchResults = editor.locator('.cm-line > .cm-searchMatch');
|
|
await expect(searchResults).toHaveCount(0);
|
|
|
|
await searchField.pressSequentially('Is');
|
|
await expect(searchResults).toHaveCount(3);
|
|
|
|
await expect(editor.locator('div:nth-child(1)')).not.toHaveClass(/(^|\s)cm-activeLine(\s|$)/);
|
|
await expect(editor.locator('div:nth-child(2)')).not.toHaveClass(/(^|\s)cm-activeLine(\s|$)/);
|
|
await nextButton.click();
|
|
await expect(editor.locator('div:nth-child(1)')).toHaveClass(/(^|\s)cm-activeLine(\s|$)/);
|
|
await expect(editor.locator('div:nth-child(2)')).not.toHaveClass(/(^|\s)cm-activeLine(\s|$)/);
|
|
await nextButton.click();
|
|
await expect(editor.locator('div:nth-child(1)')).not.toHaveClass(/(^|\s)cm-activeLine(\s|$)/);
|
|
await expect(editor.locator('div:nth-child(2)')).toHaveClass(/(^|\s)cm-activeLine(\s|$)/);
|
|
|
|
await toggleByWord.click();
|
|
await expect(searchResults).toHaveCount(1);
|
|
|
|
await toggleCase.click();
|
|
await expect(searchResults).toHaveCount(0);
|
|
|
|
await toggleByWord.click();
|
|
await expect(searchResults).toHaveCount(1);
|
|
|
|
await toggleRegex.click();
|
|
await expect(searchResults).toHaveCount(1);
|
|
|
|
await toggleCase.click();
|
|
await searchField.clear();
|
|
await expect(searchResults).toHaveCount(0);
|
|
|
|
await searchField.pressSequentially('^is$');
|
|
await expect(searchResults).toHaveCount(1);
|
|
|
|
await page.locator('#editor-find').click();
|
|
await expect(searchResults).toHaveCount(0);
|
|
await expect(searchField).toHaveCount(0);
|
|
});
|
|
|
|
test('Replace in file', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/_new/master?value=This\\nis\\nFrogejo!\\nthIs', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
const editor = page.locator('.cm-content');
|
|
const searchField = page.locator('.fj-search input[name="search"]');
|
|
const replaceField = page.locator('.fj-search input[name="replace"]');
|
|
|
|
await expect(async () => {
|
|
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
|
expect(internal).toStrictEqual('This\nis\nFrogejo!\nthIs');
|
|
}).toPass();
|
|
|
|
await editor.click();
|
|
|
|
// Open search
|
|
await page.locator('#editor-find').click();
|
|
await expect(searchField).toBeFocused();
|
|
|
|
await searchField.pressSequentially('Is');
|
|
await replaceField.pressSequentially('Blub');
|
|
|
|
await page.getByRole('button', {name: 'Replace all'}).click();
|
|
|
|
await expect(async () => {
|
|
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
|
|
expect(internal).toStrictEqual('ThBlub\nBlub\nFrogejo!\nthBlub');
|
|
}).toPass();
|
|
});
|
|
|
|
test('Do not open search if search button not available', async ({page}) => {
|
|
const response = await page.goto('/user2/repo1/settings/hooks/git/pre-receive', {waitUntil: 'domcontentloaded'});
|
|
expect(response?.status()).toBe(200);
|
|
|
|
const editor = page.locator('.cm-content');
|
|
const searchField = page.locator('.fj-search input[name="search"]');
|
|
|
|
await expect(page.locator('#editor-find')).toHaveCount(0);
|
|
await editor.click();
|
|
|
|
await page.keyboard.press('ControlOrMeta+F', {delay: 5});
|
|
await expect(searchField).toHaveCount(0);
|
|
});
|