
Previously, we were at an ACE editor published between 1.1.8 and 1.1.9. This caused multiple issues and was especially a problem for the upcoming pair programming feature. Further, updating ace is a long-time priority, see https://github.com/openHPI/codeocean/issues/250. Now, we are not yet updating to the latest version, but rather to the next minor version. This already contains breaking changes, and we are currently interested to keep the number of changes as low as possible. Further updating ACE might be still a future task. The new ACE version 1.2.0 is taken from this tag: https://github.com/ajaxorg/ace-builds/releases/tag/v1.2.0. We are using the src build (not minified, not in the noconflict version), since the same was used before as well. Further, we need to change our migration for storing editor events. Since the table is not yet used (in production), we also update the enum.
1118 lines
44 KiB
Plaintext
1118 lines
44 KiB
Plaintext
var CodeOceanEditor = {
|
|
//ACE-Editor-Path
|
|
// ruby part adds the relative_url_root, if it is set.
|
|
ACE_FILES_PATH: '<%= "#{Rails.application.config.relative_url_root.chomp('/')}/assets/ace/" %>',
|
|
THEME: window.getCurrentTheme() === 'dark' ? 'ace/theme/tomorrow_night' : 'ace/theme/tomorrow',
|
|
|
|
//Color-Encoding for Percentages in Progress Bars (For submissions)
|
|
ADEQUATE_PERCENTAGE: 50,
|
|
SUCCESSFULL_PERCENTAGE: 90,
|
|
|
|
//Key-Codes (for Hotkeys)
|
|
R_KEY_CODE: 82,
|
|
S_KEY_CODE: 83,
|
|
T_KEY_CODE: 84,
|
|
ENTER_KEY_CODE: 13,
|
|
|
|
//Request-For-Comments-Configuration
|
|
REQUEST_FOR_COMMENTS_DELAY: 0,
|
|
REQUEST_TOOLTIP_TIME: 5000,
|
|
REQUEST_TOOLTIP_DELAY: 15 * 60 * 1000,
|
|
|
|
editors: [],
|
|
editor_for_file: new Map(),
|
|
regex_for_language: new Map(),
|
|
tracepositions_regex: undefined,
|
|
|
|
active_file: undefined,
|
|
active_frame: undefined,
|
|
running: false,
|
|
|
|
lastCopyText: null,
|
|
lastDeltaObject_for_file: new Map(),
|
|
|
|
<% self.class.include Rails.application.routes.url_helpers %>
|
|
<% @config ||= CodeOcean::Config.new(:code_ocean).read(erb: false) %>
|
|
// Important notice: Changing the config values requires any content-wise
|
|
// modification for this file in the development environment. Lacking to do so
|
|
// will result in the old, server-side cached serving of this file even across
|
|
// server restarts!
|
|
sendEvents: <%= @config['codeocean_events'] ? @config['codeocean_events']['enabled'] : false %>,
|
|
eventURL: "<%= @config['codeocean_events'] ? events_path : '' %>",
|
|
fileTypeURL: "<%= file_types_path %>",
|
|
|
|
|
|
configureEditors: function () {
|
|
_.each(['modePath', 'themePath', 'workerPath'], function (attribute) {
|
|
ace.config.set(attribute, this.ACE_FILES_PATH);
|
|
}.bind(this));
|
|
},
|
|
|
|
confirmDestroy: function (event) {
|
|
event.preventDefault();
|
|
if (confirm($(event.target).data('message-confirm'))) {
|
|
this.destroyFile();
|
|
}
|
|
},
|
|
|
|
confirmReset: function (event) {
|
|
event.preventDefault();
|
|
const initiator = $(event.target.closest("button"));
|
|
if (confirm(initiator.data('message-confirm'))) {
|
|
this.resetCode(initiator);
|
|
}
|
|
},
|
|
|
|
confirmResetActiveFile: function (event) {
|
|
event.preventDefault();
|
|
const initiator = $(event.target.closest("button"));
|
|
let message = initiator.data('message-confirm');
|
|
message = message.replace('%{filename}', CodeOceanEditor.active_file.filename.replace(/#$/, ''))
|
|
if (confirm(message)) {
|
|
this.resetCode(initiator, true); // delete only active file
|
|
}
|
|
},
|
|
|
|
fileActionsAvailable: function () {
|
|
return this.isActiveFileRenderable() || this.isActiveFileRunnable() || this.isActiveFileStoppable() || this.isActiveFileTestable();
|
|
},
|
|
|
|
findOrCreateOutputElement: function (index) {
|
|
if ($('#output-' + index).isPresent()) {
|
|
return $('#output-' + index);
|
|
} else {
|
|
var element = $('<div class="mb-2 output-element">').attr('id', 'output-' + index);
|
|
$('#output').append(element);
|
|
return element;
|
|
}
|
|
},
|
|
|
|
getCardClass: function (result) {
|
|
if (result.file_role === 'teacher_defined_linter') {
|
|
return 'info'
|
|
} else if (result.stderr && !result.score) {
|
|
return 'danger';
|
|
} else if (result.score < 1) {
|
|
return 'warning';
|
|
} else {
|
|
return 'success';
|
|
}
|
|
},
|
|
|
|
showOutput: function (event) {
|
|
const target = $(event.target).attr('href');
|
|
if (target !== "#") {
|
|
event.preventDefault();
|
|
this.showOutputBar();
|
|
$('body').scrollTo($(event.target).attr('href'));
|
|
}
|
|
},
|
|
|
|
renderProgressBar: function (score, maximum_score) {
|
|
var percentage = score / maximum_score * 100;
|
|
var progress_bar = $('#score .progress-bar');
|
|
progress_bar.removeClass().addClass(this.getProgressBarClass(percentage));
|
|
progress_bar.attr({
|
|
'aria-valuemax': maximum_score,
|
|
'aria-valuemin': 0,
|
|
'aria-valuenow': score
|
|
});
|
|
progress_bar.css('width', percentage + '%');
|
|
},
|
|
|
|
// The event ready.jstree is fired too early and thus doesn't work.
|
|
selectFileInJsTree: function (filetree, file_id) {
|
|
if (!filetree.is(':visible'))
|
|
// The left sidebar is not shown and thus the filetree is not rendered.
|
|
return;
|
|
|
|
if (!filetree.hasClass('jstree-loading')) {
|
|
filetree.jstree("deselect_all");
|
|
filetree.jstree(true).select_node(file_id);
|
|
} else {
|
|
setTimeout(CodeOceanEditor.selectFileInJsTree.bind(null, filetree, file_id), 250);
|
|
}
|
|
},
|
|
|
|
showFirstFile: function (own_solution = false) {
|
|
let frame;
|
|
let filetree;
|
|
let editorSelector;
|
|
if (own_solution) {
|
|
frame = $('.own-frame[data-role="main_file"]').isPresent() ? $('.own-frame[data-role="main_file"]') : $('.own-frame').first();
|
|
filetree = $('#own-files');
|
|
editorSelector = '.own-editor';
|
|
} else {
|
|
frame = $('.frame[data-role="main_file"]').isPresent() ? $('.frame[data-role="main_file"]') : $('.frame').first();
|
|
filetree = $('#files');
|
|
editorSelector = '.editor';
|
|
}
|
|
|
|
var file_id = frame.find(editorSelector).data('file-id');
|
|
this.setActiveFile(frame.data('filename'), file_id);
|
|
this.selectFileInJsTree(filetree, file_id);
|
|
this.showFrame(frame);
|
|
this.toggleButtonStates();
|
|
},
|
|
|
|
showFrame: function (frame) {
|
|
if (frame.hasClass('own-frame')) {
|
|
$('.own-frame').hide();
|
|
} else {
|
|
$('.frame').hide();
|
|
}
|
|
|
|
this.active_frame = frame;
|
|
frame.show();
|
|
this.resizeParentOfAceEditor(frame.find('.ace_editor'));
|
|
},
|
|
|
|
getProgressBarClass: function (percentage) {
|
|
if (percentage < this.ADEQUATE_PERCENTAGE) {
|
|
return 'progress-bar progress-bar-striped bg-danger';
|
|
} else if (percentage < this.SUCCESSFULL_PERCENTAGE) {
|
|
return 'progress-bar progress-bar-striped bg-warning';
|
|
} else {
|
|
return 'progress-bar progress-bar-striped bg-success';
|
|
}
|
|
},
|
|
|
|
handleKeyPress: function (event) {
|
|
if (event.altKey && event.which === this.R_KEY_CODE) {
|
|
$('#run').trigger('click');
|
|
} else if (event.altKey && event.which === this.S_KEY_CODE) {
|
|
$('#assess').trigger('click');
|
|
} else if (event.altKey && event.which === this.T_KEY_CODE) {
|
|
$('#test').trigger('click');
|
|
} else {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
},
|
|
|
|
handleCopyEvent: function (text) {
|
|
CodeOceanEditor.lastCopyText = text;
|
|
},
|
|
|
|
handlePasteEvent: function (pasteObject, event) {
|
|
var same = (CodeOceanEditor.lastCopyText === pasteObject.text);
|
|
|
|
// if the text is not copied from within the editor (from any file), send an event to the backend
|
|
if (!same) {
|
|
CodeOceanEditor.publishCodeOceanEvent({
|
|
category: 'editor_paste',
|
|
data: pasteObject.text,
|
|
exercise_id: $('#editor').data('exercise-id'),
|
|
file_id: $(event.container).data('file-id')
|
|
});
|
|
}
|
|
},
|
|
|
|
hideSpinner: function () {
|
|
$('button i.fa-solid, button i.fa-regular').show();
|
|
$('button i.fa-spin').removeClass('d-inline-block').addClass('d-none');
|
|
},
|
|
|
|
startSentryTransaction: function (initiator) {
|
|
const cause = initiator.data('cause') || initiator.prop('id');
|
|
this.sentryTransaction = window.SentryUtils.startIdleTransaction(
|
|
Sentry.getCurrentHub(),
|
|
{ name: cause, op: "transaction" },
|
|
0, // Idle Timeout
|
|
window.SentryUtils.TRACING_DEFAULTS.finalTimeout,
|
|
true); // onContext
|
|
Sentry.getCurrentHub().configureScope(scope => scope.setSpan(this.sentryTransaction));
|
|
},
|
|
|
|
resizeAceEditors: function (own_solution = false) {
|
|
let editorSelector;
|
|
if (own_solution) {
|
|
editorSelector = $('.own-editor')
|
|
} else {
|
|
editorSelector = $('.editor')
|
|
}
|
|
|
|
editorSelector.each(function (index, element) {
|
|
this.resizeParentOfAceEditor(element);
|
|
}.bind(this));
|
|
window.dispatchEvent(new Event('resize'));
|
|
},
|
|
|
|
resizeSidebars: function () {
|
|
$('#content-left-sidebar').height(this.calculateEditorHeight('#content-left-sidebar', false));
|
|
$('#content-right-sidebar').height(this.calculateEditorHeight('#content-right-sidebar', false));
|
|
},
|
|
|
|
calculateEditorHeight: function (element, considerStatusbar) {
|
|
const jqueryElement = $(element);
|
|
if (jqueryElement.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const bottom = considerStatusbar ? ($('#statusbar').height() || 0) : 0;
|
|
// calculate needed size: window height - position of top of ACE editor - height of autosave label below editor - 5 for bar margins
|
|
return window.innerHeight - jqueryElement.offset().top - bottom - 5;
|
|
},
|
|
|
|
resizeParentOfAceEditor: function (element) {
|
|
const editorHeight = this.calculateEditorHeight(element, true);
|
|
$(element).parent().height(editorHeight);
|
|
},
|
|
|
|
initializeEditors: function (own_solution = false) {
|
|
// Initialize the editors array if not present already. This is mainly required for community solutions
|
|
this.editors = this.editors || [];
|
|
let editorSelector;
|
|
if (own_solution) {
|
|
editorSelector = $('.own-editor')
|
|
} else {
|
|
editorSelector = $('.editor')
|
|
}
|
|
|
|
editorSelector.each(function (index, element) {
|
|
|
|
// Resize frame on load
|
|
this.resizeParentOfAceEditor(element);
|
|
|
|
// Resize frame on window size change
|
|
$(window).resize(function () {
|
|
this.resizeParentOfAceEditor(element);
|
|
this.resizeSidebars();
|
|
}.bind(this));
|
|
|
|
var editor = ace.edit(element);
|
|
|
|
var document = editor.getSession().getDocument();
|
|
// insert pre-existing code into editor. we have to use insertLines, otherwise the deltas are not properly added
|
|
var file_id = $(element).data('file-id');
|
|
var content = $('.editor-content[data-file-id=' + file_id + ']');
|
|
this.setActiveFile($(element).parent().data('filename'), file_id);
|
|
|
|
document.insertLines(0, content.text().split(/\n/));
|
|
// remove last (empty) that is there by default line
|
|
document.removeLines(document.getLength() - 1, document.getLength() - 1);
|
|
editor.setReadOnly($(element).parent().data('read-only') !== undefined);
|
|
if (editor.getReadOnly()) {
|
|
editor.setHighlightActiveLine(false);
|
|
editor.setHighlightGutterLine(false);
|
|
editor.renderer.$cursorLayer.element.style.opacity = 0;
|
|
}
|
|
editor.setShowPrintMargin(false);
|
|
editor.setTheme(this.THEME);
|
|
|
|
|
|
// set options for autocompletion
|
|
if ($(element).data('allow-auto-completion')) {
|
|
editor.setOptions({
|
|
enableBasicAutocompletion: true,
|
|
enableSnippets: false,
|
|
enableLiveAutocompletion: true
|
|
});
|
|
}
|
|
|
|
editor.commands.bindKey("ctrl+alt+0", null);
|
|
this.editors.push(editor);
|
|
this.editor_for_file.set($(element).parent().data('filename'), editor);
|
|
this.lastDeltaObject_for_file.set($(element).parent().data('filename'), null);
|
|
var session = editor.getSession();
|
|
var mode = $(element).data('mode')
|
|
session.setMode(mode);
|
|
session.setTabSize($(element).data('indent-size'));
|
|
session.setUseSoftTabs(true);
|
|
session.setUseWrapMode(true);
|
|
|
|
// set regex for parsing error traces based on the mode of the main file.
|
|
if ($(element).parent().data('role') === "main_file") {
|
|
this.tracepositions_regex = this.regex_for_language.get($(element).data('mode'));
|
|
}
|
|
|
|
/*
|
|
* Register event handlers
|
|
*/
|
|
|
|
// editor itself
|
|
editor.on("paste", this.handlePasteEvent.bind(element));
|
|
editor.on("copy", this.handleCopyEvent.bind(element));
|
|
|
|
// listener for autosave
|
|
session.on("change", function (deltaObject, session) {
|
|
if (this.compareDeltaObjects(deltaObject, this.lastDeltaObject_for_file.get(this.active_file.filename))) {
|
|
this.lastDeltaObject_for_file.set(this.active_file.filename, null);
|
|
return;
|
|
}
|
|
App.synchronized_editor?.editor_change(deltaObject, this.active_file);
|
|
|
|
// TODO: This is a workaround for a bug in Ace. Remove when upgrading Ace.
|
|
this.handleUTF16Surrogates(deltaObject, session);
|
|
this.resetSaveTimer();
|
|
}.bind(this));
|
|
}.bind(this));
|
|
},
|
|
|
|
handleAceThemeChangeEvent: function (event) {
|
|
this.editors.forEach(function (editor) {
|
|
editor.setTheme(this.THEME);
|
|
}.bind(this));
|
|
},
|
|
|
|
handleUTF16Surrogates: function (AceDeltaObject, AceSession) {
|
|
if (_.isEmpty(AceDeltaObject.lines) || AceDeltaObject.action !== "remove") {
|
|
return;
|
|
}
|
|
|
|
const firstCodePoint = AceDeltaObject.lines[0].codePointAt(0);
|
|
if (0xDC00 <= firstCodePoint && firstCodePoint <= 0xDFFF) {
|
|
// The text contains a UTF-16 surrogate pair, and the only the lower part is removed.
|
|
// We need to remove the high surrogate pair as well.
|
|
const previousCharacter = {
|
|
start: {
|
|
row: AceDeltaObject.start.row,
|
|
column: AceDeltaObject.start.column - 1
|
|
},
|
|
end: {
|
|
row: AceDeltaObject.start.row,
|
|
column: AceDeltaObject.start.column
|
|
}
|
|
}
|
|
const previousCharacterCodePoint = AceSession.getTextRange(previousCharacter).codePointAt(0);
|
|
if (0xD800 <= previousCharacterCodePoint && previousCharacterCodePoint <= 0xDBFF) {
|
|
AceSession.remove(previousCharacter);
|
|
}
|
|
}
|
|
|
|
const lastLine = AceDeltaObject.lines.slice(-1)[0];
|
|
const lastCodePoint = lastLine.codePointAt(lastLine.length - 1);
|
|
if (0xD800 <= lastCodePoint && lastCodePoint <= 0xDBFF) {
|
|
// The text contains a UTF-16 surrogate pair, and the only the higher part is removed.
|
|
// We need to remove the high surrogate pair as well.
|
|
const nextCharacter = {
|
|
start: {
|
|
row: AceDeltaObject.end.row,
|
|
column: AceDeltaObject.end.column - 1
|
|
},
|
|
end: {
|
|
row: AceDeltaObject.end.row,
|
|
column: AceDeltaObject.end.column
|
|
}
|
|
}
|
|
const nextCharacterCodePoint = AceSession.getTextRange(nextCharacter).codePointAt(0);
|
|
if (0xDC00 <= nextCharacterCodePoint && nextCharacterCodePoint <= 0xDFFF) {
|
|
AceSession.remove(nextCharacter);
|
|
}
|
|
}
|
|
},
|
|
|
|
initializeEventHandlers: function () {
|
|
$(document).on('click', '#results a', this.showOutput.bind(this));
|
|
$(document).on('keydown', this.handleKeyPress.bind(this));
|
|
$(document).on('theme:change:ace', this.handleAceThemeChangeEvent.bind(this));
|
|
$('#start_chat').on('click', function(event) {
|
|
this.createEventHandler('pp_start_chat', null)(event)
|
|
// Allow to open the new tab even in Safari.
|
|
// See: https://stackoverflow.com/a/70463940
|
|
setTimeout(() => {
|
|
var pop_up_window = window.open($('#start_chat').data('url'), '_blank');
|
|
if (pop_up_window) {
|
|
pop_up_window.onerror = function (message) {
|
|
$.flash.danger({text: message});
|
|
this.sendError(message, null);
|
|
};
|
|
}
|
|
})
|
|
}.bind(this));
|
|
this.initializeFileTreeButtons();
|
|
this.initializeWorkspaceButtons();
|
|
this.initializeRequestForComments()
|
|
},
|
|
|
|
teardownEventHandlers: function () {
|
|
$(document).unbind('click');
|
|
$(document).unbind('keydown');
|
|
this.teardownWorkspaceButtons();
|
|
this.teardownRequestForComments();
|
|
const rfcModal = $('#comment-modal');
|
|
if (rfcModal.isPresent()) {
|
|
bootstrap.Modal.getInstance(rfcModal)?.hide();
|
|
}
|
|
this.teardownFileTreeButtons();
|
|
},
|
|
|
|
updateEditorModeToFileTypeID: function (editor, fileTypeID) {
|
|
var newMode = 'ace/mode/text'
|
|
|
|
$.ajax(this.fileTypeURL + '/' + fileTypeID, {
|
|
dataType: 'json'
|
|
}).done(function (data) {
|
|
if (data['editor_mode'] !== null) {
|
|
newMode = data['editor_mode'];
|
|
}
|
|
}).fail(_.noop)
|
|
.always(function () {
|
|
ace.edit(editor).session.setMode(newMode);
|
|
});
|
|
},
|
|
|
|
initializeFileTree: function (own_solution = false) {
|
|
let filesInstance;
|
|
if (own_solution) {
|
|
filesInstance = $('#own-files');
|
|
} else {
|
|
filesInstance = $('#files');
|
|
}
|
|
const jsTreeConfig = filesInstance.data('entries') || {core: {}};
|
|
jsTreeConfig.core.themes = {...jsTreeConfig.core.themes, name: window.getCurrentTheme() === "dark" ? "default-dark" : "default"}
|
|
filesInstance.jstree(jsTreeConfig);
|
|
filesInstance.on('click', 'li.jstree-leaf > a', function (event) {
|
|
const file_id = parseInt($(event.target).parent().attr('id'));
|
|
const frame = $('[data-file-id="' + file_id + '"]').parent();
|
|
this.setActiveFile(frame.data('filename'), file_id);
|
|
this.showFrame(frame);
|
|
this.toggleButtonStates();
|
|
}.bind(this));
|
|
$(document).on('theme:change', function(event) {
|
|
const jsTree = filesInstance?.jstree(true);
|
|
|
|
if (jsTree) {
|
|
const newColorScheme = event.detail.currentTheme;
|
|
// Update the JStree theme
|
|
jsTree?.set_theme(newColorScheme === "dark" ? "default-dark" : "default");
|
|
}
|
|
});
|
|
},
|
|
|
|
initializeFileTreeButtons: function () {
|
|
$('#create-file').on('click', this.showFileDialog.bind(this));
|
|
$('#destroy-file').on('click', this.confirmDestroy.bind(this));
|
|
$('#destroy-file-collapsed').on('click', this.confirmDestroy.bind(this));
|
|
$('#download').on('click', this.downloadCode.bind(this));
|
|
},
|
|
|
|
teardownFileTreeButtons: function () {
|
|
$('#create-file').unbind('click');
|
|
$('#destroy-file').unbind('click');
|
|
$('#destroy-file-collapsed').unbind('click');
|
|
$('#download').unbind('click');
|
|
},
|
|
|
|
initializeSideBarCollapse: function () {
|
|
$('#sidebar-collapse-collapsed').on('click', this.handleSideBarToggle.bind(this));
|
|
$('#sidebar-collapse').on('click', this.handleSideBarToggle.bind(this));
|
|
const tipButton = $('#tips-collapsed');
|
|
if (tipButton) {
|
|
tipButton.on('click', this.handleSideBarToggle.bind(this));
|
|
}
|
|
$('#sidebar').on('transitionend', this.resizeAceEditors.bind(this));
|
|
$('#sidebar').on('transitionend', this.resizeSidebars.bind(this));
|
|
},
|
|
|
|
handleSideBarToggle: function () {
|
|
const sidebar = $('#sidebar');
|
|
sidebar.toggleClass('sidebar-col').toggleClass('sidebar-col-collapsed');
|
|
if (sidebar.hasClass('w-25') || sidebar.hasClass('restore-to-w-25')) {
|
|
sidebar.toggleClass('w-25').toggleClass('restore-to-w-25');
|
|
}
|
|
$('#sidebar-collapsed').toggleClass('d-none');
|
|
$('#sidebar-uncollapsed').toggleClass('d-none');
|
|
},
|
|
|
|
initializeRegexes: function () {
|
|
// These RegEx are run on the HTML escaped output!
|
|
this.regex_for_language.set("ace/mode/python", /File "(.+?)", line (\d+)/g);
|
|
this.regex_for_language.set("ace/mode/java", /(?:\.\/)?(.*\.java):(\d+):/g);
|
|
},
|
|
|
|
initializeWorkspaceButtons: function () {
|
|
$('#submit').one('click', this.submitCode.bind(this));
|
|
$('#assess').on('click', this.scoreCode.bind(this));
|
|
$('#render').on('click', this.renderCode.bind(this));
|
|
$('#run').on('click', this.runCode.bind(this));
|
|
$('#stop').on('click', this.stopCode.bind(this));
|
|
$('#test').on('click', this.testCode.bind(this));
|
|
$('#start-over').on('click', this.confirmReset.bind(this));
|
|
$('#start-over-active-file').on('click', this.confirmResetActiveFile.bind(this));
|
|
},
|
|
|
|
teardownWorkspaceButtons: function () {
|
|
$('#submit').unbind('click');
|
|
$('#assess').unbind('click');
|
|
$('#render').unbind('click');
|
|
$('#run').unbind('click');
|
|
$('#stop').unbind('click');
|
|
$('#test').unbind('click');
|
|
$('#start-over').unbind('click');
|
|
$('#start-over-active-file').unbind('click');
|
|
},
|
|
|
|
initializeRequestForComments: function () {
|
|
var button = $('#requestComments');
|
|
button.prop('disabled', true);
|
|
button.on('click', function () {
|
|
button.tooltip('hide');
|
|
$('#rfc_intervention_text').hide()
|
|
new bootstrap.Modal($('#comment-modal')).show();
|
|
});
|
|
|
|
$('#askForCommentsButton').one('click', this.requestComments.bind(this));
|
|
$('#closeAskForCommentsButton').on('click', function () {
|
|
bootstrap.Modal.getInstance($('#comment-modal')).hide();
|
|
});
|
|
|
|
setTimeout(function () {
|
|
button.prop('disabled', false);
|
|
setTimeout(function () {
|
|
button.tooltip('show');
|
|
setTimeout(function () {
|
|
button.tooltip('hide');
|
|
}, this.REQUEST_TOOLTIP_TIME);
|
|
}, this.REQUEST_TOOLTIP_DELAY)
|
|
}.bind(this), this.REQUEST_FOR_COMMENTS_DELAY);
|
|
},
|
|
|
|
teardownRequestForComments: function () {
|
|
$('#requestComments').unbind('click');
|
|
$('#askForCommentsButton').unbind('click');
|
|
$('#closeAskForCommentsButton').unbind('click');
|
|
},
|
|
|
|
isActiveFileRenderable: function () {
|
|
if (this.active_frame.data() === undefined) {
|
|
return false;
|
|
}
|
|
return 'renderable' in this.active_frame.data();
|
|
},
|
|
|
|
isActiveFileRunnable: function () {
|
|
return this.isActiveFileExecutable() && ['main_file', 'user_defined_file', 'executable_file'].includes(this.active_frame.data('role'));
|
|
},
|
|
|
|
isActiveFileStoppable: function () {
|
|
return this.isActiveFileRunnable() && this.running;
|
|
},
|
|
|
|
isActiveFileSubmission: function () {
|
|
return ['Submission'].includes(this.active_frame.data('contextType'));
|
|
},
|
|
|
|
isActiveFileTestable: function () {
|
|
return this.isActiveFileExecutable() && ['teacher_defined_test', 'user_defined_test', 'teacher_defined_linter'].includes(this.active_frame.data('role'));
|
|
},
|
|
|
|
populateCard: function (card, result, index) {
|
|
card.addClass(`card border-${this.getCardClass(result)}`);
|
|
card.find('.card-header').addClass(`bg-${this.getCardClass(result)} text-white`);
|
|
card.find('.card-title .filename').text(result.filename);
|
|
card.find('.card-title .number').text(index + 1);
|
|
card.find('.row .col-md-9').eq(0).find('.number').eq(0).text(result.passed);
|
|
card.find('.row .col-md-9').eq(0).find('.number').eq(1).text(result.count);
|
|
if (result.weight !== 0) {
|
|
card.find('.row .col-md-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2)));
|
|
card.find('.row .col-md-9').eq(1).find('.number').eq(1).text(result.weight);
|
|
} else {
|
|
// Hide score row if no score could be achieved
|
|
card.find('.attribute-row.row').eq(1).addClass('d-none');
|
|
}
|
|
card.find('.row .col-md-9').eq(2).html(result.message);
|
|
|
|
// Add error message from code to card
|
|
if (result.error_messages) {
|
|
const targetNode = card.find('.row .col-md-9').eq(3);
|
|
|
|
let errorMessagesToShow = [];
|
|
result.error_messages.forEach(function (item) {
|
|
if (item) {
|
|
errorMessagesToShow.push(item)
|
|
}
|
|
})
|
|
|
|
// delete all current elements
|
|
targetNode.text('');
|
|
// create a new list and append each element
|
|
const ul = document.createElement("ul");
|
|
|
|
// Extract detailed linter results
|
|
if (result.file_role === 'teacher_defined_linter') {
|
|
const detailed_linter_results = result.detailed_linter_results;
|
|
const severity_groups = detailed_linter_results.reduce(function(map, obj) {
|
|
map[obj.severity] = map[obj.severity] || []
|
|
map[obj.severity].push(obj);
|
|
return map;
|
|
}, {});
|
|
|
|
for (let severity in severity_groups) {
|
|
if (!severity_groups.hasOwnProperty(severity)) {
|
|
continue;
|
|
}
|
|
const linter_results = severity_groups[severity]
|
|
|
|
const li = document.createElement("li");
|
|
const text = $.parseHTML(`<u>${severity}:</u>`);
|
|
$(li).append(text);
|
|
ul.append(li);
|
|
|
|
const sub_ul = document.createElement("ul");
|
|
sub_ul.setAttribute('class', 'inline_list');
|
|
for (let check_run of linter_results) {
|
|
const sub_li = document.createElement("li");
|
|
|
|
let scope = '';
|
|
if (check_run.scope) {
|
|
scope = `, ${check_run.scope}()`;
|
|
}
|
|
const context = `${check_run.file_name}: ${check_run.line}${scope}`;
|
|
const line_link = `<a href='#' data-file='${check_run.file_name}' data-line='${check_run.line}'>${context}</a>`;
|
|
const message = `${check_run.name}: ${check_run.result} (${line_link})`;
|
|
const sub_text = $.parseHTML(message);
|
|
$(sub_li).append(sub_text).on("click", "a", this.jumpToSourceLine.bind(this));
|
|
sub_ul.append(sub_li);
|
|
}
|
|
li.append(sub_ul);
|
|
}
|
|
|
|
// Just show standard results for normal test results
|
|
} else {
|
|
errorMessagesToShow.forEach(function (item) {
|
|
var li = document.createElement("li");
|
|
var text = document.createTextNode(item);
|
|
$(li).append(text);
|
|
ul.append(li);
|
|
})
|
|
}
|
|
|
|
// one or more errors?
|
|
if (errorMessagesToShow.length > 1) {
|
|
ul.setAttribute('class', 'inline_list');
|
|
} else {
|
|
ul.setAttribute('class', 'single_entry_inline_list');
|
|
}
|
|
targetNode.append(ul);
|
|
}
|
|
//card.find('.row .col-md-9').eq(4).find('a').attr('href', '#output-' + index);
|
|
},
|
|
|
|
createEventHandler: function (eventType, data) {
|
|
return function (event) {
|
|
CodeOceanEditor.publishCodeOceanEvent({
|
|
category: eventType,
|
|
data: data,
|
|
exercise_id: $('#editor').data('exercise-id'),
|
|
file_id: CodeOceanEditor.active_file.id,
|
|
});
|
|
event.stopPropagation();
|
|
};
|
|
},
|
|
|
|
publishCodeOceanEvent: function (payload) {
|
|
if (this.sendEvents) {
|
|
$.ajax(this.eventURL, {
|
|
type: 'POST',
|
|
cache: false,
|
|
dataType: 'JSON',
|
|
data: {
|
|
event: payload
|
|
},
|
|
success: _.noop,
|
|
error: _.noop
|
|
});
|
|
}
|
|
},
|
|
|
|
sendError: function (message, submission_id) {
|
|
this.showSpinner($('#render'));
|
|
var jqxhr = this.ajax({
|
|
data: {
|
|
error: {
|
|
message: message,
|
|
submission_id: submission_id
|
|
}
|
|
},
|
|
url: $('#editor').data('errors-url')
|
|
});
|
|
jqxhr.always(this.hideSpinner);
|
|
},
|
|
|
|
toggleButtonStates: function () {
|
|
$('#destroy-file').prop('disabled', this.active_frame.data('role') !== 'user_defined_file');
|
|
$('#start-over-active-file').prop('disabled', this.active_frame.data('role') === 'user_defined_file' || this.active_frame.data('read-only') !== undefined);
|
|
$('#dummy').toggle(!this.fileActionsAvailable());
|
|
$('#render').toggle(this.isActiveFileRenderable());
|
|
$('#run-stop-button-group').tooltip('hide').toggleClass('flex-grow-1', this.isActiveFileRunnable() || this.isActiveFileStoppable());
|
|
$('#run').toggle(this.isActiveFileRunnable() && !this.running);
|
|
$('#stop').toggle(this.isActiveFileStoppable());
|
|
$('#test').toggle(this.isActiveFileTestable());
|
|
},
|
|
|
|
jumpToSourceLine: function (event) {
|
|
const file = $(event.target).data('file');
|
|
const line = $(event.target).data('line');
|
|
|
|
const frame = $('div.frame[data-filename="' + file + '"]');
|
|
this.showFrame(frame);
|
|
this.toggleButtonStates();
|
|
|
|
const file_id = frame.find('.editor').data('file-id');
|
|
this.setActiveFile(frame.data('filename'), file_id);
|
|
this.selectFileInJsTree($('#files'), file_id);
|
|
|
|
const editor = this.editor_for_file.get(file);
|
|
editor.gotoLine(line, 0);
|
|
event.preventDefault();
|
|
},
|
|
|
|
augmentStacktraceInOutput: function () {
|
|
if (this.tracepositions_regex) {
|
|
$('#output > .output-element').each($.proxy(function(index, element) {
|
|
element = $(element)
|
|
|
|
const text = _.escape(element.text());
|
|
element.on("click", "a", this.jumpToSourceLine.bind(this));
|
|
|
|
let matches;
|
|
|
|
let augmented_text = element.html();
|
|
while (matches = this.tracepositions_regex.exec(text)) {
|
|
const frame = $('div.frame[data-filename="' + matches[1] + '"]')
|
|
|
|
if (frame.length > 0) {
|
|
augmented_text = augmented_text.replace(new RegExp(_.unescape(matches[0]), 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
|
|
}
|
|
}
|
|
element.html(augmented_text);
|
|
}, this));
|
|
}
|
|
},
|
|
|
|
resetOutputTab: function () {
|
|
this.clearOutput();
|
|
$('#flowrHint').fadeOut();
|
|
this.clearHints();
|
|
this.showOutputBar();
|
|
this.clearFileDownloads();
|
|
},
|
|
|
|
isActiveFileBinary: function () {
|
|
if (this.active_frame.data() === undefined) {
|
|
return false;
|
|
}
|
|
return 'binary' in this.active_frame.data();
|
|
},
|
|
|
|
isActiveFileExecutable: function () {
|
|
if (this.active_frame.data() === undefined) {
|
|
return false;
|
|
}
|
|
return 'executable' in this.active_frame.data();
|
|
},
|
|
|
|
setActiveFile: function (filename, fileId) {
|
|
this.active_file = {
|
|
filename: filename,
|
|
id: fileId
|
|
};
|
|
},
|
|
|
|
showSpinner: function (initiator) {
|
|
$(initiator).closest('[data-bs-toggle="tooltip"]').tooltip('hide');
|
|
$(initiator).find('i.fa-solid, i.fa-regular').hide();
|
|
$(initiator).find('i.fa-spin').addClass('d-inline-block').removeClass('d-none');
|
|
},
|
|
|
|
showStatus: function (output) {
|
|
if (output.status === 'timeout' || output.status === 'buffer_overflow') {
|
|
this.showTimeoutMessage();
|
|
} else if (output.status === 'container_depleted') {
|
|
this.showContainerDepletedMessage();
|
|
} else if (output.status === 'out_of_memory') {
|
|
this.showOutOfMemoryMessage();
|
|
}
|
|
},
|
|
|
|
clearHints: function () {
|
|
var container = $('#error-hints');
|
|
container.find('ul.body > li.hint').remove();
|
|
container.fadeOut();
|
|
},
|
|
|
|
showHint: function (message) {
|
|
var template = function (description, hint) {
|
|
return '\
|
|
<li class="hint">\
|
|
<div class="description">\
|
|
' + description + '\
|
|
</div>\
|
|
<div class="hint">\
|
|
' + hint + '\
|
|
</div>\
|
|
</li>\
|
|
'
|
|
};
|
|
var container = $('#error-hints');
|
|
container.find('ul.body').append(template(message.description, message.hint));
|
|
container.fadeIn();
|
|
},
|
|
|
|
prepareFileDownloads: function(message) {
|
|
const fileTree = $('#download-file-tree');
|
|
fileTree.jstree(message.data);
|
|
fileTree.on('select_node.jstree', function (node, selected, _event) {
|
|
selected.instance.deselect_all();
|
|
const downloadPath = selected.node.original.download_path;
|
|
if (downloadPath) {
|
|
window.location = downloadPath;
|
|
}
|
|
}.bind(this));
|
|
$('#download-files').removeClass('d-none');
|
|
},
|
|
|
|
clearFileDownloads: function() {
|
|
$('#download-files').addClass('d-none');
|
|
$('#download-file-tree').replaceWith($('<div id="download-file-tree">'));
|
|
},
|
|
|
|
showContainerDepletedMessage: function () {
|
|
$.flash.danger({
|
|
icon: ['fa-regular', 'fa-clock'],
|
|
text: $('#editor').data('message-depleted')
|
|
});
|
|
},
|
|
|
|
showOutOfMemoryMessage: function () {
|
|
$.flash.info({
|
|
icon: ['fa-regular', 'fa-clock'],
|
|
text: $('#editor').data('message-out-of-memory')
|
|
});
|
|
},
|
|
|
|
showTimeoutMessage: function () {
|
|
$.flash.info({
|
|
icon: ['fa-regular', 'fa-clock'],
|
|
text: $('#editor').data('message-timeout')
|
|
});
|
|
},
|
|
|
|
showWebsocketError: function (error) {
|
|
if (window.navigator.userAgent.indexOf('Edge') > -1 || window.navigator.userAgent.indexOf('Trident') > -1) {
|
|
// Mute errors in Microsoft Edge and Internet Explorer
|
|
return;
|
|
}
|
|
$.flash.danger({
|
|
text: $('#flash').data('websocket-failure'),
|
|
showPermanent: true
|
|
});
|
|
Sentry.captureException(JSON.stringify(error, ["message", "arguments", "type", "name", "data"]));
|
|
},
|
|
|
|
showFileDialog: function (event) {
|
|
event.preventDefault();
|
|
this.createSubmission('#create-file', null, function (response) {
|
|
$('#code_ocean_file_context_id').val(response.id);
|
|
new bootstrap.Modal($('#modal-file')).show();
|
|
}.bind(this));
|
|
},
|
|
|
|
initializeOutputBarToggle: function () {
|
|
$('#toggle-sidebar-output').on('click', this.hideOutputBar.bind(this));
|
|
$('#toggle-sidebar-output-collapsed').on('click', this.showOutputBar.bind(this));
|
|
$('#output_sidebar').on('transitionend', this.resizeAceEditors.bind(this));
|
|
$('#output_sidebar').on('transitionend', this.resizeSidebars.bind(this));
|
|
},
|
|
|
|
showOutputBar: function () {
|
|
$('#output_sidebar_collapsed').addClass('d-none');
|
|
$('#output_sidebar_uncollapsed').removeClass('d-none');
|
|
$('#output_sidebar').removeClass('output-col-collapsed').addClass('output-col');
|
|
},
|
|
|
|
hideOutputBar: function () {
|
|
$('#output_sidebar_collapsed').removeClass('d-none');
|
|
$('#output_sidebar_uncollapsed').addClass('d-none');
|
|
$('#output_sidebar').removeClass('output-col').addClass('output-col-collapsed');
|
|
},
|
|
|
|
initializeSideBarTooltips: function () {
|
|
$('[data-bs-toggle="tooltip"]').tooltip()
|
|
},
|
|
|
|
initializeDescriptionToggle: function () {
|
|
$('#exercise-headline').on('click', this.toggleDescriptionCard.bind(this));
|
|
$('a#toggle').on('click', this.toggleDescriptionCard.bind(this));
|
|
},
|
|
|
|
toggleDescriptionCard: function (event) {
|
|
$('#description-card').toggleClass('description-card-collapsed').toggleClass('description-card');
|
|
$('#description-symbol').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right');
|
|
var toggle = $('a#toggle');
|
|
toggle.text(toggle.text() == toggle.data('hide') ? toggle.data('show') : toggle.data('hide'));
|
|
this.resizeAceEditors();
|
|
this.resizeSidebars();
|
|
event.preventDefault();
|
|
},
|
|
|
|
/**
|
|
* interventions
|
|
* */
|
|
initializeInterventionTimer: function () {
|
|
const editor = $('#editor');
|
|
|
|
if (editor.data('rfc-interventions') || editor.data('break-interventions') || editor.data('tips-interventions')) { // split in break or rfc intervention
|
|
window.onblur = function () {
|
|
window.blurred = true;
|
|
};
|
|
window.onfocus = function () {
|
|
window.blurred = false;
|
|
};
|
|
|
|
const delta = 100; // time in ms to wait for window event before time gets stopped
|
|
let tid;
|
|
$.ajax({
|
|
dataType: 'json',
|
|
method: 'GET',
|
|
// get working times for this exercise
|
|
url: editor.data('working-times-url'),
|
|
success: function (data) {
|
|
const percentile75 = data['working_time_75_percentile'];
|
|
const accumulatedWorkTimeUser = data['working_time_accumulated'];
|
|
|
|
let minTimeIntervention = 20 * 60 * 1000;
|
|
|
|
let timeUntilIntervention;
|
|
if ((accumulatedWorkTimeUser - percentile75) > 0) {
|
|
// working time is already over 75 percentile
|
|
timeUntilIntervention = minTimeIntervention;
|
|
} else {
|
|
// working time is less than 75 percentile
|
|
// ensure we give user at least minTimeIntervention before we bother the user
|
|
timeUntilIntervention = Math.max(percentile75 - accumulatedWorkTimeUser, minTimeIntervention);
|
|
}
|
|
|
|
tid = setInterval(function () {
|
|
if (window.blurred) {
|
|
return;
|
|
}
|
|
timeUntilIntervention -= delta;
|
|
if (timeUntilIntervention <= 0) {
|
|
const interventionSaveUrl = editor.data('intervention-save-url');
|
|
clearInterval(tid);
|
|
// timeUntilIntervention passed
|
|
if (editor.data('tips-interventions')) {
|
|
const modal = $('#tips-intervention-modal');
|
|
if (!modal.isPresent()) {
|
|
// The modal is not present (e.g., because the site was navigated), so we don't continue here.
|
|
return;
|
|
}
|
|
|
|
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
|
|
new bootstrap.Modal(modal).show();
|
|
$.ajax({
|
|
data: {
|
|
intervention_type: 'TipsIntervention'
|
|
},
|
|
dataType: 'json',
|
|
type: 'POST',
|
|
url: interventionSaveUrl
|
|
});
|
|
} else if (editor.data('break-interventions')) {
|
|
const modal = $('#break-intervention-modal');
|
|
if (!modal.isPresent()) {
|
|
// The modal is not present (e.g., because the site was navigated), so we don't continue here.
|
|
return;
|
|
}
|
|
|
|
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
|
|
new bootstrap.Modal(modal).show();
|
|
$.ajax({
|
|
data: {
|
|
intervention_type: 'BreakIntervention'
|
|
},
|
|
dataType: 'json',
|
|
type: 'POST',
|
|
url: interventionSaveUrl
|
|
});
|
|
} else if (editor.data('rfc-interventions')) {
|
|
const button = $('#requestComments');
|
|
// only show intervention if user did not requested for a comment already
|
|
if (!button.prop('disabled')) {
|
|
$('#rfc_intervention_text').show();
|
|
const modal = $('#comment-modal');
|
|
if (!modal.isPresent()) {
|
|
// The modal is not present (e.g., because the site was navigated), so we don't continue here.
|
|
return;
|
|
}
|
|
|
|
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
|
|
modal.on('hidden.bs.modal', function () {
|
|
modal.find('.modal-footer').text('');
|
|
});
|
|
new bootstrap.Modal(modal).show();
|
|
$.ajax({
|
|
data: {
|
|
intervention_type: 'QuestionIntervention'
|
|
},
|
|
dataType: 'json',
|
|
type: 'POST',
|
|
url: interventionSaveUrl
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, delta);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
applyChanges: function (delta, active_file) {
|
|
this.lastDeltaObject_for_file.set(active_file.filename, delta);
|
|
const editor = this.editor_for_file.get(active_file.filename)
|
|
editor.session.doc.applyDeltas([delta]);
|
|
},
|
|
|
|
compareDeltaObjects: function (delta, last_delta) {
|
|
if (delta === null || last_delta === null) {
|
|
return false;
|
|
}
|
|
|
|
const delta_data = delta.data
|
|
return !_.isEqual(delta_data, last_delta);
|
|
},
|
|
|
|
showPartnersConnectionStatus: function (status, username) {
|
|
switch(status) {
|
|
case 'connected':
|
|
$('#pg_session').text(I18n.t('exercises.editor.is_online', {name: username}));
|
|
break;
|
|
case 'disconnected':
|
|
$('#pg_session').text(I18n.t('exercises.editor.is_offline', {name: username}));
|
|
break;
|
|
}
|
|
},
|
|
|
|
initializeEverything: function () {
|
|
CodeOceanEditor.editors = [];
|
|
this.initializeRegexes();
|
|
this.configureEditors();
|
|
this.initializeEditors();
|
|
this.initializeEventHandlers();
|
|
this.initializeFileTree();
|
|
this.initializeSideBarCollapse();
|
|
this.initializeOutputBarToggle();
|
|
this.initializeDescriptionToggle();
|
|
this.initializeSideBarTooltips();
|
|
this.initializeInterventionTimer();
|
|
this.initPrompt();
|
|
this.renderScore();
|
|
this.showFirstFile();
|
|
this.resizeAceEditors();
|
|
this.resizeSidebars();
|
|
this.initializeDeadlines();
|
|
CodeOceanEditorTips.initializeEventHandlers();
|
|
|
|
window.addEventListener("turbolinks:before-render", App.synchronized_editor?.disconnect.bind(App.synchronized_editor));
|
|
window.addEventListener("beforeunload", App.synchronized_editor?.disconnect.bind(App.synchronized_editor));
|
|
|
|
window.addEventListener("turbolinks:before-render", this.autosaveIfChanged.bind(this));
|
|
window.addEventListener("beforeunload", this.autosaveIfChanged.bind(this));
|
|
// create autosave when the editor is opened the first time
|
|
this.autosave();
|
|
}
|
|
};
|