feat(ui): replace Monaco with CodeMirror (#10559)
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>
This commit is contained in:
Beowulf 2026-01-04 23:52:33 +01:00 committed by Gusted
parent 1cecec6536
commit 28e0af23fa
31 changed files with 1665 additions and 325 deletions

View file

@ -528,11 +528,7 @@ export default tseslint.config(
'no-this-before-super': [2],
'no-throw-literal': [2],
'no-undef-init': [2],
'no-undef': [2, {
typeof: true,
}],
'no-undef': [0],
'no-undefined': [0],
'no-underscore-dangle': [0],
'no-unexpected-multiline': [2],

View file

@ -248,5 +248,13 @@
"admin.auths.oauth2_quota_group_claim_name": "Claim name providing group names for this source to be used for quota management. (Optional)",
"admin.auths.oauth2_quota_group_map": "Map claimed groups to quota groups. (Optional - requires claim name above)",
"admin.auths.oauth2_quota_group_map_removal": "Remove users from synchronized quota groups if user does not belong to corresponding group.",
"editor.search": "Search",
"editor.find_previous": "Previous find",
"editor.find_next": "Next find",
"editor.replace": "Replace",
"editor.replace_all": "Replace all",
"editor.toggle_case": "Toggle case sensitivity",
"editor.toggle_regex": "Toggle using regular expressions",
"editor.toggle_whole_word": "Toggle matching whole words",
"meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it."
}

537
package-lock.json generated
View file

@ -9,10 +9,33 @@
"@citation-js/core": "0.7.21",
"@citation-js/plugin-bibtex": "0.7.21",
"@citation-js/plugin-software-formats": "0.6.1",
"@codemirror/autocomplete": "6.19.1",
"@codemirror/commands": "6.10.0",
"@codemirror/lang-cpp": "6.0.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-go": "6.0.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-java": "6.0.2",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-less": "6.0.2",
"@codemirror/lang-liquid": "6.3.0",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/lang-sass": "6.0.2",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.11.3",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.2",
"@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0",
"@google/model-viewer": "4.1.0",
"@lezer/highlight": "1.2.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0",
"ansi_up": "6.0.5",
@ -35,8 +58,6 @@
"mermaid": "11.12.2",
"mini-css-extract-plugin": "2.9.3",
"minimatch": "10.1.1",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.1",
"pdfobject": "2.3.0",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
@ -477,6 +498,297 @@
"node": ">=14.0.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
"integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-cpp": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/cpp": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-less": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
"integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-liquid": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.0.tgz",
"integrity": "sha512-fY1YsUExcieXRTsCiwX/bQ9+PbCTA/Fumv7C7mTUZHoFkibfESnaXwpr2aKH6zZVwysEunsHHkaIpM/pl3xETQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sass": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
"integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/sass": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
"integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.2",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.2.tgz",
"integrity": "sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
@ -2131,6 +2443,183 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
"integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
"license": "MIT"
},
"node_modules/@lezer/cpp": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.4.tgz",
"integrity": "sha512-aYSdZyUueeTgnfXQntiGUqKNW5WujlAsIbbHzkfJDneSZoyjPg8ObmWG3bzDPVYMC/Kf4l43WJLCunPnYFfQ0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.12.tgz",
"integrity": "sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz",
"integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.1.tgz",
"integrity": "sha512-72ah+Sml7lD8Wn7lnz9vwYmZBo9aQT+I2gjK/0epI+gjdwUbWw3MJ/ZBGEqG1UfrIauRqH37/c5mVHXeCTGXtA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/sass": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz",
"integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz",
"integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.0.tgz",
@ -2146,6 +2635,12 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mcaptcha/core-glue": {
"version": "0.1.0-alpha-5",
"resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz",
@ -5951,6 +6446,12 @@
}
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -11298,26 +11799,6 @@
"ufo": "^1.6.1"
}
},
"node_modules/monaco-editor": {
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT",
"peer": true
},
"node_modules/monaco-editor-webpack-plugin": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.1.tgz",
"integrity": "sha512-WxdbFHS3Wtz4V9hzhe/Xog5hQRSMxmDLkEEYZwqMDHgJlkZo00HVFZR0j5d0nKypjTUkkygH3dDSXERLG4757A==",
"license": "MIT",
"dependencies": {
"loader-utils": "^2.0.2"
},
"peerDependencies": {
"monaco-editor": ">= 0.31.0",
"webpack": "^4.5.0 || 5.x"
}
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
@ -13755,6 +14236,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-search": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
@ -15259,6 +15746,12 @@
"vue": "^3.2.29"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/watchpack": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz",

View file

@ -8,10 +8,33 @@
"@citation-js/core": "0.7.21",
"@citation-js/plugin-bibtex": "0.7.21",
"@citation-js/plugin-software-formats": "0.6.1",
"@codemirror/autocomplete": "6.19.1",
"@codemirror/commands": "6.10.0",
"@codemirror/lang-cpp": "6.0.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-go": "6.0.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-java": "6.0.2",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-less": "6.0.2",
"@codemirror/lang-liquid": "6.3.0",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/lang-sass": "6.0.2",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.11.3",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.2",
"@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0",
"@google/model-viewer": "4.1.0",
"@lezer/highlight": "1.2.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0",
"ansi_up": "6.0.5",
@ -34,8 +57,6 @@
"mermaid": "11.12.2",
"mini-css-extract-plugin": "2.9.3",
"minimatch": "10.1.1",
"monaco-editor": "0.52.2",
"monaco-editor-webpack-plugin": "7.1.1",
"pdfobject": "2.3.0",
"postcss": "8.5.6",
"postcss-loader": "8.2.0",
@ -101,9 +122,7 @@
"vite-string-plugin": "1.4.9",
"vitest": "4.0.16"
},
"browserslist": [
"defaults"
],
"browserslist": ["defaults"],
"scarfSettings": {
"enabled": false
}

View file

@ -137,11 +137,6 @@
],
"automerge": true
},
{
"description": "Hold back on some package updates for a few days",
"matchPackageNames": ["monaco-editor"],
"minimumReleaseAge": "30 days"
},
{
"description": "Disable indirect updates for stable branches",
"matchBaseBranches": ["/^v\\d+\\.\\d+\\/forgejo$/"],

View file

@ -25,25 +25,28 @@
</div>
</div>
<div class="field">
<div class="ui top attached tabular menu" data-write="write" data-preview="preview" data-diff="diff">
<a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a>
<a class="item" data-tab="preview" data-url="{{.Repository.Link}}/markup" data-context="{{.RepoLink}}" data-branch-path="{{.BranchNameSubURL}}" data-markup-mode="file">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
{{if not .IsNewFile}}
<a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
{{end}}
<div id="editor-bar">
<div class="switch" data-write="write" data-preview="preview" data-diff="diff">
<button type="button" class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</button>
<button type="button" class="item" data-tab="preview" data-url="{{.Repository.Link}}/markup" data-context="{{.RepoLink}}" data-branch-path="{{.BranchNameSubURL}}" data-markup-mode="file">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</button>
{{if not .IsNewFile}}
<button type="button" class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</button>
{{end}}
</div>
<button class="secondary button" id="editor-find" type="button">{{svg "octicon-search"}}<span class="text not-mobile">Search</span></button>
</div>
<div class="ui bottom attached active tab segment" data-tab="write">
<div class="ui bottom active tab segment" data-tab="write">
<textarea id="edit_area" name="content" class="tw-hidden" data-id="repo-{{.Repository.Name}}-{{.TreePath}}"
data-url="{{.Repository.Link}}/markup"
data-context="{{.RepoLink}}"
data-previewable-extensions="{{.PreviewableExtensions}}"
data-line-wrap-extensions="{{.LineWrapExtensions}}">{{.FileContent}}</textarea>
<div class="editor-loading is-loading"></div>
{{template "shared/codemirror_container" .}}
</div>
<div class="ui bottom attached tab segment markup" data-tab="preview">
<div class="ui bottom tab segment markup" data-tab="preview">
{{ctx.Locale.Tr "loading"}}
</div>
<div class="ui bottom attached tab segment diff edit-diff" data-tab="diff">
<div class="ui bottom tab segment diff edit-diff" data-tab="diff">
<div class="tw-p-16"></div>
</div>
</div>

View file

@ -18,15 +18,18 @@
</div>
</div>
<div class="field">
<div class="ui top attached tabular menu" data-write="write">
<a class="active item" data-tab="write">{{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}}</a>
<div id="editor-bar">
<div class="switch" data-write="write">
<button type="button" class="active item" data-tab="write">{{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}}</button>
</div>
<button class="secondary button" id="editor-find" type="button">{{svg "octicon-search"}}<span class="text not-mobile">Search</span></button>
</div>
<div class="ui bottom attached active tab segment" data-tab="write">
<textarea id="edit_area" name="content" class="tw-hidden" data-id="repo-{{.Repository.Name}}-patch"
data-context="{{.RepoLink}}"
data-line-wrap-extensions="{{.LineWrapExtensions}}">
{{.FileContent}}</textarea>
<div class="editor-loading is-loading"></div>
{{template "shared/codemirror_container" .}}
</div>
</div>
{{template "repo/editor/commit_form" .}}

View file

@ -14,7 +14,7 @@
<div class="field">
<label for="content">{{ctx.Locale.Tr "repo.settings.githook_content"}}</label>
<textarea id="content" name="content" class="tw-hidden">{{if .IsActive}}{{.Content}}{{else}}{{.Sample}}{{end}}</textarea>
<div class="editor-loading is-loading"></div>
{{template "shared/codemirror_container" .}}
</div>
<div class="inline field">
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_githook"}}</button>

View file

@ -0,0 +1,11 @@
<div class="codemirror-container"
data-search-text="{{ctx.Locale.Tr "editor.search"}}"
data-find-prev-text="{{ctx.Locale.Tr "editor.find_previous"}}"
data-find-next-text="{{ctx.Locale.Tr "editor.find_next"}}"
data-replace-text="{{ctx.Locale.Tr "editor.replace"}}"
data-replace-all-text="{{ctx.Locale.Tr "editor.replace_all"}}"
data-toggle-case-text="{{ctx.Locale.Tr "editor.toggle_case"}}"
data-toggle-regex-text="{{ctx.Locale.Tr "editor.toggle_regex"}}"
data-toggle-whole-word-text="{{ctx.Locale.Tr "editor.toggle_whole_word"}}">
<div class="editor-loading is-loading"></div>
</div>

View file

@ -0,0 +1,217 @@
// 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);
});

View file

@ -103,14 +103,17 @@ func TestE2e(t *testing.T) {
}
t.Run(testname, func(t *testing.T) {
if testname == "user-settings.test.e2e" {
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testE2eWebRoutes, routers.NormalRoutes())()
}
if testname == "buttons.test.e2e" || testname == "dropdown.test.e2e" || testname == "modal.test.e2e" {
defer test.MockVariableValue(&setting.IsProd, false)()
defer test.MockVariableValue(&testE2eWebRoutes, routers.NormalRoutes())()
}
if testname == "codemirror.test.e2e" {
defer test.MockVariableValue(&setting.DisableGitHooks, false)()
}
if testname == "user-settings.test.e2e" {
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testE2eWebRoutes, routers.NormalRoutes())()
}
// Default 2 minute timeout
onForgejoRun(t, func(*testing.T, *url.URL) {

View file

@ -19,7 +19,7 @@ test('Repository image diff', async ({page}) => {
const filename = `${dynamic_id()}.svg`;
await page.getByPlaceholder('Name your file…').fill(filename);
await page.locator('.monaco-editor').click();
await page.locator('.cm-content').click();
await page.keyboard.type('<svg version="1.1" width="300" height="200" xmlns="http://www.w3.org/2000/svg"><circle cx="150" cy="100" r="80" fill="green" /></svg>\n');
await page.locator('.quick-pull-choice input[value="direct"]').click();
@ -28,7 +28,7 @@ test('Repository image diff', async ({page}) => {
response = await page.goto(`/user2/repo1/_edit/master/${filename}`, {waitUntil: 'domcontentloaded'});
expect(response?.status()).toBe(200);
await page.locator('.monaco-editor').click();
await page.locator('.cm-content').click();
await page.keyboard.press('Meta+KeyA');
await page.keyboard.type('<svg version="1.1" width="300" height="200" xmlns="http://www.w3.org/2000/svg"><circle cx="150" cy="100" r="80" fill="red" /></svg>\n');

View file

@ -23,16 +23,15 @@ test('Markdown image preview behaviour', async ({page}) => {
// Click 'Edit file' tab
await page.locator('[data-tooltip-content="Edit file"]').click();
// This yields the monaco editor
const editor = page.getByRole('presentation').nth(0);
await editor.click();
// This yields the codemirror editor
await page.locator('.cm-content').click();
// Clear all the content
await page.keyboard.press('ControlOrMeta+KeyA');
// Add the image
await page.keyboard.type('![Logo of Forgejo](./assets/logo.svg "Logo of Forgejo")');
// Click 'Preview' tab
await page.locator('a[data-tab="preview"]').click();
await page.locator('button[data-tab="preview"]').click();
// Check for the image preview via the expected attribute
const preview = page.locator('div[data-tab="preview"] p[dir="auto"] a');

View file

@ -23,7 +23,7 @@ test('Dialog modal', async ({page}) => {
const filename = `${dynamic_id()}.md`;
await page.getByPlaceholder('Name your file…').fill(filename);
await page.locator('.monaco-editor').click();
await page.locator('.cm-content').click();
await page.keyboard.type('Hi, nice to meet you. Can I talk about ');
await page.locator('.quick-pull-choice input[value="direct"]').click();
@ -32,7 +32,7 @@ test('Dialog modal', async ({page}) => {
response = await page.goto(`/user2/repo1/_edit/master/${filename}`, {waitUntil: 'domcontentloaded'});
expect(response?.status()).toBe(200);
await page.locator('.monaco-editor-container').click();
await page.locator('.cm-content').click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.press('Backspace');

View file

@ -81,7 +81,7 @@ test('New repo: initialize later', async ({page}) => {
// Otherwise, filling the filename might not populate the tree_path form field or preview tab
// The editor has race conditions, likely related to https://codeberg.org/forgejo/forgejo/issues/3371
await expect(page.locator('.is-loading')).toBeHidden();
await page.locator('.view-lines').click();
await page.locator('.cm-content').click();
await page.keyboard.type('# Heading\n\nHello Forgejo!');
await page.getByPlaceholder('Name your file…').fill('README.md');
await expect(page.getByText('Preview')).toBeVisible();

View file

@ -25,6 +25,6 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"exactOptionalPropertyTypes": false,
"exactOptionalPropertyTypes": false
}
}

View file

@ -1,7 +1,6 @@
import {defineConfig} from 'vitest/config';
import vuePlugin from '@vitejs/plugin-vue';
import {stringPlugin} from 'vite-string-plugin';
import {resolve} from 'node:path';
export default defineConfig({
test: {
@ -15,9 +14,6 @@ export default defineConfig({
globals: true,
watch: false,
mockReset: true,
alias: {
'monaco-editor': resolve(import.meta.dirname, '/node_modules/monaco-editor/esm/vs/editor/editor.api'),
},
},
plugins: [
stringPlugin(),

View file

@ -1,48 +1,103 @@
.monaco-editor-container,
.editor-loading.is-loading {
width: 100%;
min-height: 200px;
height: 90vh;
height: 200px;
}
.edit.githook .monaco-editor-container {
.edit.githook .codemirror-container {
border: 1px solid var(--color-secondary);
height: 70vh;
}
/* overwrite conflicting styles from fomantic */
.monaco-editor-container .inputarea {
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
resize: none !important;
border: none !important;
color: transparent !important;
background-color: transparent !important;
#editor-bar {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
/* these seem unthemeable */
.monaco-scrollable-element > .scrollbar > .slider {
background: var(--color-primary) !important;
}
.monaco-scrollable-element > .scrollbar > .slider:hover {
background: var(--color-primary-dark-1) !important;
}
.monaco-scrollable-element > .scrollbar > .slider:active {
background: var(--color-primary-dark-2) !important;
@media (max-width: 768px) {
#editor-bar {
gap: var(--button-spacing);
.switch {
overflow-x: scroll;
}
}
}
/* fomantic styles destroy this element only visible on IOS, restore it */
.monaco-editor .iPadShowKeyboard {
border: none !important;
width: 58px !important;
min-width: 0 !important;
height: 36px !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
position: absolute !important;
resize: none !important;
overflow: hidden !important;
border-radius: var(--border-radius-medium) !important;
.cm-panel.fj-search {
position: absolute;
top: 0;
right: 0;
background-color: var(--color-body);
box-shadow: 0 6px 18px var(--color-shadow);
border-radius: 0.3rem;
display: flex;
flex-direction: column;
gap: 1rem;
.search-input-group {
align-items: center;
background: var(--color-input-background);
border: 1px solid var(--color-input-border);
border-radius: 0.3rem;
display: inline-flex;
width: 100%;
&:focus-within {
border-color: var(--color-primary);
}
input {
background: none !important;
box-shadow: none !important;
border-color: transparent !important;
}
label {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--color-secondary-bg);
border-radius: 50%;
padding: 6px 12px;
height: 2em;
width: 2em;
font-size: 80%;
white-space: pre;
user-select: none;
&:hover {
background-color: var(--color-label-hover-bg);
}
&.focused {
box-shadow:
inset 0 1px 1px rgb(0 0 0 / 8%),
0 0 8px var(--color-accent);
}
&.active {
background-color: var(--color-accent);
}
}
}
.search-hidden-inputs {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
}
.search-section,
.replace-section {
display: flex;
gap: var(--button-spacing);
}
}
@media screen and (max-width: 786px) {
.cm-panel.fj-search {
position: sticky;
box-shadow: none;
}
}

View file

@ -14,28 +14,13 @@ function shouldIgnoreError(err) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Ignore these errors.
if (!err.stack?.includes(assetBaseUrl)) return true;
// Ignore some known internal errors that we are unable to currently fix (eg via Monaco).
const ignorePatterns = [
'/assets/js/monaco.', // https://codeberg.org/forgejo/forgejo/issues/3638 , https://github.com/go-gitea/gitea/issues/30861 , https://github.com/microsoft/monaco-editor/issues/4496
];
for (const pattern of ignorePatterns) {
if (err.stack?.includes(pattern)) return true;
}
return false;
}
const filteredErrors = new Set([
'getModifierState is not a function', // https://github.com/microsoft/monaco-editor/issues/4325
]);
export function showGlobalErrorMessage(msg) {
const pageContent = document.querySelector('.page-content');
if (!pageContent) return;
for (const filteredError of filteredErrors) {
if (msg.includes(filteredError)) return;
}
// compact the message to a data attribute to avoid too many duplicated messages
const msgCompact = msg.replace(/\W/g, '').trim();
let msgDiv = pageContent.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);

View file

@ -1,191 +0,0 @@
import tinycolor from 'tinycolor2';
import {basename, extname, isObject, isDarkTheme} from '../utils.js';
import {onInputDebounce} from '../utils/dom.js';
const languagesByFilename = {};
const languagesByExt = {};
const baseOptions = {
fontFamily: 'var(--fonts-monospace)',
fontSize: 14, // https://github.com/microsoft/monaco-editor/issues/2242
guides: {bracketPairs: false, indentation: false},
links: false,
minimap: {enabled: false},
occurrencesHighlight: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'all',
renderLineHighlightOnlyWhenFocus: true,
rulers: false,
scrollbar: {horizontalScrollbarSize: 6, verticalScrollbarSize: 6},
scrollBeyondLastLine: false,
automaticLayout: true,
};
function getEditorconfig(input) {
try {
return JSON.parse(input.getAttribute('data-editorconfig'));
} catch {
return null;
}
}
function initLanguages(monaco) {
for (const {filenames, extensions, id} of monaco.languages.getLanguages()) {
for (const filename of filenames || []) {
languagesByFilename[filename] = id;
}
for (const extension of extensions || []) {
languagesByExt[extension] = id;
}
}
}
function getLanguage(filename) {
return languagesByFilename[filename] || languagesByExt[extname(filename)] || 'plaintext';
}
function updateEditor(monaco, editor, filename, lineWrapExts) {
editor.updateOptions(getFileBasedOptions(filename, lineWrapExts));
const model = editor.getModel();
const language = model.getLanguageId();
const newLanguage = getLanguage(filename);
if (language !== newLanguage) monaco.editor.setModelLanguage(model, newLanguage);
}
// export editor for customization - https://github.com/go-gitea/gitea/issues/10409
function exportEditor(editor) {
if (!window.codeEditors) window.codeEditors = [];
if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor);
}
export async function createMonaco(textarea, filename, editorOpts) {
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
initLanguages(monaco);
let {language, ...other} = editorOpts;
if (!language) language = getLanguage(filename);
const container = document.createElement('div');
container.className = 'monaco-editor-container';
textarea.parentNode.append(container);
// https://github.com/microsoft/monaco-editor/issues/2427
// also, monaco can only parse 6-digit hex colors, so we convert the colors to that format
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
monaco.editor.defineTheme('gitea', {
base: isDarkTheme() ? 'vs-dark' : 'vs',
inherit: true,
rules: [
{
background: getColor('--color-code-bg'),
},
],
colors: {
'editor.background': getColor('--color-code-bg'),
'editor.foreground': getColor('--color-text'),
'editor.inactiveSelectionBackground': getColor('--color-primary-light-4'),
'editor.lineHighlightBackground': getColor('--color-editor-line-highlight'),
'editor.selectionBackground': getColor('--color-primary-light-3'),
'editor.selectionForeground': getColor('--color-primary-light-3'),
'editorLineNumber.background': getColor('--color-code-bg'),
'editorLineNumber.foreground': getColor('--color-secondary-dark-6'),
'editorWidget.background': getColor('--color-body'),
'editorWidget.border': getColor('--color-secondary'),
'input.background': getColor('--color-input-background'),
'input.border': getColor('--color-input-border'),
'input.foreground': getColor('--color-input-text'),
'scrollbar.shadow': getColor('--color-shadow'),
'progressBar.background': getColor('--color-primary'),
},
});
const editor = monaco.editor.create(container, {
value: textarea.value,
theme: 'gitea',
language,
...other,
});
monaco.editor.addKeybindingRules([
{keybinding: monaco.KeyCode.Enter, command: null}, // disable enter from accepting code completion
]);
const model = editor.getModel();
model.onDidChangeContent(() => {
textarea.value = editor.getValue({preserveBOM: true});
textarea.dispatchEvent(new Event('change')); // seems to be needed for jquery-are-you-sure
});
exportEditor(editor);
const loading = document.querySelector('.editor-loading');
if (loading) loading.remove();
return {monaco, editor};
}
function getFileBasedOptions(filename, lineWrapExts) {
return {
wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off',
};
}
function togglePreviewDisplay(previewable) {
const previewTab = document.querySelector('a[data-tab="preview"]');
if (!previewTab) return;
if (previewable) {
const newUrl = (previewTab.getAttribute('data-url') || '').replace(/(.*)\/.*/, `$1/markup`);
previewTab.setAttribute('data-url', newUrl);
previewTab.style.display = '';
} else {
previewTab.style.display = 'none';
// If the "preview" tab was active, user changes the filename to a non-previewable one,
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
if (previewTab.classList.contains('active')) {
const writeTab = document.querySelector('a[data-tab="write"]');
writeTab.click();
}
}
}
export async function createCodeEditor(textarea, filenameInput) {
const filename = basename(filenameInput.value);
const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
const previewable = previewableExts.has(extname(filename));
const editorConfig = getEditorconfig(filenameInput);
togglePreviewDisplay(previewable);
const {monaco, editor} = await createMonaco(textarea, filename, {
...baseOptions,
...getFileBasedOptions(filenameInput.value, lineWrapExts),
...getEditorConfigOptions(editorConfig),
});
filenameInput.addEventListener('input', onInputDebounce(() => {
const filename = filenameInput.value;
const previewable = previewableExts.has(extname(filename));
togglePreviewDisplay(previewable);
updateEditor(monaco, editor, filename, lineWrapExts);
}));
return editor;
}
function getEditorConfigOptions(ec) {
if (!isObject(ec)) return {};
const opts = {};
opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec);
if ('indent_size' in ec) opts.indentSize = Number(ec.indent_size);
if ('tab_width' in ec) opts.tabSize = Number(ec.tab_width) || opts.indentSize;
if ('max_line_length' in ec) opts.rulers = [Number(ec.max_line_length)];
opts.trimAutoWhitespace = ec.trim_trailing_whitespace === true;
opts.insertSpaces = ec.indent_style === 'space';
opts.useTabStops = ec.indent_style === 'tab';
return opts;
}

View file

@ -0,0 +1,124 @@
import {basename, extname} from '../utils.js';
import {hideElem, onInputDebounce, showElem} from '../utils/dom.js';
import {createCodemirror, type CodemirrorEditor, type EditorOptions} from './codemirror.ts';
import {EditorView} from '@codemirror/view';
import type {LanguageSupport} from '@codemirror/language';
interface EditorConfig {
indent_style: string;
indent_size: string;
}
export class SettableEditorView extends EditorView {
public setValue(value: string) {
// Replace \n with the actual newline character and unescape escaped \n
value = value.replaceAll(/(?<!\\)\\n/g, '\n').replaceAll(/\\\\n/g, '\\n');
this.dispatch({changes: {from: 0, to: this.state.doc.length, insert: value}});
}
}
function getEditorconfig(input: HTMLInputElement): null | EditorConfig {
try {
return JSON.parse(input.getAttribute('data-editorconfig'));
} catch {
return null;
}
}
async function updateEditor(editor: CodemirrorEditor, filename: string, lineWrapExts: string[]) {
const fileOption = getFileBasedOptions(filename, lineWrapExts);
editor.view.dispatch({
effects: editor.compartments.wordWrap.reconfigure(fileOption.wordWrap ? editor.codemirrorView.EditorView.lineWrapping : []),
});
const currentLanguage = editor.compartments.language.get(editor.view.state) as Array<unknown> | LanguageSupport;
const newLanguage = editor.codemirrorLanguage.LanguageDescription.matchFilename(editor.languages, filename);
if (!currentLanguage || (currentLanguage as Array<unknown>).length === 0 || !newLanguage || (currentLanguage as LanguageSupport).language.name.toLowerCase() !== newLanguage.name.toLowerCase()) {
editor.view.dispatch({
effects: editor.compartments.language.reconfigure(newLanguage ? await newLanguage.load() : []),
});
}
}
function getFileBasedOptions(filename: string, lineWrapExts: string[]): Pick<EditorOptions, 'wordWrap'> {
return {
wordWrap: (lineWrapExts || []).includes(extname(filename)),
};
}
function togglePreviewDisplay(previewable: boolean) {
const previewTab = document.querySelector('.item[data-tab="preview"]') as HTMLAnchorElement;
if (!previewTab) return;
if (previewable) {
const newUrl = (previewTab.getAttribute('data-url') || '').replace(/(.*)\/.*/, `$1/markup`);
previewTab.setAttribute('data-url', newUrl);
previewTab.style.display = '';
} else {
previewTab.style.display = 'none';
// If the "preview" tab was active, user changes the filename to a non-previewable one,
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
if (previewTab.classList.contains('active')) {
const writeTab = document.querySelector('.item[data-tab="write"]') as HTMLAnchorElement;
writeTab.click();
}
}
}
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput: HTMLInputElement): Promise<SettableEditorView> {
const filename = basename(filenameInput.value);
const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
const previewable = previewableExts.has(extname(filename));
const editorConfig = getEditorconfig(filenameInput);
togglePreviewDisplay(previewable);
const editor = await createCodemirror(textarea, filename, {
...getFileBasedOptions(filenameInput.value, lineWrapExts),
...getEditorConfigOptions(editorConfig),
});
filenameInput.addEventListener('input', onInputDebounce(async () => {
const filename = filenameInput.value;
const previewable = previewableExts.has(extname(filename));
togglePreviewDisplay(previewable);
await updateEditor(editor, filename, lineWrapExts);
}));
const searchButton = document.querySelector('#editor-find');
searchButton.addEventListener('click', () => {
const search = editor.codemirrorSearch;
const view = editor.view;
if (search.searchPanelOpen(view.state)) {
search.closeSearchPanel(view);
} else {
search.openSearchPanel(view);
}
});
const writeTab = document.querySelector('#editor-bar .switch .item[data-tab="write"]');
document.querySelector('#editor-bar .switch').addEventListener('click', () => {
if (writeTab.classList.contains('active')) {
showElem(searchButton);
} else {
hideElem(searchButton);
}
});
return Object.setPrototypeOf(editor.view, SettableEditorView.prototype);
}
function getEditorConfigOptions(ec: null | EditorConfig): Pick<EditorOptions, 'indentSize' | 'tabSize' | 'indentStyle'> {
if (ec === null) {
return {indentStyle: 'space'};
}
const opts: ReturnType<typeof getEditorConfigOptions> = {
indentStyle: ec.indent_style,
};
if ('indent_size' in ec) opts.indentSize = Number(ec.indent_size);
if ('tab_width' in ec) opts.tabSize = Number(ec.tab_width) || opts.indentSize;
return opts;
}

View file

@ -0,0 +1,161 @@
import type {LanguageDescription} from '@codemirror/language';
export function languages(codemirrorLanguage: CodeMirrorLanguage): LanguageDescription[] {
return [
codemirrorLanguage.LanguageDescription.of({
name: 'C',
extensions: ['c', 'h', 'ino'],
async load() {
return (await import('@codemirror/lang-cpp')).cpp();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'C++',
alias: ['cpp'],
extensions: ['cpp', 'c++', 'cc', 'cxx', 'hpp', 'h++', 'hh', 'hxx'],
async load() {
return (await import('@codemirror/lang-cpp')).cpp();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'CSS',
extensions: ['css'],
async load() {
return (await import('@codemirror/lang-css')).css();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Go',
extensions: ['go'],
async load() {
return (await import('@codemirror/lang-go')).go();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'HTML',
alias: ['xhtml'],
extensions: ['html', 'htm', 'handlebars', 'hbs'],
async load() {
return (await import('@codemirror/lang-html')).html();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Java',
extensions: ['java'],
async load() {
return (await import('@codemirror/lang-java')).java();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'JavaScript',
alias: ['ecmascript', 'js', 'node'],
extensions: ['js', 'mjs', 'cjs'],
async load() {
return (await import('@codemirror/lang-javascript')).javascript();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'JSON',
alias: ['json5'],
extensions: ['json', 'map'],
async load() {
return (await import('@codemirror/lang-json')).json();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'JSX',
extensions: ['jsx'],
async load() {
return (await import('@codemirror/lang-javascript')).javascript({jsx: true});
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'LESS',
extensions: ['less'],
async load() {
return (await import('@codemirror/lang-less')).less();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Liquid',
extensions: ['liquid'],
async load() {
return (await import('@codemirror/lang-liquid')).liquid();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Markdown',
extensions: ['md', 'markdown', 'mkd'],
async load() {
return (await import('@codemirror/lang-markdown')).markdown();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'PHP',
extensions: ['php', 'php3', 'php4', 'php5', 'php7', 'phtml'],
async load() {
return (await import('@codemirror/lang-php')).php();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Python',
extensions: ['BUILD', 'bzl', 'py', 'pyw'],
filename: /^(BUCK|BUILD)$/,
async load() {
return (await import('@codemirror/lang-python')).python();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Rust',
extensions: ['rs'],
async load() {
return (await import('@codemirror/lang-rust')).rust();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'Sass',
extensions: ['sass'],
async load() {
return (await import('@codemirror/lang-sass')).sass({indented: true});
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'SCSS',
extensions: ['scss'],
async load() {
return (await import('@codemirror/lang-sass')).sass();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'TSX',
extensions: ['tsx'],
async load() {
return (await import('@codemirror/lang-javascript')).javascript({jsx: true, typescript: true});
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'TypeScript',
alias: ['ts'],
extensions: ['ts', 'mts', 'cts'],
async load() {
return (await import('@codemirror/lang-javascript')).javascript({typescript: true});
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'XML',
alias: ['rss', 'wsdl', 'xsd'],
extensions: ['xml', 'xsl', 'xsd', 'svg'],
async load() {
return (await import('@codemirror/lang-xml')).xml();
},
}),
codemirrorLanguage.LanguageDescription.of({
name: 'YAML',
alias: ['yml'],
extensions: ['yaml', 'yml'],
async load() {
return (await import('@codemirror/lang-yaml')).yaml();
},
}),
];
}

View file

@ -0,0 +1,240 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
import type {SearchQuery} from '@codemirror/search';
import type {EditorView, Panel, ViewUpdate} from '@codemirror/view';
import {svg} from '../svg.js';
class SearchPanel implements Panel {
searchField: HTMLInputElement;
replaceField: HTMLInputElement;
caseField: HTMLInputElement;
caseLabel: HTMLLabelElement;
reField: HTMLInputElement;
reLabel: HTMLLabelElement;
wordField: HTMLInputElement;
wordLabel: HTMLLabelElement;
dom: HTMLElement;
query: SearchQuery;
search: CodeMirrorSearch;
constructor(readonly codemirrorSearch: CodeMirrorSearch, readonly view: EditorView) {
this.search = codemirrorSearch;
const container = view.dom.parentElement;
const query = (this.query = this.search.getSearchQuery(view.state));
this.commit = this.commit.bind(this);
this.searchField = document.createElement('input');
this.searchField.value = query.search;
this.searchField.name = 'search';
const searchText = container.getAttribute('data-search-text');
this.searchField.placeholder = searchText;
this.searchField.ariaLabel = searchText;
this.searchField.classList.add('cm-textfield');
this.searchField.setAttribute('main-field', 'true');
this.searchField.addEventListener('keyup', this.commit);
this.searchField.addEventListener('change', this.commit);
this.caseField = document.createElement('input');
this.caseField.checked = query.caseSensitive;
this.caseField.type = 'checkbox';
this.caseField.name = 'case_sensitive';
this.caseField.id = 'search_case_sensitive';
this.caseField.addEventListener('change', this.commit);
this.caseField.addEventListener('focus', () => this.updateLabels());
this.caseField.addEventListener('blur', () => this.updateLabels());
this.caseLabel = document.createElement('label');
this.caseLabel.setAttribute('for', 'search_case_sensitive');
const caseText = container.getAttribute('data-toggle-case-text');
this.caseLabel.ariaLabel = caseText;
this.caseLabel.setAttribute('data-tooltip-content', caseText);
this.caseLabel.textContent = 'aA';
this.reField = document.createElement('input');
this.reField.checked = query.regexp;
this.reField.type = 'checkbox';
this.reField.name = 'regexp';
this.reField.id = 'search_regexp';
this.reField.addEventListener('change', this.commit);
this.reField.addEventListener('focus', () => this.updateLabels());
this.reField.addEventListener('blur', () => this.updateLabels());
this.reLabel = document.createElement('label');
this.reLabel.setAttribute('for', 'search_regexp');
const reText = container.getAttribute('data-toggle-regex-text');
this.reLabel.ariaLabel = reText;
this.reLabel.setAttribute('data-tooltip-content', reText);
this.reLabel.textContent = '[.+]';
this.wordField = document.createElement('input');
this.wordField.checked = query.wholeWord;
this.wordField.type = 'checkbox';
this.wordField.name = 'by_word';
this.wordField.id = 'search_by_word';
this.wordField.addEventListener('change', this.commit);
this.wordField.addEventListener('focus', () => this.updateLabels());
this.wordField.addEventListener('blur', () => this.updateLabels());
this.wordLabel = document.createElement('label');
this.wordLabel.setAttribute('for', 'search_by_word');
const wholeWordText = container.getAttribute('data-toggle-whole-word-text');
this.wordLabel.ariaLabel = wholeWordText;
this.wordLabel.setAttribute('data-tooltip-content', wholeWordText);
this.wordLabel.textContent = 'W';
this.updateLabels();
const searchFieldContainer = document.createElement('span');
searchFieldContainer.classList.add('search-input-group');
searchFieldContainer.replaceChildren(this.searchField, this.caseLabel, this.reLabel, this.wordLabel);
const hiddenInputs = document.createElement('div');
hiddenInputs.classList.add('search-hidden-inputs');
hiddenInputs.replaceChildren(this.caseField, this.reField, this.wordField);
const prevSearch = document.createElement('button');
prevSearch.classList.add('secondary', 'button');
prevSearch.type = 'button';
const findPrevText = container.getAttribute('data-find-prev-text');
prevSearch.ariaLabel = findPrevText;
prevSearch.addEventListener('click', () => {
this.search.findPrevious(view);
});
prevSearch.innerHTML = svg('octicon-arrow-up');
const nextSearch = document.createElement('button');
nextSearch.classList.add('secondary', 'button');
nextSearch.type = 'button';
const findNextText = container.getAttribute('data-find-next-text');
nextSearch.ariaLabel = findNextText;
nextSearch.addEventListener('click', () => {
this.search.findNext(view);
});
nextSearch.innerHTML = svg('octicon-arrow-down');
const searchSection = document.createElement('div');
searchSection.classList.add('search-section');
searchSection.replaceChildren(searchFieldContainer, hiddenInputs, prevSearch, nextSearch);
this.replaceField = document.createElement('input');
this.replaceField.value = query.replace;
this.replaceField.name = 'replace';
const replaceText = container.getAttribute('data-replace-text');
this.replaceField.placeholder = replaceText;
this.replaceField.ariaLabel = replaceText;
this.replaceField.classList.add('cm-textfield');
this.replaceField.addEventListener('keyup', this.commit);
this.replaceField.addEventListener('change', this.commit);
const replaceButton = document.createElement('button');
replaceButton.classList.add('secondary', 'button');
replaceButton.type = 'button';
replaceButton.addEventListener('click', () => {
this.search.replaceNext(view);
});
replaceButton.textContent = replaceText;
const replaceAllButton = document.createElement('button');
replaceAllButton.classList.add('secondary', 'button');
replaceAllButton.type = 'button';
replaceAllButton.addEventListener('click', () => {
this.search.replaceAll(view);
});
const replaceAllText = container.getAttribute('data-replace-all-text');
replaceAllButton.textContent = replaceAllText;
const replaceSection = document.createElement('div');
replaceSection.classList.add('replace-section');
replaceSection.replaceChildren(this.replaceField, replaceButton, replaceAllButton);
this.dom = document.createElement('div');
this.dom.classList.add('fj-search');
this.dom.addEventListener('keydown', (e: KeyboardEvent) => this.keydown(e));
this.dom.replaceChildren(searchSection, replaceSection);
}
commit() {
this.updateLabels();
const query = new this.search.SearchQuery({
search: this.searchField.value,
caseSensitive: this.caseField.checked,
regexp: this.reField.checked,
wholeWord: this.wordField.checked,
replace: this.replaceField.value,
});
if (!query.eq(this.query)) {
this.query = query;
this.view.dispatch({effects: this.search.setSearchQuery.of(query)});
// Set the new search query and reset the selection
const anchor = this.view.state.selection.main.anchor;
this.view.dispatch({
selection: {anchor},
effects: this.search.setSearchQuery.of(query),
});
}
}
keydown(e: KeyboardEvent) {
if (e.key === 'Enter' && e.target === this.searchField) {
e.preventDefault();
if (e.shiftKey) {
this.search.findPrevious(this.view);
} else {
this.search.findNext(this.view);
}
} else if (e.key === 'Enter' && e.target === this.replaceField) {
e.preventDefault();
this.search.replaceNext(this.view);
}
}
update(update: ViewUpdate) {
for (const tr of update.transactions) for (const effect of tr.effects) {
if (effect.is(this.search.setSearchQuery) && !effect.value.eq(this.query)) {
this.setQuery(effect.value);
}
}
}
setQuery(query: SearchQuery) {
this.query = query;
this.searchField.value = query.search;
this.replaceField.value = query.replace;
this.caseField.checked = query.caseSensitive;
this.reField.checked = query.regexp;
this.wordField.checked = query.wholeWord;
this.updateLabels();
}
updateLabels() {
this.caseLabel.classList.toggle('active', this.caseField.checked);
this.caseLabel.classList.toggle('focused', this.caseField === document.activeElement);
this.reLabel.classList.toggle('active', this.reField.checked);
this.reLabel.classList.toggle('focused', this.reField === document.activeElement);
this.wordLabel.classList.toggle('active', this.wordField.checked);
this.wordLabel.classList.toggle('focused', this.wordField === document.activeElement);
}
mount() {
this.searchField.select();
}
get pos() {
return 80;
}
get top() {
return true;
}
}
export function searchPanel(
codemirrorSearch: CodeMirrorSearch,
): (view: EditorView) => Panel {
return (view) => {
return new SearchPanel(codemirrorSearch, view);
};
}

View file

@ -0,0 +1,218 @@
import {isDarkTheme} from '../utils.js';
import {languages} from './codemirror-lang.ts';
import type {LanguageDescription} from '@codemirror/language';
import type {Compartment} from '@codemirror/state';
import type {EditorView, ViewUpdate} from '@codemirror/view';
import {searchPanel} from './codemirror-search.ts';
// Export editor for customization - https://github.com/go-gitea/gitea/issues/10409
function exportEditor(editor: EditorView) {
if (!window.codeEditors) window.codeEditors = new Set<EditorView>();
window.codeEditors.add(editor);
}
export interface EditorOptions {
indentSize?: number;
tabSize?: number;
wordWrap: boolean;
indentStyle: string;
onContentChange?: (update: ViewUpdate) => void;
}
export interface CodemirrorEditor {
codemirrorView: CodeMirrorView;
codemirrorLanguage: CodeMirrorLanguage;
codemirrorState: CodeMirrorState;
codemirrorSearch: CodeMirrorSearch;
view: EditorView;
languages: LanguageDescription[];
compartments: {
wordWrap: Compartment;
language: Compartment;
tabSize: Compartment;
};
}
export async function createCodemirror(
textarea: HTMLTextAreaElement,
filename: string,
editorOpts: EditorOptions,
): Promise<CodemirrorEditor> {
const codemirrorView = await import(/* webpackChunkName: "codemirror" */ '@codemirror/view');
const codemirrorCommands = await import(/* webpackChunkName: "codemirror" */ '@codemirror/commands');
const codemirrorState = await import(/* webpackChunkName: "codemirror" */ '@codemirror/state');
const codemirrorSearch = await import(/* webpackChunkName: "codemirror" */ '@codemirror/search');
const codemirrorLanguage = await import(/* webpackChunkName: "codemirror" */ '@codemirror/language');
const codemirrorAutocomplete = await import(/* webpackChunkName: "codemirror" */ '@codemirror/autocomplete');
const {tags: t} = await import(/* webpackChunkName: "codemirror" */ '@lezer/highlight');
const languageDescriptions = languages(codemirrorLanguage);
const code = codemirrorLanguage.LanguageDescription.matchFilename(
languageDescriptions,
filename,
);
const onContentChange = editorOpts.onContentChange || ((update) => {
if (update.docChanged) {
textarea.value = update.state.doc.toString();
// Make jquery-are-you-sure happy.
textarea.dispatchEvent(new Event('change'));
}
});
const darkTheme = isDarkTheme();
const theme = codemirrorView.EditorView.theme(
{
'&': {
color: 'var(--color-text)',
backgroundColor: 'var(--color-code-bg)',
maxHeight: '90vh',
},
'.cm-content, .cm-gutter': {
minHeight: '200px',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
caretColor: 'var(--color-caret)',
fontFamily: 'var(--fonts-monospace)',
fontSize: '14px',
},
'.cm-cursor, .cm-dropCursor': {
borederLeftCursor: 'var(--color-caret)',
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground':
{
backgroundColor: 'var(--color-primary-light-3)',
},
'.cm-panels': {
backgroundColor: 'var(--color-body)',
borderColor: 'var(--color-secondary)',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: '#6699ff0b',
},
'.cm-gutters': {
backgroundColor: 'var(--color-code-bg)',
color: 'var(--color-secondary-dark-6)',
},
'.cm-line ::selection, .cm-line::selection': {
color: 'inherit !important',
},
'.cm-searchMatch': {
backgroundColor: '#72a1ff59',
outline: `1px solid #ffffff0f`,
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
padding: '0.5em 0.5em',
},
},
{dark: isDarkTheme()},
);
const highlightStyle = codemirrorLanguage.HighlightStyle.define([
{
tag: [t.keyword, t.operatorKeyword, t.modifier, t.color, t.constant(t.name), t.standard(t.name), t.standard(t.tagName), t.special(t.brace), t.atom, t.bool, t.special(t.variableName)],
color: darkTheme ? '#569cd6' : '#0064ff',
},
{tag: [t.controlKeyword, t.moduleKeyword], color: darkTheme ? '#c586c0' : '#af00db'},
{
tag: [t.name, t.deleted, t.character, t.macroName, t.propertyName, t.variableName, t.labelName, t.definition(t.name)],
color: darkTheme ? '#9cdcfe' : '#383a42',
},
{
tag: [t.typeName, t.className, t.tagName, t.number, t.changed, t.annotation, t.self, t.namespace],
color: darkTheme ? '#4ec9b0' : '#267f99',
},
{
tag: [t.function(t.variableName), t.function(t.propertyName)],
color: darkTheme ? '#dcdcaa' : '#795e26',
},
{tag: [t.number], color: darkTheme ? '#b5cea8' : '#098658'},
{
tag: [t.operator, t.punctuation, t.separator, t.url, t.escape, t.regexp],
color: darkTheme ? '#d4d4d4' : '#383a42',
},
{tag: [t.regexp], color: darkTheme ? '#d16969' : '#af00db'},
{
tag: [t.special(t.string), t.processingInstruction, t.string, t.inserted],
color: darkTheme ? '#ce9178' : '#a31515',
},
{tag: [t.meta, t.comment], color: darkTheme ? '#6a9955' : '#6b6b6b'},
{tag: t.invalid, color: darkTheme ? '#ff0000' : '#e51400'},
{tag: t.strong, fontWeight: 'bold'},
{tag: t.emphasis, fontStyle: 'italic'},
{tag: t.strikethrough, textDecoration: 'line-through'},
{tag: t.link, color: darkTheme ? '#6a9955' : '#006ab1', textDecoration: 'underline'},
]);
const container = textarea.parentNode.querySelector('.codemirror-container');
const wordWrap = new codemirrorState.Compartment();
const language = new codemirrorState.Compartment();
const tabSize = new codemirrorState.Compartment();
const view = new codemirrorView.EditorView({
doc: textarea.value,
parent: container,
extensions: [
codemirrorView.lineNumbers(),
codemirrorLanguage.foldGutter(),
codemirrorView.highlightActiveLineGutter(),
codemirrorView.highlightSpecialChars(),
codemirrorView.highlightActiveLine(),
codemirrorView.drawSelection(),
codemirrorView.dropCursor(),
codemirrorSearch.search({createPanel: searchPanel(codemirrorSearch)}),
codemirrorView.keymap.of([
...codemirrorAutocomplete.closeBracketsKeymap,
...codemirrorCommands.defaultKeymap,
...codemirrorCommands.historyKeymap,
// If no search panel, then disable the search keymap
...(document.getElementById('editor-find') ? codemirrorSearch.searchKeymap : []),
...codemirrorLanguage.foldKeymap,
...codemirrorAutocomplete.completionKeymap,
codemirrorCommands.indentWithTab,
]),
codemirrorState.EditorState.allowMultipleSelections.of(true),
codemirrorLanguage.indentOnInput(),
codemirrorLanguage.syntaxHighlighting(highlightStyle),
codemirrorLanguage.bracketMatching(),
codemirrorLanguage.indentUnit.of(
editorOpts.indentStyle === 'tab' ?
'\t' :
' '.repeat(editorOpts.indentSize || 2),
),
codemirrorAutocomplete.closeBrackets(),
codemirrorAutocomplete.autocompletion(),
codemirrorCommands.history(),
tabSize.of(
codemirrorState.EditorState.tabSize.of(editorOpts.tabSize || 4),
),
wordWrap.of(
editorOpts.wordWrap ? codemirrorView.EditorView.lineWrapping : [],
),
language.of(code ? await code.load() : []),
codemirrorView.EditorView.updateListener.of(onContentChange),
theme,
],
});
exportEditor(view);
container.querySelector('.editor-loading')?.remove();
return {
codemirrorView,
codemirrorState,
codemirrorLanguage,
codemirrorSearch,
view,
languages: languageDescriptions,
compartments: {
tabSize,
wordWrap,
language,
},
};
}

View file

@ -1,6 +1,6 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {createCodeEditor} from './codeeditor.js';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, showElem, createElementFromHTML} from '../utils/dom.js';
import {initMarkupContent} from '../markup/content.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
@ -9,7 +9,7 @@ import {initTab} from '../modules/tab.ts';
import {showModal} from '../modules/modal.ts';
function initEditPreviewTab($form) {
const $tabMenu = $form.find('.tabular.menu');
const $tabMenu = $form.find('.switch');
initTab($tabMenu[0]);
const $previewTab = $tabMenu.find(
`.item[data-tab="${$tabMenu.data('preview')}"]`,

View file

@ -2,7 +2,6 @@ import {vi} from 'vitest';
import {issueTitleHTML} from './repo-issue.js';
// monaco-editor does not have any exports fields, which trips up vitest
vi.mock('./comp/ComboMarkdownEditor.js', () => ({}));
// jQuery is missing
vi.mock('./common-global.js', () => ({}));

View file

@ -1,8 +1,8 @@
import $ from 'jquery';
import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.js';
import {onInputDebounce, toggleElem} from '../utils/dom.js';
import {POST} from '../modules/fetch.js';
import {createCodemirror} from './codemirror.ts';
const {appSubUrl} = window.config;
@ -71,7 +71,7 @@ export function initRepoSettingSearchTeamBox() {
export function initRepoSettingGitHook() {
if (!$('.edit.githook').length) return;
const filename = document.querySelector('.hook-filename').textContent;
const _promise = createMonaco($('#content')[0], filename, {language: 'shell'});
const _promise = createCodemirror($('#content')[0], filename, {language: 'shell'});
}
export function initRepoSettingBranches() {

3
web_src/js/globals.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
interface Window {
codeEditors: Set<import('codemirror').EditorView>;
}

View file

@ -5,6 +5,8 @@ import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-ch
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
import giteaExclamation from '../../public/assets/img/svg/gitea-exclamation.svg';
import octiconArchive from '../../public/assets/img/svg/octicon-archive.svg';
import octiconArrowDown from '../../public/assets/img/svg/octicon-arrow-down.svg';
import octiconArrowUp from '../../public/assets/img/svg/octicon-arrow-up.svg';
import octiconArrowSwitch from '../../public/assets/img/svg/octicon-arrow-switch.svg';
import octiconBlocked from '../../public/assets/img/svg/octicon-blocked.svg';
import octiconBold from '../../public/assets/img/svg/octicon-bold.svg';
@ -81,7 +83,9 @@ const svgs = {
'gitea-empty-checkbox': giteaEmptyCheckbox,
'gitea-exclamation': giteaExclamation,
'octicon-archive': octiconArchive,
'octicon-arrow-down': octiconArrowDown,
'octicon-arrow-switch': octiconArrowSwitch,
'octicon-arrow-up': octiconArrowUp,
'octicon-blocked': octiconBlocked,
'octicon-bold': octiconBold,
'octicon-check': octiconCheck,

View file

@ -8,3 +8,8 @@ declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
type CodeMirrorLanguage = typeof import('@codemirror/language');
type CodeMirrorSearch = typeof import('@codemirror/search');
type CodeMirrorState = typeof import('@codemirror/state');
type CodeMirrorView = typeof import('@codemirror/view');

View file

@ -2,7 +2,6 @@ import fastGlob from 'fast-glob';
import wrapAnsi from 'wrap-ansi';
import {init as licenseChecker} from 'license-checker-rseidelsohn';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
import {VueLoaderPlugin} from 'vue-loader';
import EsBuildLoader from 'esbuild-loader';
import {parse} from 'node:path';
@ -144,9 +143,8 @@ export default {
output: {
path: fileURLToPath(new URL('public/assets', import.meta.url)),
filename: () => 'js/[name].js',
chunkFilename: ({chunk}) => {
const language = (/monaco.*languages?_.+?_(.+?)_/.exec(chunk.id) || [])[1];
return `js/${language ? `monaco-language-${language.toLowerCase()}` : `[name]`}.[contenthash:8].js`;
chunkFilename: () => {
return `js/[name].[contenthash:8].js`;
},
},
optimization: {
@ -254,9 +252,6 @@ export default {
filename: '[file].[contenthash:8].map',
...(sourceMaps === 'reduced' && {include: /^js\/index\.js$/}),
}),
new MonacoWebpackPlugin({
filename: 'js/monaco-[name].[contenthash:8].worker.js',
}),
],
performance: {
hints: false,
@ -283,7 +278,6 @@ export default {
colors: true,
entrypoints: false,
excludeAssets: [
/^js\/monaco-language-.+\.js$/,
!isProduction && /^licenses.txt$/,
].filter(Boolean),
groupAssetsByChunk: false,