Skip to content

Commit 54d40a7

Browse files
authored
Programming exercises: Prevent online code editor from overwriting other files during build/refresh (#11418)
1 parent 3133cd6 commit 54d40a7

File tree

11 files changed

+68
-34
lines changed

11 files changed

+68
-34
lines changed

src/main/webapp/app/programming/manage/code-editor/container/code-editor-container.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ export class CodeEditorContainerComponent implements OnChanges, ComponentCanDeac
246246
/**
247247
* When the content of a file changes, set it as unsaved.
248248
*/
249-
onFileContentChange({ file, fileContent }: { file: string; fileContent: string }) {
250-
this.unsavedFiles = { ...this.unsavedFiles, [file]: fileContent };
249+
onFileContentChange({ fileName, text }: { fileName: string; text: string }) {
250+
this.unsavedFiles = { ...this.unsavedFiles, [fileName]: text };
251251
this.onFileChanged.emit();
252252
}
253253

src/main/webapp/app/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-aeolus-build-plan.component.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => {
134134
it('should change code of active action', () => {
135135
comp.changeActiveAction('gradle');
136136
expect(comp.code).toBe(gradleBuildAction.script);
137-
comp.codeChanged('test');
137+
comp.codeChanged({ text: 'test', fileName: 'build-script.sh' });
138138
expect(gradleBuildAction.script).toBe('test');
139139
});
140140

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

167167
it('should change code', () => {
168168
comp.changeActiveAction('gradle');
169-
comp.codeChanged('this is some code');
169+
comp.codeChanged({ text: 'this is some code', fileName: 'build-script.sh' });
170170
const action: BuildAction | undefined = comp.active;
171171
expect(action).toBeDefined();
172172
expect(action).toBeInstanceOf(ScriptAction);

src/main/webapp/app/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-aeolus-build-plan.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,9 @@ export class ProgrammingExerciseCustomAeolusBuildPlanComponent implements OnChan
172172
}
173173
}
174174

175-
codeChanged(code: string): void {
175+
codeChanged(event: { text: string; fileName: string }): void {
176176
if (this.active instanceof ScriptAction) {
177-
(this.active as ScriptAction).script = code;
177+
(this.active as ScriptAction).script = event.text;
178178
}
179179
}
180180

src/main/webapp/app/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-build-plan.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ export class ProgrammingExerciseCustomBuildPlanComponent implements OnChanges {
128128

129129
faQuestionCircle = faQuestionCircle;
130130

131-
codeChanged(code: string): void {
131+
codeChanged(codeOrEvent: string | { text: string; fileName: string }): void {
132+
const code = typeof codeOrEvent === 'string' ? codeOrEvent : codeOrEvent.text;
132133
this.code = code;
133134
this.editor?.setText(code);
134135
this.programmingExercise.buildConfig!.buildScript = code;

src/main/webapp/app/programming/shared/code-editor/monaco/code-editor-monaco.component.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ describe('CodeEditorMonacoComponent', () => {
139139
fixture.detectChanges();
140140
comp.fileSession.set(fileSession);
141141
fixture.componentRef.setInput('selectedFile', selectedFile);
142-
comp.onFileTextChanged(newCode);
143-
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ file: selectedFile, fileContent: newCode });
142+
comp.onFileTextChanged({ text: newCode, fileName: selectedFile });
143+
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ fileName: selectedFile, text: newCode });
144144
expect(comp.fileSession()).toEqual({
145145
[selectedFile]: { ...fileSession[selectedFile], code: newCode, scrollTop: 0 },
146146
});

src/main/webapp/app/programming/shared/code-editor/monaco/code-editor-monaco.component.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export class CodeEditorMonacoComponent implements OnChanges {
8282
readonly buildAnnotations = input<Annotation[]>([]);
8383

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

216-
onFileTextChanged(text: string): void {
217-
if (this.selectedFile() && this.fileSession()[this.selectedFile()!]) {
218-
const previousText = this.fileSession()[this.selectedFile()!].code;
219-
const previousScrollTop = this.fileSession()[this.selectedFile()!].scrollTop;
216+
onFileTextChanged(event: { text: string; fileName: string }): void {
217+
const { text, fileName } = event;
218+
// Apply text change to the specific file it belongs to, not the currently selected file
219+
if (fileName && this.fileSession()[fileName]) {
220+
const previousText = this.fileSession()[fileName].code;
221+
const previousScrollTop = this.fileSession()[fileName].scrollTop;
222+
220223
if (previousText !== text) {
221224
this.fileSession.set({
222225
...this.fileSession(),
223-
[this.selectedFile()!]: { code: text, loadingError: false, scrollTop: previousScrollTop, cursor: this.editor().getPosition() },
226+
[fileName]: {
227+
code: text,
228+
loadingError: false,
229+
scrollTop: previousScrollTop,
230+
cursor: fileName === this.selectedFile() ? this.editor().getPosition() : this.fileSession()[fileName].cursor,
231+
},
224232
});
225-
this.onFileContentChange.emit({ file: this.selectedFile()!, fileContent: text });
233+
234+
this.onFileContentChange.emit({ fileName, text });
226235
}
227236
}
228237
}

src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe('MarkdownEditorMonacoComponent', () => {
7979
const text = 'test';
8080
const textChangeSpy = jest.spyOn(comp.markdownChange, 'emit');
8181
fixture.detectChanges();
82-
comp.onTextChanged(text);
82+
comp.onTextChanged({ text: text, fileName: 'test-file.md' });
8383
expect(textChangeSpy).toHaveBeenCalledWith(text);
8484
expect(comp._markdown).toBe(text);
8585
});

src/main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -445,9 +445,9 @@ export class MarkdownEditorMonacoComponent implements AfterContentInit, AfterVie
445445
this.resizeObserver?.disconnect();
446446
}
447447

448-
onTextChanged(text: string): void {
449-
this.markdown = text;
450-
this.markdownChange.emit(text);
448+
onTextChanged(event: { text: string; fileName: string }): void {
449+
this.markdown = event.text;
450+
this.markdownChange.emit(event.text);
451451
}
452452

453453
/**

src/main/webapp/app/shared/monaco-editor/monaco-editor.component.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe('MonacoEditorComponent', () => {
5555
fixture.detectChanges();
5656
comp.textChanged.subscribe(valueCallbackStub);
5757
comp.setText(singleLineText);
58-
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith(singleLineText);
58+
expect(valueCallbackStub).toHaveBeenCalledExactlyOnceWith({ text: singleLineText, fileName: expect.any(String) });
5959
});
6060

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

7474
it('should be set to readOnly depending on the input', () => {

src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
5656
stickyScroll = input<boolean>(false);
5757
readOnly = input<boolean>(false);
5858

59-
textChanged = output<string>();
59+
textChanged = output<{ text: string; fileName: string }>();
6060
contentHeightChanged = output<number>();
6161
onBlurEditor = output<void>();
6262

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

7272
/*
@@ -177,22 +177,46 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
177177
this.textChangedListener?.dispose();
178178
this.contentHeightListener?.dispose();
179179
this.blurEditorWidgetListener?.dispose();
180+
181+
// Clean up all per-model debounce timeouts
182+
this.textChangedEmitTimeouts.forEach((timeout) => clearTimeout(timeout));
183+
this.textChangedEmitTimeouts.clear();
180184
}
181185

182186
private emitTextChangeEvent() {
183187
const newValue = this.getText();
184188
const delay = this.textChangedEmitDelay();
189+
const model = this.getModel();
190+
const fullFilePath = this.extractFilePathFromModel(model);
191+
185192
if (!delay) {
186-
this.textChanged.emit(newValue);
187-
} else {
188-
if (this.textChangedEmitTimeout) {
189-
clearTimeout(this.textChangedEmitTimeout);
190-
this.textChangedEmitTimeout = undefined;
191-
}
192-
this.textChangedEmitTimeout = setTimeout(() => {
193-
this.textChanged.emit(newValue);
194-
}, delay);
193+
this.textChanged.emit({ text: newValue, fileName: fullFilePath });
194+
return;
195+
}
196+
const modelKey = model?.uri?.toString() ?? '';
197+
const existing = this.textChangedEmitTimeouts.get(modelKey);
198+
if (existing) {
199+
clearTimeout(existing);
200+
}
201+
const timeoutId = setTimeout(() => {
202+
this.textChanged.emit({ text: newValue, fileName: fullFilePath });
203+
this.textChangedEmitTimeouts.delete(modelKey);
204+
}, delay);
205+
this.textChangedEmitTimeouts.set(modelKey, timeoutId);
206+
}
207+
208+
private extractFilePathFromModel(model: monaco.editor.ITextModel | null): string {
209+
const path = model?.uri?.path ?? '';
210+
if (!path) {
211+
return '';
212+
}
213+
// Path format: /model/<editorId>/<full/file/path>
214+
const parts = path.split('/').filter(Boolean);
215+
if (parts.length >= 3 && parts[0] === 'model') {
216+
return parts.slice(2).join('/');
195217
}
218+
// Fallback: best effort
219+
return parts.slice(1).join('/') || parts[parts.length - 1] || '';
196220
}
197221

198222
getPosition(): EditorPosition {

0 commit comments

Comments
 (0)