Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,8 @@ export class CodeEditorContainerComponent implements OnChanges, ComponentCanDeac
/**
* When the content of a file changes, set it as unsaved.
*/
onFileContentChange({ file, fileContent }: { file: string; fileContent: string }) {
this.unsavedFiles = { ...this.unsavedFiles, [file]: fileContent };
onFileContentChange({ fileName, text }: { fileName: string; text: string }) {
this.unsavedFiles = { ...this.unsavedFiles, [fileName]: text };
this.onFileChanged.emit();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => {
it('should change code of active action', () => {
comp.changeActiveAction('gradle');
expect(comp.code).toBe(gradleBuildAction.script);
comp.codeChanged('test');
comp.codeChanged({ text: 'test', fileName: 'build-script.sh' });
expect(gradleBuildAction.script).toBe('test');
});

Expand Down Expand Up @@ -166,7 +166,7 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => {

it('should change code', () => {
comp.changeActiveAction('gradle');
comp.codeChanged('this is some code');
comp.codeChanged({ text: 'this is some code', fileName: 'build-script.sh' });
const action: BuildAction | undefined = comp.active;
expect(action).toBeDefined();
expect(action).toBeInstanceOf(ScriptAction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,9 @@ export class ProgrammingExerciseCustomAeolusBuildPlanComponent implements OnChan
}
}

codeChanged(code: string): void {
codeChanged(event: { text: string; fileName: string }): void {
if (this.active instanceof ScriptAction) {
(this.active as ScriptAction).script = code;
(this.active as ScriptAction).script = event.text;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export class ProgrammingExerciseCustomBuildPlanComponent implements OnChanges {

faQuestionCircle = faQuestionCircle;

codeChanged(code: string): void {
codeChanged(codeOrEvent: string | { text: string; fileName: string }): void {
const code = typeof codeOrEvent === 'string' ? codeOrEvent : codeOrEvent.text;
this.code = code;
this.editor?.setText(code);
this.programmingExercise.buildConfig!.buildScript = code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ describe('CodeEditorMonacoComponent', () => {
fixture.detectChanges();
comp.fileSession.set(fileSession);
fixture.componentRef.setInput('selectedFile', selectedFile);
comp.onFileTextChanged(newCode);
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ file: selectedFile, fileContent: newCode });
comp.onFileTextChanged({ text: newCode, fileName: selectedFile });
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ fileName: selectedFile, text: newCode });
expect(comp.fileSession()).toEqual({
[selectedFile]: { ...fileSession[selectedFile], code: newCode, scrollTop: 0 },
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class CodeEditorMonacoComponent implements OnChanges {
readonly buildAnnotations = input<Annotation[]>([]);

readonly onError = output<string>();
readonly onFileContentChange = output<{ file: string; fileContent: string }>();
readonly onFileContentChange = output<{ fileName: string; text: string }>();
readonly onUpdateFeedback = output<Feedback[]>();
readonly onFileLoad = output<string>();
readonly onAcceptSuggestion = output<Feedback>();
Expand Down Expand Up @@ -213,16 +213,25 @@ export class CodeEditorMonacoComponent implements OnChanges {
this.editor().setScrollTop(this.fileSession()[this.selectedFile()!].scrollTop ?? 0);
}

onFileTextChanged(text: string): void {
if (this.selectedFile() && this.fileSession()[this.selectedFile()!]) {
const previousText = this.fileSession()[this.selectedFile()!].code;
const previousScrollTop = this.fileSession()[this.selectedFile()!].scrollTop;
onFileTextChanged(event: { text: string; fileName: string }): void {
const { text, fileName } = event;
// Apply text change to the specific file it belongs to, not the currently selected file
if (fileName && this.fileSession()[fileName]) {
const previousText = this.fileSession()[fileName].code;
const previousScrollTop = this.fileSession()[fileName].scrollTop;

if (previousText !== text) {
this.fileSession.set({
...this.fileSession(),
[this.selectedFile()!]: { code: text, loadingError: false, scrollTop: previousScrollTop, cursor: this.editor().getPosition() },
[fileName]: {
code: text,
loadingError: false,
scrollTop: previousScrollTop,
cursor: fileName === this.selectedFile() ? this.editor().getPosition() : this.fileSession()[fileName].cursor,
},
});
this.onFileContentChange.emit({ file: this.selectedFile()!, fileContent: text });

this.onFileContentChange.emit({ fileName, text });
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('MarkdownEditorMonacoComponent', () => {
const text = 'test';
const textChangeSpy = jest.spyOn(comp.markdownChange, 'emit');
fixture.detectChanges();
comp.onTextChanged(text);
comp.onTextChanged({ text: text, fileName: 'test-file.md' });
expect(textChangeSpy).toHaveBeenCalledWith(text);
expect(comp._markdown).toBe(text);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,9 +445,9 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie
this.resizeObserver?.disconnect();
}

onTextChanged(text: string): void {
this.markdown = text;
this.markdownChange.emit(text);
onTextChanged(event: { text: string; fileName: string }): void {
this.markdown = event.text;
this.markdownChange.emit(event.text);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('MonacoEditorComponent', () => {
fixture.detectChanges();
comp.textChanged.subscribe(valueCallbackStub);
comp.setText(singleLineText);
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith(singleLineText);
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ text: singleLineText, fileName: expect.any(String) });
});

it('should only send a notification once per delay interval', fakeAsync(() => {
Expand All @@ -68,7 +68,7 @@ describe('MonacoEditorComponent', () => {
tick(1);
comp.setText(singleLineText);
tick(delay);
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith(singleLineText);
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ text: singleLineText, fileName: expect.any(String) });
}));

it('should be set to readOnly depending on the input', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
stickyScroll = input<boolean>(false);
readOnly = input<boolean>(false);

textChanged = output<string>();
textChanged = output<{ text: string; fileName: string }>();
contentHeightChanged = output<number>();
onBlurEditor = output<void>();

Expand All @@ -66,7 +66,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
private contentHeightListener?: Disposable;
private textChangedListener?: Disposable;
private blurEditorWidgetListener?: Disposable;
private textChangedEmitTimeout?: NodeJS.Timeout;
private textChangedEmitTimeouts = new Map<string, NodeJS.Timeout>();
private customBackspaceCommandId: string | undefined;

/*
Expand Down Expand Up @@ -177,22 +177,46 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
this.textChangedListener?.dispose();
this.contentHeightListener?.dispose();
this.blurEditorWidgetListener?.dispose();

// Clean up all per-model debounce timeouts
this.textChangedEmitTimeouts.forEach((timeout) => clearTimeout(timeout));
this.textChangedEmitTimeouts.clear();
}

private emitTextChangeEvent() {
const newValue = this.getText();
const delay = this.textChangedEmitDelay();
const model = this.getModel();
const fullFilePath = this.extractFilePathFromModel(model);

if (!delay) {
this.textChanged.emit(newValue);
} else {
if (this.textChangedEmitTimeout) {
clearTimeout(this.textChangedEmitTimeout);
this.textChangedEmitTimeout = undefined;
}
this.textChangedEmitTimeout = setTimeout(() => {
this.textChanged.emit(newValue);
}, delay);
this.textChanged.emit({ text: newValue, fileName: fullFilePath });
return;
}
const modelKey = model?.uri?.toString() ?? '';
const existing = this.textChangedEmitTimeouts.get(modelKey);
if (existing) {
clearTimeout(existing);
}
const timeoutId = setTimeout(() => {
this.textChanged.emit({ text: newValue, fileName: fullFilePath });
this.textChangedEmitTimeouts.delete(modelKey);
}, delay);
this.textChangedEmitTimeouts.set(modelKey, timeoutId);
}

private extractFilePathFromModel(model: monaco.editor.ITextModel | null): string {
const path = model?.uri?.path ?? '';
if (!path) {
return '';
}
// Path format: /model/<editorId>/<full/file/path>
const parts = path.split('/').filter(Boolean);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment that the boolean filter filters empty strings

if (parts.length >= 3 && parts[0] === 'model') {
return parts.slice(2).join('/');
}
// Fallback: best effort
return parts.slice(1).join('/') || parts[parts.length - 1] || '';
}

getPosition(): EditorPosition {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ describe('CodeEditorContainerIntegration', () => {
await loadFile(selectedFile, fileContent);

containerFixture.detectChanges();
container.monacoEditor.onFileTextChanged(newFileContent);
container.monacoEditor.onFileTextChanged({ text: newFileContent, fileName: selectedFile });
containerFixture.detectChanges();

expect(getFileStub).toHaveBeenCalledOnce();
Expand Down
Loading