diff --git a/angular.json b/angular.json index dd5355dd76e0..72d892e03ed3 100644 --- a/angular.json +++ b/angular.json @@ -60,6 +60,9 @@ "base": "build/resources/main/static/", "browser": "" }, + "loader": { + ".ttf": "file" + }, "index": "src/main/webapp/index.html", "browser": "src/main/webapp/app/app.main.ts", "polyfills": [ @@ -108,6 +111,11 @@ "glob": "**/*", "input": "src/main/webapp/swagger-ui/", "output": "swagger-ui" + }, + { + "glob": "**/*", + "input": "./node_modules/monaco-editor/min/vs", + "output": "vs" } ], "styles": [ @@ -116,7 +124,8 @@ "bundleName": "theme-dark", "input": "src/main/webapp/content/scss/themes/theme-dark.scss", "inject": false - } + }, + "node_modules/monaco-editor/min/vs/editor/editor.main.css" ], "scripts": [] }, diff --git a/jest.config.js b/jest.config.js index faea2bf3f4b3..decc585e0ba6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -42,6 +42,7 @@ const esModules = [ 'franc-min', 'internmap', 'lodash-es', + 'monaco-editor', 'n-gram', 'ngx-device-detector', 'ngx-infinite-scroll', @@ -148,5 +149,7 @@ module.exports = { '@state/(.*)': '/src/app/state/$1', '^lodash-es$': 'lodash', '@sentry/angular-ivy': '/node_modules/@sentry/angular-ivy/bundles/sentry-angular-ivy.umd.js', + '\\.css$': '/stub.js', + '^monaco-editor$': '/node_modules/monaco-editor/esm/vs/editor/editor.api.js', }, }; diff --git a/package-lock.json b/package-lock.json index c63d4e5e8572..8af0ef1918e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@sentry/types": "7.109.0", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.3.0", + "@vscode/codicons": "0.0.35", "ace-builds": "1.32.9", "bootstrap": "5.3.3", "brace": "0.11.1", @@ -57,6 +58,7 @@ "jszip": "3.10.1", "lodash-es": "4.17.21", "mobile-drag-drop": "3.0.0-rc.0", + "monaco-editor": "0.46.0", "ngx-infinite-scroll": "17.0.0", "ngx-webstorage": "13.0.1", "papaparse": "5.4.1", @@ -7040,6 +7042,11 @@ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, + "node_modules/@vscode/codicons": { + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.35.tgz", + "integrity": "sha512-7iiKdA5wHVYSbO7/Mm0hiHD3i4h+9hKUe1O4hISAe/nHhagMwb2ZbFC8jU6d7Cw+JNT2dWXN2j+WHbkhT5/l2w==" + }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", @@ -15920,6 +15927,11 @@ "resolved": "https://registry.npmjs.org/mobile-drag-drop/-/mobile-drag-drop-3.0.0-rc.0.tgz", "integrity": "sha512-f8wIDTbBYLBW/+5sei1cqUE+StyDpf/LP+FRZELlVX6tmOOmELk84r3wh1z3woxCB9G5octhF06K5COvFjGgqg==" }, + "node_modules/monaco-editor": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.46.0.tgz", + "integrity": "sha512-ADwtLIIww+9FKybWscd7OCfm9odsFYHImBRI1v9AviGce55QY8raT+9ihH8jX/E/e6QVSGM+pKj4jSUSRmALNQ==" + }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", diff --git a/package.json b/package.json index 259c66767f4a..0d755b1a0f5d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@sentry/types": "7.109.0", "@swimlane/ngx-charts": "20.5.0", "@swimlane/ngx-graph": "8.3.0", + "@vscode/codicons": "0.0.35", "ace-builds": "1.32.9", "bootstrap": "5.3.3", "brace": "0.11.1", @@ -60,6 +61,7 @@ "jszip": "3.10.1", "lodash-es": "4.17.21", "mobile-drag-drop": "3.0.0-rc.0", + "monaco-editor": "0.46.0", "ngx-infinite-scroll": "17.0.0", "ngx-webstorage": "13.0.1", "papaparse": "5.4.1", diff --git a/src/main/webapp/app/app.main.ts b/src/main/webapp/app/app.main.ts index 877b8048c1a4..045ac365023c 100644 --- a/src/main/webapp/app/app.main.ts +++ b/src/main/webapp/app/app.main.ts @@ -5,6 +5,7 @@ import 'app/shared/util/string.extension'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { ProdConfig } from './core/config/prod.config'; import { ArtemisAppModule } from './app.module'; +import { MonacoConfig } from 'app/core/config/monaco.config'; ProdConfig(); @@ -15,6 +16,8 @@ if (module['hot']) { } } +MonacoConfig(); + platformBrowserDynamic() .bootstrapModule(ArtemisAppModule, { preserveWhitespaces: true }) .then(() => {}) diff --git a/src/main/webapp/app/core/config/monaco.config.ts b/src/main/webapp/app/core/config/monaco.config.ts new file mode 100644 index 000000000000..aa40e47c177c --- /dev/null +++ b/src/main/webapp/app/core/config/monaco.config.ts @@ -0,0 +1,19 @@ +/** + * Sets up the MonacoEnvironment for the monaco editor's service worker. + */ +export function MonacoConfig() { + self.MonacoEnvironment = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getWorkerUrl: function (workerId: string, label: string) { + /* + * This is the AMD-based service worker, which comes bundled with a few special workers for selected languages. + * (e.g.: javascript, typescript, html, css) + * + * It is also possible to use an ESM-based approach, which requires a little more setup and case distinctions in this method. + * At the moment, it seems that the ESM-based approaches are incompatible with the Artemis client, as they would require custom builders. + * Support for custom builders was removed in #6546. + */ + return 'vs/base/worker/workerMain.js'; + }, + }; +} diff --git a/src/main/webapp/app/entities/build-log.model.ts b/src/main/webapp/app/entities/build-log.model.ts index f9f0b6d858e5..014fedf1e664 100644 --- a/src/main/webapp/app/entities/build-log.model.ts +++ b/src/main/webapp/app/entities/build-log.model.ts @@ -67,7 +67,7 @@ export class BuildLogEntryArray extends Array { return Array.from( this // Parse build logs - .map(({ log, time }) => ({ log: log.match(errorLogRegex), time })) + .map(({ log, time }) => ({ log: log.split('\n', 1)[0].trim().match(errorLogRegex), time })) // Remove entries that could not be parsed, are too short or not errors .filter(({ log }: { log: ParsedLogEntry | null; time: string }) => { // Java logs do not always contain "ERROR" diff --git a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html index 6098598ca71a..fd252c4bf08c 100644 --- a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html @@ -11,6 +11,7 @@

Online Code Editor

} (onError)="onError($event)" (onToggleCollapse)="onToggleCollapse($event, CollapsableCodeEditorElement.FileBrowser)" /> - + + @if (!useMonacoEditor) { + + } @else { + + } + @if (showEditorInstructions) { (); @@ -196,7 +201,9 @@ export class CodeEditorContainerComponent implements OnChanges, ComponentCanDeac if (_isEmpty(this.unsavedFiles) && this.editorState === EditorState.UNSAVED_CHANGES) { this.editorState = EditorState.CLEAN; } - this.aceEditor.onFileChange(fileChange); + this.monacoEditor?.onFileChange(fileChange); + this.aceEditor?.onFileChange(fileChange); + this.onFileChanged.emit(); } @@ -218,7 +225,8 @@ export class CodeEditorContainerComponent implements OnChanges, ComponentCanDeac if (errorFiles.length) { this.onError('saveFailed'); } - this.aceEditor.storeAnnotations(savedFiles); + this.aceEditor?.storeAnnotations(savedFiles); + this.monacoEditor?.storeAnnotations(savedFiles); } /** @@ -280,7 +288,7 @@ export class CodeEditorContainerComponent implements OnChanges, ComponentCanDeac if (type === ResizeType.SIDEBAR_RIGHT || type === ResizeType.MAIN_BOTTOM) { this.onResizeEditorInstructions.emit(); } - if (type === ResizeType.SIDEBAR_LEFT || type === ResizeType.SIDEBAR_RIGHT || type === ResizeType.MAIN_BOTTOM) { + if (this.aceEditor && (type === ResizeType.SIDEBAR_LEFT || type === ResizeType.SIDEBAR_RIGHT || type === ResizeType.MAIN_BOTTOM)) { this.aceEditor.editor.getEditor().resize(); } } diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.html b/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.html index 1f47d0a00546..ad41651ce37e 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.html +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.html @@ -6,15 +6,17 @@

}

-
- -
-
- - + @if (showTabSizeSelector) { +
+ +
+
+ + +
-
+ }
diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts index 88d15ec51bdc..2a509207248b 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/header/code-editor-header.component.ts @@ -14,6 +14,9 @@ export class CodeEditorHeaderComponent { @Input() isLoading: boolean; + @Input() + showTabSizeSelector = true; + @Output() tabSizeChanged = new EventEmitter(); diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.html b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.html new file mode 100644 index 000000000000..b49c8d601028 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.html @@ -0,0 +1,33 @@ +
+ +
+ @if (selectedFile) { + @for (feedback of filterFeedbackForSelectedFile(feedbacks); track feedback) { + + } + } + + + @if (!selectedFile && !isLoading) { +

+ Select a file to get started!. +

+ } +
+
diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.scss b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.scss new file mode 100644 index 000000000000..c401fefbadf3 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.scss @@ -0,0 +1,4 @@ +/* Monaco editor styling (code editor) */ +.card-body-monaco { + padding: unset; +} diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts new file mode 100644 index 000000000000..785bcc170704 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component.ts @@ -0,0 +1,217 @@ +import { Component, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChild, ViewChildren, ViewEncapsulation } from '@angular/core'; +import { RepositoryFileService } from 'app/exercises/shared/result/repository.service'; +import { CodeEditorRepositoryFileService } from 'app/exercises/programming/shared/code-editor/service/code-editor-repository.service'; +import { CodeEditorFileService } from 'app/exercises/programming/shared/code-editor/service/code-editor-file.service'; +import { LocalStorageService } from 'ngx-webstorage'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; +import { firstValueFrom } from 'rxjs'; +import { Annotation, FileSession } from 'app/exercises/programming/shared/code-editor/ace/code-editor-ace.component'; +import { Feedback } from 'app/entities/feedback.model'; +import { Course } from 'app/entities/course.model'; +import { CodeEditorTutorAssessmentInlineFeedbackComponent } from 'app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component'; +import { + CommitState, + CreateFileChange, + DeleteFileChange, + EditorState, + FileChange, + FileType, + RenameFileChange, +} from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; +import { fromPairs, pickBy } from 'lodash-es'; + +@Component({ + selector: 'jhi-code-editor-monaco', + templateUrl: './code-editor-monaco.component.html', + styleUrls: ['./code-editor-monaco.component.scss'], + encapsulation: ViewEncapsulation.None, + providers: [RepositoryFileService], +}) +export class CodeEditorMonacoComponent implements OnChanges { + @ViewChild('editor', { static: true }) + editor: MonacoEditorComponent; + @ViewChildren(CodeEditorTutorAssessmentInlineFeedbackComponent) + inlineFeedbackComponents: QueryList; + @Input() + commitState: CommitState; + @Input() + editorState: EditorState; + @Input() + course?: Course; + @Input() + feedbacks: Feedback[] = []; + @Input() + isTutorAssessment: boolean = false; + @Input() + selectedFile?: string; + @Input() + sessionId: number | string; + @Input() + set buildAnnotations(buildAnnotations: Array) { + this.setBuildAnnotations(buildAnnotations); + } + + annotationsArray: Array = []; + + @Output() + onFileContentChange: EventEmitter<{ file: string; fileContent: string }> = new EventEmitter<{ file: string; fileContent: string }>(); + + isLoading = false; + + fileSession: FileSession = {}; + + // Expose to template + protected readonly Feedback = Feedback; + protected readonly CommitState = CommitState; + + constructor( + private repositoryFileService: CodeEditorRepositoryFileService, + private fileService: CodeEditorFileService, + protected localStorageService: LocalStorageService, + ) {} + + async ngOnChanges(changes: SimpleChanges): Promise { + const editorWasRefreshed = changes.editorState && changes.editorState.previousValue === EditorState.REFRESHING && this.editorState === EditorState.CLEAN; + // Refreshing the editor resets any local files. + if (editorWasRefreshed) { + this.fileSession = {}; + this.editor.reset(); + } + if ((changes.selectedFile && this.selectedFile) || editorWasRefreshed) { + await this.selectFileInEditor(this.selectedFile); + this.setBuildAnnotations(this.annotationsArray); + this.renderFeedbackWidgets(); + } + } + + async selectFileInEditor(fileName: string | undefined): Promise { + if (!fileName) { + // There is nothing to be done, as the editor will be hidden when there is no file. + return; + } + if (!this.fileSession[fileName]) { + this.isLoading = true; + const fileContent = await firstValueFrom(this.repositoryFileService.getFile(fileName)).then((fileObj) => fileObj.fileContent); + this.fileSession[fileName] = { code: fileContent, loadingError: false, cursor: { column: 0, row: 0 } }; + this.isLoading = false; + } + + this.editor.changeModel(fileName, this.fileSession[fileName].code); + this.editor.setPosition(this.fileSession[fileName].cursor); + } + + onFileTextChanged(text: string): void { + if (this.selectedFile && this.fileSession[this.selectedFile]) { + const previousText = this.fileSession[this.selectedFile].code; + if (previousText !== text) { + this.fileSession[this.selectedFile] = { code: text, loadingError: false, cursor: this.editor.getPosition() }; + this.onFileContentChange.emit({ file: this.selectedFile, fileContent: text }); + } + } + } + + protected renderFeedbackWidgets() { + for (const feedback of this.filterFeedbackForSelectedFile([...this.feedbacks])) { + this.addLineWidgetWithFeedback(feedback); + } + } + + getInlineFeedbackNode(line: number) { + return [...this.inlineFeedbackComponents].find((c) => c.codeLine === line)?.elementRef?.nativeElement; + } + + private addLineWidgetWithFeedback(feedback: Feedback): void { + const line = Feedback.getReferenceLine(feedback); + if (!line) { + throw new Error('No line found for feedback ' + feedback.id); + } + // In the future, there may be more than one feedback node per line. + const feedbackNode = this.getInlineFeedbackNode(line); + // The lines are 0-based for Ace, but 1-based for Monaco -> increase by 1 to ensure it works in both editors. + this.editor.addLineWidget(line + 1, 'feedback-' + feedback.id, feedbackNode); + } + + /** + * Returns the feedbacks that refer to the currently selected file, or an empty array if no file is selected. + * @param feedbacks The feedbacks to filter. + */ + filterFeedbackForSelectedFile(feedbacks: Feedback[]): Feedback[] { + if (!this.selectedFile) { + return []; + } + return feedbacks.filter((feedback) => feedback.reference && Feedback.getReferenceFilePath(feedback) === this.selectedFile); + } + + /** + * Updates the state of the fileSession based on a change made to the files themselves (not the content). + * - If a file was renamed, references to it are updated to use its new name. + * - If a file was deleted, references to it are removed. + * - If a file was created, a new reference is created. + * Afterwards, the build annotations are updated to reflect this new change. + * @param fileChange The change made: renaming, deleting, or creating a file. + */ + async onFileChange(fileChange: FileChange) { + if (fileChange instanceof RenameFileChange) { + this.fileSession = this.fileService.updateFileReferences(this.fileSession, fileChange); + for (const annotation of this.annotationsArray) { + if (annotation.fileName === fileChange.oldFileName) { + annotation.fileName = fileChange.newFileName; + } + } + this.storeAnnotations([fileChange.newFileName]); + } else if (fileChange instanceof DeleteFileChange) { + this.fileSession = this.fileService.updateFileReferences(this.fileSession, fileChange); + this.storeAnnotations([fileChange.fileName]); + } else if (fileChange instanceof CreateFileChange && fileChange.fileType === FileType.FILE) { + this.fileSession = { ...this.fileSession, [fileChange.fileName]: { code: '', cursor: { row: 0, column: 0 }, loadingError: false } }; + } + this.setBuildAnnotations(this.annotationsArray); + } + + /* + * Taken from code-editor-ace.component.ts + */ + /** + * Saves the updated annotations to local storage + * @param savedFiles + */ + storeAnnotations(savedFiles: string[]) { + const toUpdate = fromPairs(this.annotationsArray.filter((a) => savedFiles.includes(a.fileName)).map((a) => [a.hash, a])); + const toKeep = pickBy(this.loadAnnotations(), (a) => !savedFiles.includes(a.fileName)); + + this.localStorageService.store( + 'annotations-' + this.sessionId, + JSON.stringify({ + ...toKeep, + ...toUpdate, + }), + ); + } + + /** + * Loads annotations from local storage + */ + loadAnnotations() { + return JSON.parse(this.localStorageService.retrieve('annotations-' + this.sessionId) || '{}'); + } + + setBuildAnnotations(buildAnnotations: Annotation[]): void { + if (buildAnnotations.length > 0 && this.selectedFile) { + const sessionAnnotations = this.loadAnnotations(); + this.annotationsArray = buildAnnotations.map((a) => { + const hash = a.fileName + a.row + a.column + a.text; + if (sessionAnnotations[hash] == undefined || sessionAnnotations[hash].timestamp < a.timestamp) { + return { ...a, hash }; + } else { + return sessionAnnotations[hash]; + } + }); + } else { + this.annotationsArray = buildAnnotations; + } + this.editor.setAnnotations( + buildAnnotations.filter((buildAnnotation) => buildAnnotation.fileName === this.selectedFile), + this.commitState === CommitState.UNCOMMITTED_CHANGES, + ); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-code-editor-element.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-code-editor-element.model.ts new file mode 100644 index 000000000000..f066c1c8cd68 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-code-editor-element.model.ts @@ -0,0 +1,88 @@ +import * as monaco from 'monaco-editor'; + +/** + * Abstract class representing an element in the Monaco editor, e.g. a line widget. + */ +export abstract class MonacoCodeEditorElement implements monaco.IDisposable { + static readonly CSS_HIDDEN_CLASS = 'monaco-hidden-element'; + + private id: string | undefined; + private visible = true; + protected readonly editor: monaco.editor.ICodeEditor; + + /** + * @param editor The editor to render this element in. + * @param id The id of this element if available, or undefined if not. If the ID is not available at construction time, it must be set using {@link setId}. + */ + protected constructor(editor: monaco.editor.ICodeEditor, id: string | undefined) { + this.editor = editor; + this.id = id; + } + + /** + * Returns the id of this element. Since Monaco expects the element to always be available, this method throws an error if it has not been set yet. + */ + getId(): string { + if (this.id === undefined) { + throw new Error('This editor element has no ID.'); + } + return this.id; + } + + setId(id: string): void { + this.id = id; + } + + setVisible(visible: boolean): void { + this.visible = visible; + } + + isVisible(): boolean { + return this.visible; + } + + /** + * Override this method to set up listeners that react to changes in the editor. + */ + protected setupListeners(): void {} + + /** + * Adds this element to the editor. + */ + abstract addToEditor(): void; + + /** + * Updates this element in the editor, e.g. by removing and adding it again. + * Override this method to specify custom behavior for updating elements. + */ + updateInEditor(): void { + this.removeFromEditor(); + this.addToEditor(); + } + + /** + * Removes this element from the editor, but does not destroy it. + * The element can be added to the editor again using {@link addToEditor}. + */ + abstract removeFromEditor(): void; + + /** + * Removes this element from the editor. Override this to dispose of any additional listeners, subscribers etc. + */ + dispose(): void { + this.removeFromEditor(); + } + + /** + * Updates html elements to be visible using a CSS class. This assumes that the visibility is controlled only by the CSS class of the provided elements. + * @param visible Whether the elements should be made visible. + * @param htmlElements The elements whose visibility should be updated using the {@link MonacoCodeEditorElement.CSS_HIDDEN_CLASS} CSS class. + */ + setHtmlElementsVisible(visible: boolean, ...htmlElements: HTMLElement[]): void { + if (visible) { + htmlElements.forEach((element) => element.classList.remove(MonacoCodeEditorElement.CSS_HIDDEN_CLASS)); + } else { + htmlElements.forEach((element) => element.classList.add(MonacoCodeEditorElement.CSS_HIDDEN_CLASS)); + } + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-build-annotation.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-build-annotation.model.ts new file mode 100644 index 000000000000..2e684a6e7dc2 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-build-annotation.model.ts @@ -0,0 +1,121 @@ +import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; +import { MonacoEditorGlyphMarginWidget } from 'app/shared/monaco-editor/model/monaco-editor-glyph-margin-widget.model'; + +import * as monaco from 'monaco-editor'; + +export enum MonacoEditorBuildAnnotationType { + WARNING = 'warning', + ERROR = 'error', +} + +/** + * Class representing a build annotation (error / warning with description) rendered on the margin of the Monaco editor. + * They remain fixed to their line even when the user makes edits. + * Annotations consist of a {@link MonacoEditorGlyphMarginWidget} to render an icon in the glyph margin and a separate + * decoration (managed by the {@link decorationsCollection}) to handle highlighting and the hover message. + */ +export class MonacoEditorBuildAnnotation extends MonacoCodeEditorElement { + private glyphMarginWidget: MonacoEditorGlyphMarginWidget; + private decorationsCollection: monaco.editor.IEditorDecorationsCollection; + private outdated: boolean; + private hoverMessage: string; + private type: MonacoEditorBuildAnnotationType; + private updateListener: monaco.IDisposable; + + /** + * @param editor The editor to render this annotation in. + * @param id The id of this annotation. + * @param lineNumber The line this annotation refers to. + * @param hoverMessage The message to display when the user hovers over this annotation. Can have markdown elements, e.g. `**bold**`. + * @param type The type of this annotation: error or warning. + * @param outdated Whether this annotation is outdated and should be grayed out. Defaults to false. + */ + constructor(editor: monaco.editor.ICodeEditor, id: string, lineNumber: number, hoverMessage: string, type: MonacoEditorBuildAnnotationType, outdated = false) { + super(editor, id); + this.decorationsCollection = this.editor.createDecorationsCollection([]); + this.hoverMessage = hoverMessage; + this.type = type; + this.outdated = outdated; + const glyphMarginDomNode = document.createElement('div'); + glyphMarginDomNode.id = `monaco-editor-glyph-margin-widget-${id}`; + glyphMarginDomNode.className = `codicon codicon-${this.type}`; + this.glyphMarginWidget = new MonacoEditorGlyphMarginWidget(editor, id, glyphMarginDomNode, lineNumber); + this.setupListeners(); + } + + /** + * Returns an object (a delta decoration) detailing the position and styling of the annotation. + * @private + */ + private getAssociatedDeltaDecoration(): monaco.editor.IModelDeltaDecoration { + const marginClassName = this.outdated ? 'monaco-annotation-outdated' : `monaco-annotation-${this.type}`; + const lineNumber = this.getLineNumber(); + return { + range: new monaco.Range(lineNumber, 0, lineNumber, 0), + options: { + marginClassName, + isWholeLine: true, + stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + lineNumberHoverMessage: { value: this.hoverMessage }, + glyphMarginHoverMessage: { value: this.hoverMessage }, + }, + }; + } + + /** + * Updates the style of this annotation and its linked glyph margin widget according to whether the annotation is outdated. + * @param outdated Whether this annotation is outdated and should be grayed out. + */ + setOutdatedAndUpdate(outdated: boolean) { + this.outdated = outdated; + const classList = this.getGlyphMarginDomNode().classList; + if (outdated) { + classList.remove(`monaco-glyph-${this.type}`); + classList.add(`monaco-glyph-outdated`); + } else { + classList.remove(`monaco-glyph-outdated`); + classList.add(`monaco-glyph-${this.type}`); + } + this.updateInEditor(); + } + + isOutdated(): boolean { + return this.outdated; + } + + getLineNumber(): number { + return this.glyphMarginWidget.getLineNumber(); + } + + getGlyphMarginDomNode(): HTMLElement { + return this.glyphMarginWidget.getDomNode(); + } + + protected setupListeners() { + this.updateListener = this.editor.onDidChangeModelContent(() => { + // The displayed annotations may not apply anymore if the files have changed. For convenience, we still display them for the user's reference. + this.setOutdatedAndUpdate(true); + }); + } + + addToEditor(): void { + const inRange = this.getLineNumber() <= (this.editor.getModel()?.getLineCount() ?? 0); + this.setVisible(inRange); + if (inRange) { + this.glyphMarginWidget.addToEditor(); + // Appending to this collection immediately renders the associated decoration. + this.decorationsCollection.append([this.getAssociatedDeltaDecoration()]); + } + } + removeFromEditor(): void { + this.glyphMarginWidget.removeFromEditor(); + // Clearing the collection immediately removes all decorations from the editor. + this.decorationsCollection.clear(); + } + + dispose() { + super.dispose(); + this.glyphMarginWidget.dispose(); + this.updateListener.dispose(); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-glyph-margin-widget.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-glyph-margin-widget.model.ts new file mode 100644 index 000000000000..f20993196317 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-glyph-margin-widget.model.ts @@ -0,0 +1,40 @@ +import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; +import * as monaco from 'monaco-editor'; + +/** + * Class representing a glyph margin widget in the Monaco editor. + * Glyph margin widgets refer to one line and can contain arbitrary DOM nodes. + */ +export class MonacoEditorGlyphMarginWidget extends MonacoCodeEditorElement implements monaco.editor.IGlyphMarginWidget { + private readonly domNode: HTMLElement; + private readonly lineNumber: number; + + constructor(editor: monaco.editor.ICodeEditor, id: string, domNode: HTMLElement, lineNumber: number) { + super(editor, id); + this.domNode = domNode; + this.lineNumber = lineNumber; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + getPosition(): monaco.editor.IGlyphMarginWidgetPosition { + return { + // The Center lane allows for rendering of hover messages above this widget. + lane: monaco.editor.GlyphMarginLane.Center, + zIndex: 10, + range: new monaco.Range(this.lineNumber, 0, this.lineNumber, 0), + }; + } + + getLineNumber(): number { + return this.lineNumber; + } + + addToEditor(): void { + this.editor.addGlyphMarginWidget(this); + } + removeFromEditor(): void { + this.editor.removeGlyphMarginWidget(this); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-inline-widget.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-inline-widget.model.ts new file mode 100644 index 000000000000..5449eef751b3 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-inline-widget.model.ts @@ -0,0 +1,50 @@ +import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; +import { MonacoEditorViewZone } from 'app/shared/monaco-editor/model/monaco-editor-view-zone.model'; +import { MonacoEditorOverlayWidget } from 'app/shared/monaco-editor/model/monaco-editor-overlay-widget.model'; + +import * as monaco from 'monaco-editor'; + +/** + * Class representing a line widget. + * These widgets consist of two elements: + * - a {@link MonacoEditorViewZone} that creates vertical space after the line in question. + * - a {@link MonacoEditorOverlayWidget} that contains the actual content of the widget. + * The size of these two components is linked together. + */ +export class MonacoEditorLineWidget extends MonacoCodeEditorElement { + private viewZone: MonacoEditorViewZone; + private overlayWidget: MonacoEditorOverlayWidget; + + /** + * @param editor The editor to render this widget in. + * @param overlayWidgetId The ID to use for the overlay widget. + * @param contentDomNode The content to render. + * @param afterLineNumber The line after which this line widget should be rendered. + */ + constructor(editor: monaco.editor.ICodeEditor, overlayWidgetId: string, contentDomNode: HTMLElement, afterLineNumber: number) { + super(editor, overlayWidgetId); + this.overlayWidget = new MonacoEditorOverlayWidget( + editor, + overlayWidgetId, + contentDomNode, + null, // Position is managed by viewZone. + ); + this.viewZone = new MonacoEditorViewZone(editor, afterLineNumber, contentDomNode); + } + + addToEditor() { + this.overlayWidget.addToEditor(); + this.viewZone.addToEditor(); + } + + removeFromEditor() { + this.overlayWidget.removeFromEditor(); + this.viewZone.removeFromEditor(); + } + + dispose() { + super.dispose(); + this.overlayWidget.dispose(); + this.viewZone.dispose(); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts new file mode 100644 index 000000000000..d138ae87df1c --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-overlay-widget.model.ts @@ -0,0 +1,48 @@ +import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; +import * as monaco from 'monaco-editor'; + +// null is used by the monaco editor API +type OverlayWidgetPosition = monaco.editor.IOverlayWidgetPosition | null; + +/** + * Class representing an overlay widget floating above the editor content. + */ +export class MonacoEditorOverlayWidget extends MonacoCodeEditorElement implements monaco.editor.IOverlayWidget { + private readonly domNode: HTMLElement; + private readonly position: OverlayWidgetPosition; + + /** + * @param editor The editor to render the widget in. + * @param id A unique identifier for the widget. + * @param domNode The content to render. The user will be able to interact with the widget. + * @param position The position of the widget or null if the element is positioned by another element (e.g. a view zone). + */ + constructor(editor: monaco.editor.ICodeEditor, id: string, domNode: HTMLElement, position: OverlayWidgetPosition) { + super(editor, id); + this.domNode = domNode; + // At the moment, the inline feedback nodes will only reach their maximum width with the following line. This workaround can be removed as soon as the Ace editor has been replaced. + this.domNode.style.width = '100%'; + this.position = position; + } + + setVisible(visible: boolean) { + super.setVisible(visible); + // Ensure that the displayed content is visible if and only if this element is visble. + this.setHtmlElementsVisible(visible, this.domNode); + } + + addToEditor(): void { + this.editor.addOverlayWidget(this); + this.setVisible(true); + } + removeFromEditor(): void { + this.editor.removeOverlayWidget(this); + } + + getDomNode(): HTMLElement { + return this.domNode; + } + getPosition(): OverlayWidgetPosition { + return this.position; + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-view-zone.model.ts b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-view-zone.model.ts new file mode 100644 index 000000000000..1eff13d83f8a --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/monaco-editor-view-zone.model.ts @@ -0,0 +1,66 @@ +import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; +import * as monaco from 'monaco-editor'; + +/** + * Class representing a view zone (i.e., vertical space after a certain line) in the Monaco editor. + */ +export class MonacoEditorViewZone extends MonacoCodeEditorElement implements monaco.editor.IViewZone { + private linkedContentDomNode: HTMLElement; + private resizeObserver: ResizeObserver; + + afterLineNumber: number; + heightInPx: number | undefined; + + /** + * From IViewZone. The domNode of a view zone cannot be interacted with. Therefore, we instantiate it as an empty div. + */ + domNode: HTMLElement = document.createElement('div'); + + /** + * @param editor The editor to render this view zone in. + * @param afterLineNumber The line after which to insert the view zone. + * @param linkedContentDomNode The content to which this view zone should be linked. When the linked content + * resizes, so does this view zone. Note that the content must be rendered elsewhere, e.g. in an {@link MonacoEditorOverlayWidget}. + */ + constructor(editor: monaco.editor.ICodeEditor, afterLineNumber: number, linkedContentDomNode: HTMLElement) { + // id is unavailable until the view zone is added to the editor. + super(editor, undefined); + this.afterLineNumber = afterLineNumber; + this.linkedContentDomNode = linkedContentDomNode; + this.heightInPx = this.linkedContentDomNode.offsetHeight; + } + + addToEditor(): void { + this.editor.changeViewZones((accessor) => { + this.setId(accessor.addZone(this)); + this.adjustHeightAndLayout(); + }); + this.resizeObserver = new ResizeObserver(() => { + this.adjustHeightAndLayout(); + }); + this.resizeObserver.observe(this.linkedContentDomNode); + } + + private adjustHeightAndLayout(): void { + this.editor.changeViewZones((accessor) => { + this.heightInPx = this.linkedContentDomNode.offsetHeight; + accessor.layoutZone(this.getId()); + }); + } + + removeFromEditor(): void { + this.editor.changeViewZones((accessor) => { + accessor.removeZone(this.getId()); + }); + this.resizeObserver.disconnect(); + } + + /** + * From IViewZone. Called automatically when the position of this view zone in the editor is computed. + * + * @param top The vertical offset (in pixels) of this view zone within the editor. + */ + onDomNodeTop(top: number): void { + this.linkedContentDomNode.style.top = top + 'px'; + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.html b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.html new file mode 100644 index 000000000000..48d2a8fcb4be --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.html @@ -0,0 +1 @@ +
diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss new file mode 100644 index 000000000000..27e0df5009bc --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss @@ -0,0 +1,51 @@ +@import 'node_modules/@vscode/codicons/dist/codicon.css'; + +.monaco-editor-container { + width: 100%; + height: 100%; +} + +/* + * The monaco editor can only grow (but not shrink) when its container is resized. + * Along with a corresponding ResizeObserver that calls layout(), this style forces the editor to shrink + * when its container's size is reduced. + */ +.monaco-shrink-to-fit { + max-width: 99%; + max-height: 99%; +} + +/* + * Adjusts the font size to better match the glyph margin + */ +[class*='codicon-']::before { + font-size: 19px; +} + +.monaco-glyph-error { + color: var(--monaco-editor-build-annotation-error-glyph); +} + +.monaco-glyph-warning { + color: var(--monaco-editor-build-annotation-warning-glyph); +} + +.monaco-glyph-outdated { + color: var(--monaco-editor-build-annotation-outdated-glyph); +} + +.monaco-annotation-error { + background: var(--monaco-editor-build-annotation-error-background); +} + +.monaco-annotation-warning { + background: var(--monaco-editor-build-annotation-warning-background); +} + +.monaco-annotation-outdated { + background: var(--monaco-editor-build-annotation-outdated-background); +} + +.monaco-hidden-element { + display: none; +} diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts new file mode 100644 index 000000000000..eeaaa2d5364d --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts @@ -0,0 +1,218 @@ +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, ViewEncapsulation } from '@angular/core'; +import * as monaco from 'monaco-editor'; +import { Subscription } from 'rxjs'; +import { Theme, ThemeService } from 'app/core/theme/theme.service'; +import { Annotation } from 'app/exercises/programming/shared/code-editor/ace/code-editor-ace.component'; +import { MonacoEditorLineWidget } from 'app/shared/monaco-editor/model/monaco-editor-inline-widget.model'; +import { MonacoEditorBuildAnnotation, MonacoEditorBuildAnnotationType } from 'app/shared/monaco-editor/model/monaco-editor-build-annotation.model'; + +type EditorPosition = { row: number; column: number }; +@Component({ + selector: 'jhi-monaco-editor', + templateUrl: 'monaco-editor.component.html', + styleUrls: ['monaco-editor.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class MonacoEditorComponent implements OnInit, OnDestroy { + @ViewChild('monacoEditorContainer', { static: true }) private monacoEditorContainer: ElementRef; + private _editor: monaco.editor.IStandaloneCodeEditor; + themeSubscription?: Subscription; + models: monaco.editor.IModel[] = []; + lineWidgets: MonacoEditorLineWidget[] = []; + editorBuildAnnotations: MonacoEditorBuildAnnotation[] = []; + + constructor(private themeService: ThemeService) {} + + @Input() + textChangedEmitDelay?: number; + + @Input() + set readOnly(value: boolean) { + this._readOnly = value; + if (this._editor) { + this._editor.updateOptions({ + readOnly: value, + }); + } + } + + private _readOnly: boolean = false; + + @Output() + textChanged = new EventEmitter(); + + private textChangedEmitTimeout?: NodeJS.Timeout; + + ngOnInit(): void { + this._editor = monaco.editor.create(this.monacoEditorContainer.nativeElement, { + value: '', + glyphMargin: true, + minimap: { enabled: false }, + readOnly: this._readOnly, + }); + + const resizeObserver = new ResizeObserver(() => { + this._editor.layout(); + }); + resizeObserver.observe(this.monacoEditorContainer.nativeElement); + + this._editor.onDidChangeModelContent(() => { + this.emitTextChangeEvent(); + }, this); + + this.themeSubscription = this.themeService.getCurrentThemeObservable().subscribe((theme) => this.changeTheme(theme)); + } + + ngOnDestroy() { + this.reset(); + this._editor.dispose(); + this.themeSubscription?.unsubscribe(); + } + + private emitTextChangeEvent() { + const newValue = this.getText(); + if (!this.textChangedEmitDelay) { + this.textChanged.emit(newValue); + } else { + if (this.textChangedEmitTimeout) { + clearTimeout(this.textChangedEmitTimeout); + this.textChangedEmitTimeout = undefined; + } + this.textChangedEmitTimeout = setTimeout(() => { + this.textChanged.emit(newValue); + }, this.textChangedEmitDelay); + } + } + + // Workaround: The rest of the code expects { row, column } - we have { lineNumber, column }. Can be removed when Ace is removed. + getPosition(): EditorPosition { + const position = this._editor.getPosition() ?? new monaco.Position(0, 0); + return { row: position.lineNumber, column: position.column }; + } + + setPosition(position: EditorPosition) { + this._editor.setPosition({ lineNumber: position.row, column: position.column }); + } + + getText(): string { + return this._editor.getValue(); + } + + setText(text: string): void { + this._editor.setValue(text); + } + + /** + * Signals to the editor that the specified text was typed. + * @param text The text to type. + */ + triggerKeySequence(text: string): void { + this._editor.trigger('MonacoEditorComponent::triggerKeySequence', 'type', { text }); + } + + getNumberOfLines(): number { + return this._editor.getModel()?.getLineCount() ?? 0; + } + + isReadOnly(): boolean { + return this._editor.getOption(monaco.editor.EditorOption.readOnly); + } + + /** + * Switches to another model (representing files) in the editor and optionally sets its content. + * The editor's syntax highlighting will be set depending on the file extension. + * All elements currently rendered in the editor will be disposed. + * @param fileName The name of the file to switch to. + * @param newFileContent The content of the file (will be retrieved from the model if left out). + */ + changeModel(fileName: string, newFileContent?: string) { + const uri = monaco.Uri.parse(`inmemory://model/${this._editor.getId()}/${fileName}`); + const model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(newFileContent ?? '', undefined, uri); + if (!this.models.includes(model)) { + this.models.push(model); + } + if (newFileContent !== undefined) { + model.setValue(newFileContent); + } + + // Some elements remain when the model is changed - dispose of them. + this.disposeEditorElements(); + + monaco.editor.setModelLanguage(model, model.getLanguageId()); + this._editor.setModel(model); + } + + disposeModels() { + this._editor.setModel(null); + this.models.forEach((m) => m.dispose()); + this.models = []; + } + + reset(): void { + this.disposeEditorElements(); + this.disposeModels(); + } + + disposeEditorElements(): void { + this.disposeAnnotations(); + this.disposeWidgets(); + } + + disposeWidgets() { + this.lineWidgets.forEach((i) => { + i.dispose(); + }); + this.lineWidgets = []; + } + + disposeAnnotations() { + this.editorBuildAnnotations.forEach((o) => { + o.dispose(); + }); + this.editorBuildAnnotations = []; + } + + changeTheme(artemisTheme: Theme): void { + this._editor.updateOptions({ + theme: artemisTheme === Theme.DARK ? 'vs-dark' : 'vs-light', + }); + } + + /** + * Sets the build annotations to display in the editor. They are fixed to their respective lines and will be marked + * as outdated. + * @param annotations The annotations to render in the editor. + * @param outdated Whether the specified annotations are already outdated and should be grayed out. + */ + setAnnotations(annotations: Annotation[], outdated: boolean = false): void { + if (!this._editor) { + return; + } + this.disposeAnnotations(); + for (const annotation of annotations) { + const lineNumber = annotation.row + 1; + const editorBuildAnnotation = new MonacoEditorBuildAnnotation( + this._editor, + `${annotation.fileName}:${lineNumber}:${annotation.text}`, + lineNumber, + annotation.text, + annotation.type === 'error' ? MonacoEditorBuildAnnotationType.ERROR : MonacoEditorBuildAnnotationType.WARNING, + ); + editorBuildAnnotation.addToEditor(); + editorBuildAnnotation.setOutdatedAndUpdate(outdated); + this.editorBuildAnnotations.push(editorBuildAnnotation); + } + } + + /** + * Renders a line widget after the specified line. + * @param lineNumber The line after which the widget should be rendered. + * @param id The ID to use for the widget. + * @param domNode The content to display in the editor. + */ + addLineWidget(lineNumber: number, id: string, domNode: HTMLElement) { + const lineWidget = new MonacoEditorLineWidget(this._editor, id, domNode, lineNumber); + lineWidget.addToEditor(); + this.lineWidgets.push(lineWidget); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.module.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.module.ts new file mode 100644 index 000000000000..e9fcd6e23ec7 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from '@angular/core'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; + +@NgModule({ + declarations: [MonacoEditorComponent], + exports: [MonacoEditorComponent], +}) +export class MonacoEditorModule {} diff --git a/src/main/webapp/content/scss/themes/_dark-variables.scss b/src/main/webapp/content/scss/themes/_dark-variables.scss index 41c2dad32bc2..3c0acbdca3b6 100644 --- a/src/main/webapp/content/scss/themes/_dark-variables.scss +++ b/src/main/webapp/content/scss/themes/_dark-variables.scss @@ -426,6 +426,14 @@ $code-editor-file-browser-badge-background: $code-editor-file-browser-tree-hover $code-editor-diff-newline-background: rgba(46, 160, 67, 0.22); $code-editor-gutter-newline-background: rgba(46, 160, 67, 0.6); +// Monaco editor +$monaco-editor-build-annotation-error-background: scale-color($danger, $alpha: -80%); +$monaco-editor-build-annotation-error-glyph: $danger; +$monaco-editor-build-annotation-warning-background: scale-color($warning, $alpha: -80%); +$monaco-editor-build-annotation-warning-glyph: $warning; +$monaco-editor-build-annotation-outdated-background: scale-color($gray-500, $alpha: -90%); +$monaco-editor-build-annotation-outdated-glyph: $gray-500; + // Git-Diff Viewer $git-diff-viewer-added-line-background: rgba(63, 185, 80, 0.15); $git-diff-viewer-added-line-gutter-background: rgba(63, 185, 80, 0.3); diff --git a/src/main/webapp/content/scss/themes/_default-variables.scss b/src/main/webapp/content/scss/themes/_default-variables.scss index 4fb413ef043e..b19babf14c37 100644 --- a/src/main/webapp/content/scss/themes/_default-variables.scss +++ b/src/main/webapp/content/scss/themes/_default-variables.scss @@ -350,6 +350,14 @@ $code-editor-file-browser-badge-background: $code-editor-file-browser-tree-hover $code-editor-diff-newline-background: rgba(63, 185, 80, 0.4); $code-editor-gutter-newline-background: rgba(63, 185, 80, 0.8); +// Monaco editor +$monaco-editor-build-annotation-error-background: scale-color($danger, $alpha: -80%); +$monaco-editor-build-annotation-error-glyph: $danger; +$monaco-editor-build-annotation-warning-background: scale-color($warning, $alpha: -80%); +$monaco-editor-build-annotation-warning-glyph: $warning; +$monaco-editor-build-annotation-outdated-background: scale-color($gray-500, $alpha: -80%); +$monaco-editor-build-annotation-outdated-glyph: $gray-500; + // Git-Diff Viewer $git-diff-viewer-added-line-background: rgba(63, 185, 80, 0.5); $git-diff-viewer-added-line-gutter-background: rgba(63, 185, 80, 0.5); diff --git a/src/main/webapp/i18n/de/editor.json b/src/main/webapp/i18n/de/editor.json index cdb4fdd20cef..0e0345990c57 100644 --- a/src/main/webapp/i18n/de/editor.json +++ b/src/main/webapp/i18n/de/editor.json @@ -126,7 +126,8 @@ }, "orion": { "testLocally": "Lokal Testen" - } + }, + "switchEditors": "Code-Editor wechseln" } } } diff --git a/src/main/webapp/i18n/en/editor.json b/src/main/webapp/i18n/en/editor.json index f9577b68ec2a..52ba1d0751dd 100644 --- a/src/main/webapp/i18n/en/editor.json +++ b/src/main/webapp/i18n/en/editor.json @@ -127,7 +127,8 @@ }, "orion": { "testLocally": "Test Locally" - } + }, + "switchEditors": "Switch code editor" } } } diff --git a/src/test/cypress/support/pageobjects/course/CourseOverviewPage.ts b/src/test/cypress/support/pageobjects/course/CourseOverviewPage.ts index 045eeb817d56..02960ad6aa2c 100644 --- a/src/test/cypress/support/pageobjects/course/CourseOverviewPage.ts +++ b/src/test/cypress/support/pageobjects/course/CourseOverviewPage.ts @@ -10,8 +10,8 @@ export class CourseOverviewPage { cy.get('input[formcontrolname="searchFilter"]').type(term); } - startExercise(exerciseId: number) { - cy.reloadUntilFound('#start-exercise-' + exerciseId); + startExercise(exerciseId: number, refreshInterval?: number) { + cy.reloadUntilFound('#start-exercise-' + exerciseId, refreshInterval); cy.get('#start-exercise-' + exerciseId).click(); } diff --git a/src/test/cypress/support/pageobjects/exercises/programming/OnlineEditorPage.ts b/src/test/cypress/support/pageobjects/exercises/programming/OnlineEditorPage.ts index 94f2c4b2031c..5a1763bb2740 100644 --- a/src/test/cypress/support/pageobjects/exercises/programming/OnlineEditorPage.ts +++ b/src/test/cypress/support/pageobjects/exercises/programming/OnlineEditorPage.ts @@ -1,6 +1,6 @@ import { courseList, courseOverview } from '../../../artemis'; import { DELETE } from '../../../constants'; -import { BASE_API, GET, POST } from '../../../constants'; +import { BASE_API, POST } from '../../../constants'; import { CypressCredentials } from '../../../users'; import { getExercise } from '../../../utils'; @@ -29,17 +29,19 @@ export class OnlineEditorPage { } else { this.createFileInRootPackage(exerciseID, newFile.name, submission.packageName!); } - cy.fixture(newFile.path).then(($fileContent) => { - cy.window().then((win) => { - getExercise(exerciseID) - .find('#ace-code-editor') - .then(($el) => { - // @ts-expect-error ace does not exists on windows, but works without issue - const editor = win.ace.edit($el.get(0)); - editor.setValue($fileContent, 1); + cy.fixture(newFile.path) + .then(($fileContent) => { + cy.window().then((win) => { + win.navigator.clipboard.writeText($fileContent).then(() => { + // Some files have 200+ lines; typing would be too slow. Instead, paste the file content. + // On MacOS (darwin), the keyboard shortcut is CMD (meta) + V instead of CTRL + V. + const keyModifier = Cypress.platform === 'darwin' ? 'meta' : 'ctrl'; + // view-lines is the class of the text area in monaco. + getExercise(exerciseID).find('.view-lines').click().type(`{${keyModifier}}v`).wait(200); }); - }); - }); + }); + }) + .wait(500); } cy.wait(500); } @@ -98,18 +100,13 @@ export class OnlineEditorPage { */ createFileInRootFolder(exerciseID: number, fileName: string) { const postRequestId = 'createFile' + fileName; - const getRequestId = 'getFile' + fileName; const requestPath = `${BASE_API}/repository/*/file?file=${fileName}`; getExercise(exerciseID).find('[id="create_file_root"]').click().wait(500); cy.intercept(POST, requestPath).as(postRequestId); - cy.intercept(GET, requestPath).as(getRequestId); getExercise(exerciseID).find('#file-browser-create-node').type(fileName).wait(500).type('{enter}'); cy.wait('@' + postRequestId) .its('response.statusCode') .should('eq', 200); - cy.wait('@' + getRequestId) - .its('response.statusCode') - .should('eq', 200); this.findFileBrowser(exerciseID).contains(fileName).should('be.visible').wait(500); } @@ -123,18 +120,13 @@ export class OnlineEditorPage { const packagePath = packageName.replace(/\./g, '/'); const filePath = `src/${packagePath}/${fileName}`; const postRequestId = 'createFile' + fileName; - const getRequestId = 'getFile' + fileName; const requestPath = `${BASE_API}/repository/*/file?file=${filePath}`; getExercise(exerciseID).find('[id="file-browser-folder-create-file"]').eq(2).click().wait(500); cy.intercept(POST, requestPath).as(postRequestId); - cy.intercept(GET, requestPath).as(getRequestId); getExercise(exerciseID).find('#file-browser-create-node').type(fileName).wait(500).type('{enter}'); cy.wait('@' + postRequestId) .its('response.statusCode') .should('eq', 200); - cy.wait('@' + getRequestId) - .its('response.statusCode') - .should('eq', 200); this.findFileBrowser(exerciseID).contains(fileName).should('be.visible').wait(500); } @@ -191,13 +183,15 @@ export class OnlineEditorPage { * Starts the participation in the test programming exercise. */ startParticipation(courseId: number, exerciseId: number, credentials: CypressCredentials) { + // For shorter intervals, the reload may come before the app can render the elements. + const reloadInterval = 4000; cy.login(credentials, '/'); cy.url().should('include', '/courses'); cy.log('Participating in the programming exercise as a student...'); courseList.openCourse(courseId!); cy.url().should('include', '/exercises'); - courseOverview.startExercise(exerciseId); - cy.reloadUntilFound('#open-exercise-' + exerciseId); + courseOverview.startExercise(exerciseId, reloadInterval); + cy.reloadUntilFound('#open-exercise-' + exerciseId, reloadInterval); courseOverview.openRunningProgrammingExercise(exerciseId); } } diff --git a/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts b/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts index 202f0086eb36..d73c6db5c0ec 100644 --- a/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts +++ b/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts @@ -17,10 +17,7 @@ export class ProgrammingExerciseFeedbackPage extends AbstractExerciseFeedback { } private findVisibleInlineFeedback() { - // The additional `jhi-ace-editor` selector is needed because sometimes, the ACE editor will - // copy the feedback to other temporary locations in the DOM. - // => The ID is not unique anymore! - return cy.get('jhi-ace-editor [id*="code-editor-inline-feedback-"]').should('be.visible'); + return cy.get('[id*="code-editor-inline-feedback-"]').should('be.visible'); } shouldShowRepositoryLockedWarning() { diff --git a/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts new file mode 100644 index 000000000000..638cbba7aeea --- /dev/null +++ b/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts @@ -0,0 +1,288 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../test.module'; + +import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; +import { MockComponent } from 'ng-mocks'; +import { CodeEditorTutorAssessmentInlineFeedbackComponent } from 'app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component'; +import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; +import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; +import { CodeEditorFileService } from 'app/exercises/programming/shared/code-editor/service/code-editor-file.service'; +import { CodeEditorRepositoryFileService } from 'app/exercises/programming/shared/code-editor/service/code-editor-repository.service'; +import { MockCodeEditorRepositoryFileService } from '../../helpers/mocks/service/mock-code-editor-repository-file.service'; +import { MockLocalStorageService } from '../../helpers/mocks/service/mock-local-storage.service'; +import { LocalStorageService } from 'ngx-webstorage'; +import { Annotation } from 'app/exercises/programming/shared/code-editor/ace/code-editor-ace.component'; +import { SimpleChange } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code-editor/header/code-editor-header.component'; +import { CreateFileChange, DeleteFileChange, EditorState, FileType, RenameFileChange } from 'app/exercises/programming/shared/code-editor/model/code-editor.model'; + +describe('CodeEditorMonacoComponent', () => { + let comp: CodeEditorMonacoComponent; + let fixture: ComponentFixture; + let getInlineFeedbackNodeStub: jest.SpyInstance; + let codeEditorRepositoryFileService: CodeEditorRepositoryFileService; + let loadFileFromRepositoryStub: jest.SpyInstance; + + const exampleFeedbacks = [ + { + id: 1, + reference: 'file:file1.java_line:1', + }, + { + id: 2, + reference: 'file:file1.java_line:2', + }, + { + id: 3, + reference: 'file:file2.java_line:9', + }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, MonacoEditorModule], + declarations: [ + CodeEditorMonacoComponent, + MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), + MockComponent(CodeEditorHeaderComponent), + MonacoEditorComponent, + ], + providers: [ + CodeEditorFileService, + { provide: CodeEditorRepositoryFileService, useClass: MockCodeEditorRepositoryFileService }, + { provide: LocalStorageService, useClass: MockLocalStorageService }, + ], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CodeEditorMonacoComponent); + comp = fixture.componentInstance; + codeEditorRepositoryFileService = fixture.debugElement.injector.get(CodeEditorRepositoryFileService); + loadFileFromRepositoryStub = jest.spyOn(codeEditorRepositoryFileService, 'getFile'); + getInlineFeedbackNodeStub = jest.spyOn(comp, 'getInlineFeedbackNode').mockReturnValue(document.createElement('div')); + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + return new MockResizeObserver(callback); + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should hide the editor if no file is selected', () => { + comp.sessionId = 'test'; + fixture.detectChanges(); + const element = document.getElementById('monaco-editor-test'); + expect(element).not.toBeNull(); + expect(element!.hidden).toBeTrue(); + }); + + it('should not try to load a file if none is selected', async () => { + const editorChangeModelSpy = jest.spyOn(comp.editor, 'changeModel'); + fixture.detectChanges(); + await comp.selectFileInEditor(undefined); + expect(editorChangeModelSpy).not.toHaveBeenCalled(); + expect(loadFileFromRepositoryStub).not.toHaveBeenCalled(); + }); + + it('should hide the editor if a file is being loaded', () => { + comp.sessionId = 'test'; + comp.selectedFile = 'file'; + fixture.detectChanges(); + comp.isLoading = true; + fixture.detectChanges(); + const element = document.getElementById('monaco-editor-test'); + expect(element).not.toBeNull(); + expect(element!.hidden).toBeTrue(); + }); + + it('should display the usable editor when a file is selected', () => { + comp.sessionId = 'test'; + comp.selectedFile = 'file'; + comp.isLoading = false; + comp.isTutorAssessment = false; + fixture.detectChanges(); + const element = document.getElementById('monaco-editor-test'); + expect(element).not.toBeNull(); + expect(element!.hidden).toBeFalse(); + expect(comp.editor.isReadOnly()).toBeFalse(); + }); + + it('should update the file session and notify when the file content changes', () => { + const selectedFile = 'file'; + const fileSession = { + [selectedFile]: { code: 'some unchanged code', cursor: { row: 0, column: 0 }, loadingError: false }, + }; + const newCode = 'some new code'; + const valueCallbackStub = jest.fn(); + comp.onFileContentChange.subscribe(valueCallbackStub); + fixture.detectChanges(); + comp.fileSession = fileSession; + comp.selectedFile = selectedFile; + comp.onFileTextChanged(newCode); + expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ file: selectedFile, fileContent: newCode }); + expect(comp.fileSession).toEqual({ + [selectedFile]: { ...fileSession[selectedFile], code: newCode }, + }); + }); + + it('should load a selected file only if it is not present yet', async () => { + const fileToLoad = { fileName: 'file-to-load', fileContent: 'some code' }; + const loadedFileSubject = new BehaviorSubject(fileToLoad); + loadFileFromRepositoryStub.mockReturnValue(loadedFileSubject); + const setPositionStub = jest.spyOn(comp.editor, 'setPosition').mockImplementation(); + const changeModelStub = jest.spyOn(comp.editor, 'changeModel').mockImplementation(); + const presentFileName = 'present-file'; + const presentFileSession = { + [presentFileName]: { code: 'code\ncode', cursor: { row: 1, column: 2 }, loadingError: false }, + }; + fixture.detectChanges(); + comp.fileSession = presentFileSession; + await comp.selectFileInEditor(fileToLoad.fileName); + await comp.selectFileInEditor(presentFileName); + expect(loadFileFromRepositoryStub).toHaveBeenCalledExactlyOnceWith(fileToLoad.fileName); + expect(comp.fileSession).toEqual({ + ...presentFileSession, + [fileToLoad.fileName]: { code: fileToLoad.fileContent, cursor: { column: 0, row: 0 }, loadingError: false }, + }); + expect(setPositionStub).toHaveBeenCalledTimes(2); + expect(changeModelStub).toHaveBeenCalledTimes(2); + }); + + it('should discard local changes when the editor is refreshed', async () => { + const fileToReload = { fileName: 'file-to-reload', fileContent: 'some remote code' }; + const editorResetStub = jest.spyOn(comp.editor, 'reset').mockImplementation(); + const reloadedFileSubject = new BehaviorSubject(fileToReload); + loadFileFromRepositoryStub.mockReturnValue(reloadedFileSubject); + comp.selectedFile = fileToReload.fileName; + comp.fileSession = { + [fileToReload.fileName]: { code: 'some local undiscarded changes', cursor: { row: 0, column: 0 }, loadingError: false }, + }; + comp.editorState = EditorState.CLEAN; + fixture.detectChanges(); + // Simulate a refresh of the editor. + await comp.ngOnChanges({ editorState: new SimpleChange(EditorState.REFRESHING, EditorState.CLEAN, false) }); + expect(comp.fileSession).toEqual({ + [fileToReload.fileName]: { code: fileToReload.fileContent, cursor: { row: 0, column: 0 }, loadingError: false }, + }); + expect(editorResetStub).toHaveBeenCalledOnce(); + }); + + it('should use the code and cursor position of the selected file', async () => { + const setPositionStub = jest.spyOn(comp.editor, 'setPosition').mockImplementation(); + const changeModelStub = jest.spyOn(comp.editor, 'changeModel').mockImplementation(); + fixture.detectChanges(); + const selectedFile = 'file1'; + const fileSession = { + [selectedFile]: { code: 'code\ncode', cursor: { row: 1, column: 2 }, loadingError: false }, + }; + comp.fileSession = fileSession; + await comp.selectFileInEditor(selectedFile); + expect(setPositionStub).toHaveBeenCalledExactlyOnceWith(fileSession[selectedFile].cursor); + expect(changeModelStub).toHaveBeenCalledExactlyOnceWith(selectedFile, fileSession[selectedFile].code); + }); + + it('should display build annotations for the current file', async () => { + const setAnnotationsStub = jest.spyOn(comp.editor, 'setAnnotations').mockImplementation(); + const selectFileInEditorStub = jest.spyOn(comp, 'selectFileInEditor').mockImplementation(); + const buildAnnotations: Annotation[] = [ + { + fileName: 'file1', + text: 'error', + type: 'error', + hash: 'file111error', + timestamp: 0, + row: 1, + column: 1, + }, + { + fileName: 'file2', + text: 'error', + type: 'error', + hash: 'file211error', + timestamp: 0, + row: 1, + column: 1, + }, + ]; + comp.annotationsArray = buildAnnotations; + comp.selectedFile = 'file1'; + fixture.detectChanges(); + await comp.ngOnChanges({ selectedFile: new SimpleChange(undefined, 'file1', false) }); + comp.selectedFile = 'file2'; + fixture.detectChanges(); + await comp.ngOnChanges({ selectedFile: new SimpleChange('file1', 'file2', false) }); + expect(setAnnotationsStub).toHaveBeenCalledTimes(2); + expect(selectFileInEditorStub).toHaveBeenCalledTimes(2); + expect(setAnnotationsStub).toHaveBeenNthCalledWith(1, [buildAnnotations[0]], false); + expect(setAnnotationsStub).toHaveBeenNthCalledWith(2, [buildAnnotations[1]], false); + }); + + it('should display feedback when viewing a tutor assessment', async () => { + const addLineWidgetStub = jest.spyOn(comp.editor, 'addLineWidget').mockImplementation(); + const selectFileInEditorStub = jest.spyOn(comp, 'selectFileInEditor').mockImplementation(); + comp.isTutorAssessment = true; + comp.selectedFile = 'file1.java'; + comp.feedbacks = exampleFeedbacks; + fixture.detectChanges(); + await comp.ngOnChanges({ selectedFile: new SimpleChange(undefined, 'file1', false) }); + expect(addLineWidgetStub).toHaveBeenCalledTimes(2); + expect(addLineWidgetStub).toHaveBeenNthCalledWith(1, 2, `feedback-1`, document.createElement('div')); + expect(addLineWidgetStub).toHaveBeenNthCalledWith(2, 3, `feedback-2`, document.createElement('div')); + expect(getInlineFeedbackNodeStub).toHaveBeenCalledTimes(2); + expect(selectFileInEditorStub).toHaveBeenCalledOnce(); + }); + + it('should update file session when a file is renamed', async () => { + const oldFileName = 'old-file-name'; + const newFileName = 'new-file-name'; + const otherFileName = 'other-file'; + const fileSession = { + [oldFileName]: { code: 'renamed', cursor: { row: 0, column: 0 }, loadingError: false }, + [otherFileName]: { code: 'unrelated', cursor: { row: 0, column: 0 }, loadingError: false }, + }; + fixture.detectChanges(); + comp.fileSession = { ...fileSession }; + const renameFileChange = new RenameFileChange(FileType.FILE, oldFileName, newFileName); + await comp.onFileChange(renameFileChange); + expect(comp.fileSession).toEqual({ + [newFileName]: fileSession[oldFileName], + [otherFileName]: fileSession[otherFileName], + }); + }); + + it('should update file session when a file is deleted', async () => { + const fileToDeleteName = 'file-to-delete'; + const otherFileName = 'other-file'; + const fileSession = { + [fileToDeleteName]: { code: 'will be deleted', cursor: { row: 0, column: 0 }, loadingError: false }, + [otherFileName]: { code: 'unrelated', cursor: { row: 0, column: 0 }, loadingError: false }, + }; + fixture.detectChanges(); + comp.fileSession = { ...fileSession }; + const deleteFileChange = new DeleteFileChange(FileType.FILE, fileToDeleteName); + await comp.onFileChange(deleteFileChange); + expect(comp.fileSession).toEqual({ + [otherFileName]: fileSession[otherFileName], + }); + }); + + it('should update file session when a file is created', async () => { + const fileToCreateName = 'file-to-create'; + const otherFileName = 'other-file'; + const fileSession = { + [otherFileName]: { code: 'unrelated', cursor: { row: 0, column: 0 }, loadingError: false }, + }; + fixture.detectChanges(); + comp.fileSession = { ...fileSession }; + const createFileChange = new CreateFileChange(FileType.FILE, fileToCreateName); + await comp.onFileChange(createFileChange); + expect(comp.fileSession).toEqual({ + [otherFileName]: fileSession[otherFileName], + [fileToCreateName]: { code: '', cursor: { row: 0, column: 0 }, loadingError: false }, + }); + }); +}); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts new file mode 100644 index 000000000000..9e05595c1361 --- /dev/null +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts @@ -0,0 +1,217 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; +import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; +import { Theme, ThemeService } from 'app/core/theme/theme.service'; +import { BehaviorSubject } from 'rxjs'; +import { Annotation } from 'app/exercises/programming/shared/code-editor/ace/code-editor-ace.component'; +import { MonacoEditorBuildAnnotationType } from 'app/shared/monaco-editor/model/monaco-editor-build-annotation.model'; +import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; + +describe('MonacoEditorComponent', () => { + let fixture: ComponentFixture; + let comp: MonacoEditorComponent; + let mockThemeService: ThemeService; + + const singleLineText = 'public class Main { }'; + const multiLineText = ['public class Main {', 'static void main() {', 'foo();', '}', '}'].join('\n'); + + const buildAnnotationArray: Annotation[] = [{ fileName: 'example.java', row: 1, column: 0, timestamp: 0, type: MonacoEditorBuildAnnotationType.ERROR, text: 'example error' }]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, MonacoEditorModule], + declarations: [MonacoEditorComponent], + providers: [], + }) + .compileComponents() + .then(() => { + mockThemeService = TestBed.inject(ThemeService); + fixture = TestBed.createComponent(MonacoEditorComponent); + comp = fixture.componentInstance; + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + return new MockResizeObserver(callback); + }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should set the text of the editor', () => { + fixture.detectChanges(); + comp.setText(singleLineText); + expect(comp.getText()).toEqual(singleLineText); + }); + + it('should notify when the text changes', () => { + const valueCallbackStub = jest.fn(); + fixture.detectChanges(); + comp.textChanged.subscribe(valueCallbackStub); + comp.setText(singleLineText); + expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith(singleLineText); + }); + + it('should only send a notification once per delay interval', fakeAsync(() => { + const delay = 1000; + const valueCallbackStub = jest.fn(); + comp.textChangedEmitDelay = delay; + fixture.detectChanges(); + comp.textChanged.subscribe(valueCallbackStub); + comp.setText('too early'); + tick(1); + comp.setText(singleLineText); + tick(delay); + expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith(singleLineText); + })); + + it('should be set to readOnly depending on the input', () => { + comp.readOnly = true; + fixture.detectChanges(); + expect(comp.isReadOnly()).toBeTrue(); + comp.readOnly = false; + fixture.detectChanges(); + expect(comp.isReadOnly()).toBeFalse(); + }); + + it('should adjust its theme to the global theme', () => { + const themeSubject = new BehaviorSubject(Theme.LIGHT); + const subscribeStub = jest.spyOn(mockThemeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); + const changeThemeSpy = jest.spyOn(comp, 'changeTheme'); + fixture.detectChanges(); + themeSubject.next(Theme.DARK); + expect(subscribeStub).toHaveBeenCalledOnce(); + expect(changeThemeSpy).toHaveBeenCalledTimes(2); + expect(changeThemeSpy).toHaveBeenNthCalledWith(1, Theme.LIGHT); + expect(changeThemeSpy).toHaveBeenNthCalledWith(2, Theme.DARK); + }); + + it('should unsubscribe from the global theme when destroyed', () => { + const themeSubject = new BehaviorSubject(Theme.LIGHT); + const subscribeStub = jest.spyOn(mockThemeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); + fixture.detectChanges(); + const unsubscribeStub = jest.spyOn(comp.themeSubscription!, 'unsubscribe').mockImplementation(); + comp.ngOnDestroy(); + expect(subscribeStub).toHaveBeenCalledOnce(); + expect(unsubscribeStub).toHaveBeenCalledOnce(); + }); + + it('should display hidden line widgets', () => { + const lineWidgetDiv = document.createElement('div'); + // This is the case e.g. for feedback items. + lineWidgetDiv.classList.add(MonacoCodeEditorElement.CSS_HIDDEN_CLASS); + const widgetId = 'test-widget'; + fixture.detectChanges(); + comp.setText(multiLineText); + comp.addLineWidget(2, widgetId, lineWidgetDiv); + expect(lineWidgetDiv.classList).not.toContain(MonacoCodeEditorElement.CSS_HIDDEN_CLASS); + }); + + it('should display build annotations', () => { + const annotation = buildAnnotationArray[0]; + const buildAnnotationId = `monaco-editor-glyph-margin-widget-${annotation.fileName}:${annotation.row + 1}:${annotation.text}`; + fixture.detectChanges(); + comp.setAnnotations(buildAnnotationArray, false); + comp.setText(multiLineText); + const element = document.getElementById(buildAnnotationId); + expect(comp.editorBuildAnnotations).toHaveLength(1); + expect(element).not.toBeNull(); + expect(element).toEqual(comp.editorBuildAnnotations[0].getGlyphMarginDomNode()); + }); + + it('should not display build annotations that are out of bounds', () => { + const annotation = buildAnnotationArray[0]; + const buildAnnotationId = `monaco-editor-glyph-margin-widget-${annotation.fileName}:${annotation.row + 1}:${annotation.text}`; + fixture.detectChanges(); + comp.setAnnotations(buildAnnotationArray, false); + comp.setText(singleLineText); + const element = document.getElementById(buildAnnotationId); + expect(comp.editorBuildAnnotations).toHaveLength(1); + // Ensure that the element is actually there, but not displayed in the DOM. + expect(element).toBeNull(); + expect(comp.editorBuildAnnotations[0].getGlyphMarginDomNode().id).toBe(buildAnnotationId); + }); + + it('should mark build annotations as outdated if specified', () => { + fixture.detectChanges(); + comp.setText(multiLineText); + comp.setAnnotations(buildAnnotationArray, true); + expect(comp.editorBuildAnnotations).toHaveLength(1); + expect(comp.editorBuildAnnotations[0].isOutdated()).toBeTrue(); + }); + + it('should mark build annotations as outdated when a keyboard input is made', () => { + fixture.detectChanges(); + comp.setText(multiLineText); + comp.setAnnotations(buildAnnotationArray, false); + expect(comp.editorBuildAnnotations).toHaveLength(1); + expect(comp.editorBuildAnnotations[0].isOutdated()).toBeFalse(); + comp.triggerKeySequence('typing'); + expect(comp.editorBuildAnnotations[0].isOutdated()).toBeTrue(); + }); + + it('should not allow editing in readonly mode', () => { + comp.readOnly = true; + fixture.detectChanges(); + comp.setText(singleLineText); + comp.triggerKeySequence('some ignored input'); + expect(comp.getText()).toBe(singleLineText); + }); + + it('should dispose and destroy its widgets and annotations when destroyed', () => { + fixture.detectChanges(); + comp.setAnnotations(buildAnnotationArray); + comp.addLineWidget(1, 'widget', document.createElement('div')); + const disposeAnnotationSpy = jest.spyOn(comp.editorBuildAnnotations[0], 'dispose'); + const disposeWidgetSpy = jest.spyOn(comp.lineWidgets[0], 'dispose'); + comp.ngOnDestroy(); + expect(disposeWidgetSpy).toHaveBeenCalledOnce(); + expect(disposeAnnotationSpy).toHaveBeenCalledOnce(); + }); + + it('should switch to and update the text of a single model', () => { + fixture.detectChanges(); + comp.changeModel('file', multiLineText); + expect(comp.getText()).toBe(multiLineText); + expect(comp.models).toHaveLength(1); + expect(comp.models[0].getValue()).toBe(multiLineText); + comp.changeModel('file', singleLineText); + expect(comp.getText()).toBe(singleLineText); + expect(comp.models).toHaveLength(1); + expect(comp.models[0].getValue()).toBe(singleLineText); + }); + + it('should initialize an empty model if no text is specified', () => { + fixture.detectChanges(); + comp.changeModel('file'); + expect(comp.getText()).toBe(''); + expect(comp.models).toHaveLength(1); + expect(comp.models[0].getValue()).toBe(''); + }); + + it('should switch between multiple models without changing their content', () => { + fixture.detectChanges(); + // Set initial values + comp.changeModel('file1', singleLineText); + comp.changeModel('file2', multiLineText); + expect(comp.getText()).toBe(multiLineText); + // Switch without changing + comp.changeModel('file1'); + expect(comp.getText()).toBe(singleLineText); + comp.changeModel('file2'); + expect(comp.getText()).toBe(multiLineText); + }); + + it('should dispose its models when destroyed', () => { + fixture.detectChanges(); + comp.changeModel('file1', singleLineText); + const model = comp.models[0]; + const modelDisposeSpy = jest.spyOn(model, 'dispose'); + comp.ngOnDestroy(); + expect(comp.models).toBeEmpty(); + expect(modelDisposeSpy).toHaveBeenCalledOnce(); + expect(model.isDisposed()).toBeTrue(); + }); +}); diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts index 1958b8b96768..cdd5bb1617b9 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts @@ -234,8 +234,8 @@ describe('CodeEditorContainerIntegration', () => { expect(container.fileBrowser.unsavedFiles).toHaveLength(0); // ace editor - expect(container.aceEditor.isLoading).toBeFalse(); - expect(container.aceEditor.commitState).toBe(CommitState.CLEAN); + expect(container.aceEditor?.isLoading).toBeFalse(); + expect(container.aceEditor?.commitState).toBe(CommitState.CLEAN); // actions expect(container.actions.commitState).toBe(CommitState.CLEAN); @@ -308,9 +308,9 @@ describe('CodeEditorContainerIntegration', () => { expect(container.fileBrowser.unsavedFiles).toHaveLength(0); // ace editor - expect(container.aceEditor.isLoading).toBeFalse(); - expect(container.aceEditor.annotationsArray.map((a) => omit(a, 'hash'))).toEqual(extractedBuildLogErrors); - expect(container.aceEditor.commitState).toBe(CommitState.COULD_NOT_BE_RETRIEVED); + expect(container.aceEditor?.isLoading).toBeFalse(); + expect(container.aceEditor?.annotationsArray?.map((a) => omit(a, 'hash'))).toEqual(extractedBuildLogErrors); + expect(container.aceEditor?.commitState).toBe(CommitState.COULD_NOT_BE_RETRIEVED); // actions expect(container.actions.commitState).toBe(CommitState.COULD_NOT_BE_RETRIEVED); @@ -347,14 +347,14 @@ describe('CodeEditorContainerIntegration', () => { containerFixture.detectChanges(); await containerFixture.whenStable(); expect(container.selectedFile).toBe(selectedFile); - expect(container.aceEditor.selectedFile).toBe(selectedFile); - expect(container.aceEditor.isLoading).toBeFalse(); - expect(container.aceEditor.fileSession).toContainKey(selectedFile); + expect(container.aceEditor?.selectedFile).toBe(selectedFile); + expect(container.aceEditor?.isLoading).toBeFalse(); + expect(container.aceEditor?.fileSession).toContainKey(selectedFile); expect(getFileStub).toHaveBeenCalledOnce(); expect(getFileStub).toHaveBeenCalledWith(selectedFile); containerFixture.detectChanges(); - expect(container.aceEditor.editor.getEditor().getSession().getValue()).toBe(fileContent); + expect(container.aceEditor?.editor?.getEditor()?.getSession()?.getValue()).toBe(fileContent); })); it('should mark file to have unsaved changes in file tree if the file was changed in editor', waitForAsync(async () => { @@ -365,7 +365,7 @@ describe('CodeEditorContainerIntegration', () => { loadFile(selectedFile, fileContent); containerFixture.detectChanges(); - container.aceEditor.onFileTextChanged(newFileContent); + container.aceEditor?.onFileTextChanged(newFileContent); containerFixture.detectChanges(); await containerFixture.whenStable(); diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts index 8f9423ac121d..c8da711a6482 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-student.integration.spec.ts @@ -59,6 +59,7 @@ import { CodeEditorFileBrowserFileComponent } from 'app/exercises/programming/sh import { CodeEditorStatusComponent } from 'app/exercises/programming/shared/code-editor/status/code-editor-status.component'; import { TreeviewComponent } from 'app/exercises/programming/shared/code-editor/treeview/components/treeview/treeview.component'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; describe('CodeEditorStudentIntegration', () => { // needed to make sure ace is defined @@ -102,6 +103,7 @@ describe('CodeEditorStudentIntegration', () => { MockComponent(CodeEditorActionsComponent), MockComponent(CodeEditorBuildOutputComponent), MockComponent(CodeEditorAceComponent), + MockComponent(CodeEditorMonacoComponent), MockComponent(CodeEditorFileBrowserCreateNodeComponent), MockComponent(CodeEditorFileBrowserFolderComponent), MockComponent(CodeEditorFileBrowserFileComponent), diff --git a/src/test/javascript/spec/jest-test-setup.ts b/src/test/javascript/spec/jest-test-setup.ts index 9a5024acc9bb..19df45923461 100644 --- a/src/test/javascript/spec/jest-test-setup.ts +++ b/src/test/javascript/spec/jest-test-setup.ts @@ -5,6 +5,17 @@ import 'app/core/config/dayjs'; import 'jest-canvas-mock'; import 'jest-extended'; import failOnConsole from 'jest-fail-on-console'; +import { TextDecoder, TextEncoder } from 'util'; + +/* + * In the Jest configuration, we only import the basic features of monaco (editor.api.js) instead + * of the full module (editor.main.js) because of a ReferenceError in the language features of Monaco. + * The following import imports the core features of the monaco editor, but leaves out the language + * features. It contains an unchecked call to queryCommandSupported, so the function has to be set + * on the document. + */ +document.queryCommandSupported = () => false; +import 'monaco-editor/esm/vs/editor/edcore.main'; failOnConsole({ shouldFailOnWarn: true, @@ -62,3 +73,6 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: jest.fn(), })), }); + +// Prevents an error with the monaco editor tests +Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/src/test/playwright/support/pageobjects/exercises/programming/OnlineEditorPage.ts b/src/test/playwright/support/pageobjects/exercises/programming/OnlineEditorPage.ts index 26f0cadad6e2..a1c09b4dc0b0 100644 --- a/src/test/playwright/support/pageobjects/exercises/programming/OnlineEditorPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/programming/OnlineEditorPage.ts @@ -22,6 +22,10 @@ export class OnlineEditorPage { return getExercise(this.page, exerciseID).locator('#cardFiles'); } + findEditorTextField(exerciseID: number) { + return getExercise(this.page, exerciseID).locator('.view-lines').first(); + } + async typeSubmission(exerciseID: number, submission: ProgrammingExerciseSubmission) { for (const newFile of submission.files) { if (submission.createFilesInRootFolder) { @@ -30,17 +34,19 @@ export class OnlineEditorPage { await this.createFileInRootPackage(exerciseID, newFile.name, submission.packageName!); } const fileContent = await Fixtures.get(newFile.path); - await this.page.evaluate( - ({ editorSelector, fileContent }) => { - const editorElement = document.querySelector(editorSelector); - if (editorElement) { - // @ts-expect-error ace does not exists on windows, but works without issue - const editor = ace.edit(editorElement); - editor.setValue(fileContent, 1); // Set the editor's content - } + const editorElement = this.findEditorTextField(exerciseID); + await editorElement.click(); + await editorElement.evaluate( + (element, { fileContent }) => { + const clipboardData = new DataTransfer(); + const format = 'text/plain'; + clipboardData.setData(format, fileContent); + const event = new ClipboardEvent('paste', { clipboardData }); + element.dispatchEvent(event); }, - { editorSelector: '#ace-code-editor', fileContent: fileContent! }, + { fileContent: fileContent! }, ); + await this.page.waitForTimeout(500); } await this.page.waitForTimeout(500); } diff --git a/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts b/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts index bff11821dda5..a81b34c9271b 100644 --- a/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/programming/ProgrammingExerciseFeedbackPage.ts @@ -24,7 +24,7 @@ export class ProgrammingExerciseFeedbackPage extends AbstractExerciseFeedback { // Playwright handles visibility checks differently, so the check is incorporated into the expect statement. // Note: Playwright's visibility checks are more strict than Cypress's. // If the element's visibility varies dynamically, you may need to adjust the logic. - const feedbackElement = this.page.locator('jhi-ace-editor [id*="code-editor-inline-feedback-"]'); + const feedbackElement = this.page.locator('[id*="code-editor-inline-feedback-"]'); await expect(feedbackElement).toBeVisible(); return feedbackElement; } diff --git a/stub.js b/stub.js new file mode 100644 index 000000000000..6d827415c175 --- /dev/null +++ b/stub.js @@ -0,0 +1,5 @@ +/* + * Some dependencies may import css files even during tests (e.g. monaco-editor). + * This file stops them from having an impact on the test cases. + */ +module.exports = {};