diff --git a/.github/workflows/webstandard.yml b/.github/workflows/webstandard.yml index 23e0760cab..3ad55c0492 100644 --- a/.github/workflows/webstandard.yml +++ b/.github/workflows/webstandard.yml @@ -54,6 +54,15 @@ jobs: run: .github/jobs/baseinstall.sh ${{ matrix.role }} - name: Run webstandard tests (W3C, WCAG) run: .github/jobs/webstandard.sh ${{ matrix.test }} ${{ matrix.role }} + - name: dump the db + if: ${{ !cancelled() }} + run: mysqldump -uroot -proot --quick --max_allowed_packet=1024M domjudge > /tmp/db.sql + - name: Upload database dump for debugging + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: DB-dump + path: /tmp/db.sql - name: Upload all logs/artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 diff --git a/webapp/public/js/domjudge.js b/webapp/public/js/domjudge.js index 371ae20e92..194364f9a9 100644 --- a/webapp/public/js/domjudge.js +++ b/webapp/public/js/domjudge.js @@ -138,6 +138,20 @@ function setDiffMode(value) localStorage.setItem('domjudge_editor_diff_mode', value); } +function getDiffTag() +{ + let diffTag = localStorage.getItem('domjudge_editor_diff_tag'); + if (diffTag === undefined) { + return 'no-diff'; + } + return diffTag; +} + +function setDiffTag(value) +{ + localStorage.setItem('domjudge_editor_diff_tag', value); +} + // Send a notification if notifications have been enabled. // The options argument is passed to the Notification constructor, // except that the following tags (if found) are interpreted and @@ -1290,3 +1304,189 @@ function initScoreboardSubmissions() { }); }); } + +const editors = []; +function initDiffEditor(editorId) { + const wrapper = $(`#${editorId}-wrapper`); + + const initialTag = getDiffTag(); + const select = wrapper.find(".diff-select"); + for (let i = 0; i < select[0].options.length; i++) { + if (select[0].options[i].dataset.tag == initialTag) { + select[0].selectedIndex = i; + break; + } + } + // Fall back to other tagged diff if preferred tag is not available for this submission. + if (initialTag !== "no-diff" && select[0].selectedIndex === 0) { + for (let i = 1; i < select[0].options.length; i++) { + if (select[0].options[i].dataset.tag) { + select[0].selectedIndex = i; + break; + } + } + } + + const initialDiffMode = getDiffMode(); + const radios = wrapper.find(`.diff-mode > input[type='radio']`); + radios.each((_, radio) => { + radio.checked = radio.value === initialDiffMode + }); + + const download = wrapper.find(".download")[0]; + const edit = wrapper.find(".edit")[0]; + const updateTabRank = (rank) => { + if (rank) { + let url = new URL(download.href); + url.searchParams.set("fetch", rank); + download.href = url; + + url = new URL(edit.href); + url.searchParams.set("rank", rank); + edit.href = url; + } else { + download.href = "#"; + edit.href = "#"; + } + }; + wrapper.find(".nav").on('show.bs.tab', (e) => { + updateTabRank(e.target.dataset.rank); + }) + + const editor = { + 'getDiffMode': () => { + for (let radio of radios) { + if (radio.checked) { + return radio.value; + } + } + }, + 'getDiffSelection': () => { + let s = select[0]; + return s.options[s.selectedIndex].value; + }, + 'updateIcon': (rank, icon) => { + const element = wrapper.find(".nav-link[data-rank]")[rank].querySelector('.fa-fw'); + element.className = 'fas fa-fw fa-' + icon; + }, + 'onDiffModeChange': (f) => { + radios.change((e) => { + const diffMode = e.target.value; + f(diffMode); + }); + }, + 'onDiffSelectChange': (f) => { + select.change((e) => { + const submitId = e.target.value; + const noDiff = submitId === ""; + f(submitId, noDiff); + }); + } + }; + editors[editorId] = editor; + + const updateMode = (diffMode) => { + setDiffMode(diffMode); + }; + updateMode(initialDiffMode); + editor.onDiffModeChange(updateMode); + + const updateSelect = (submitId, noDiff) => { + radios.each((_, radio) => { + radio.disabled = noDiff; + }); + + const selected = select[0].options[select[0].selectedIndex]; + if (selected && selected.dataset.tag) { + setDiffTag(selected.dataset.tag); + } + + // TODO: add tab panes for deleted source files. + }; + updateSelect(select[0].value, select[0].value === ""); + editor.onDiffSelectChange(updateSelect); +} + +function initDiffEditorTab(editorId, diffId, rank, models, modifiedModel) { + const empty = monaco.editor.getModel(monaco.Uri.file("empty")) ?? monaco.editor.createModel("", undefined, monaco.Uri.file("empty")); + + const diffEditor = monaco.editor.createDiffEditor( + document.getElementById(diffId), { + scrollbar: { + alwaysConsumeMouseWheel: false, + vertical: 'auto', + horizontal: 'auto' + }, + scrollBeyondLastLine: false, + automaticLayout: true, + readOnly: true, + theme: getCurrentEditorTheme(), + }); + + const updateMode = (diffMode) => { + diffEditor.updateOptions({ + renderSideBySide: diffMode === 'side-by-side', + }); + }; + editors[editorId].onDiffModeChange(updateMode); + + const updateSelect = (submitId, noDiff) => { + if (!noDiff) { + const model = models[submitId]; + if (model === undefined) { + models[submitId] = {'model': empty}; + } else if (model !== undefined && !model['model']) { + // TODO: show source code instead of diff to empty file? + model['model'] = monaco.editor.createModel(model['source'], undefined, monaco.Uri.file("test/" + submitId + "/" + model['filename'])); + } + } + + diffEditor.updateOptions({ + renderOverviewRuler: !noDiff, + }); + if (noDiff) { + diffEditor.updateOptions({ + renderSideBySide: false, + }); + } else { + // Reset the diff mode to the currently selected mode. + updateMode(editors[editorId].getDiffMode()) + } + // TODO: handle single-file submission case with renamed file. + const oldViewState = diffEditor.saveViewState(); + diffEditor.setModel({ + original: noDiff ? modifiedModel : models[submitId]['model'], + modified: modifiedModel, + }); + diffEditor.restoreViewState(oldViewState); + + diffEditor.getOriginalEditor().updateOptions({ + lineNumbers: !noDiff, + }); + diffEditor.getModifiedEditor().updateOptions({ + minimap: { + enabled: noDiff, + }, + }) + }; + editors[editorId].onDiffSelectChange(updateSelect); + updateSelect(editors[editorId].getDiffSelection(), editors[editorId].getDiffSelection() === ""); + + const updateIcon = () => { + const noDiff = editors[editorId].getDiffSelection() === ""; + if (noDiff) { + editors[editorId].updateIcon(rank, 'file'); + return; + } + + const lineChanges = diffEditor.getLineChanges(); + if (diffEditor.getModel().original == empty) { + editors[editorId].updateIcon(rank, 'file-circle-plus'); + } else if (lineChanges !== null && lineChanges.length > 0) { + editors[editorId].updateIcon(rank, 'file-circle-exclamation'); + } else { + editors[editorId].updateIcon(rank, 'file-circle-check'); + } + } + diffEditor.onDidUpdateDiff(updateIcon); +} \ No newline at end of file diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index acca869506..038f12ced4 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -849,22 +849,10 @@ public function sourceAction( ->getQuery() ->getResult(); - $originalSubmission = $originalFiles = null; - - if ($submission->getOriginalSubmission()) { - /** @var Submission $originalSubmission */ - $originalSubmission = $this->em->getRepository(Submission::class)->find($submission->getOriginalSubmission()->getSubmitid()); - - /** @var SubmissionFile[] $files */ - $originalFiles = $this->em->createQueryBuilder() - ->from(SubmissionFile::class, 'file') - ->select('file') - ->andWhere('file.submission = :submission') - ->setParameter('submission', $originalSubmission) - ->orderBy('file.ranknumber') - ->getQuery() - ->getResult(); - + $otherSubmissions = []; + $originalSubmission = $submission->getOriginalSubmission(); + if ($originalSubmission) { + $otherSubmissions[] = $originalSubmission; /** @var Submission $oldSubmission */ $oldSubmission = $this->em->createQueryBuilder() ->from(Submission::class, 's') @@ -900,30 +888,35 @@ public function sourceAction( ->getQuery() ->getOneOrNullResult(); } + if ($oldSubmission !== null) { + $otherSubmissions[] = $oldSubmission; + } /** @var SubmissionFile[] $files */ $oldFiles = $this->em->createQueryBuilder() ->from(SubmissionFile::class, 'file') ->select('file') - ->andWhere('file.submission = :submission') - ->setParameter('submission', $oldSubmission) - ->orderBy('file.ranknumber') + ->andWhere('file.submission in (:submissions)') + ->setParameter('submissions', array_map(fn($s) => $s->getSubmitid(), $otherSubmissions)) + ->orderBy('file.submission, file.ranknumber') ->getQuery() ->getResult(); - $oldFileStats = $oldFiles !== null ? $this->determineFileChanged($files, $oldFiles) : []; - $originalFileStats = $originalFiles !== null ? $this->determineFileChanged($files, $originalFiles) : []; + $otherFiles = []; + foreach ($oldFiles as $f) { + $submitId = $f->getSubmission()->getSubmitid(); + $otherFiles[$submitId] ??= []; + $otherFiles[$submitId][] = $f; + } return $this->render('jury/submission_source.html.twig', [ 'submission' => $submission, 'files' => $files, 'oldSubmission' => $oldSubmission, - 'oldFiles' => $oldFiles, - 'oldFileStats' => $oldFileStats, 'originalSubmission' => $originalSubmission, - 'originalFiles' => $originalFiles, - 'originalFileStats' => $originalFileStats, 'allowEdit' => $this->allowEdit(), + 'otherSubmissions' => $otherSubmissions, + 'otherFiles' => $otherFiles, ]); } diff --git a/webapp/src/Twig/TwigExtension.php b/webapp/src/Twig/TwigExtension.php index c1309fe6b7..7dfb75b13e 100644 --- a/webapp/src/Twig/TwigExtension.php +++ b/webapp/src/Twig/TwigExtension.php @@ -34,6 +34,7 @@ use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Serializer\SerializerInterface; use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\Extension\GlobalsInterface; @@ -57,6 +58,7 @@ public function __construct( protected readonly TokenStorageInterface $tokenStorage, protected readonly AuthorizationCheckerInterface $authorizationChecker, protected readonly RouterInterface $router, + protected readonly SerializerInterface $serializer, #[Autowire('%kernel.project_dir%')] protected readonly string $projectDir, protected array $renderedSources = [] @@ -177,7 +179,6 @@ public function getGlobals(): array 'hc-black' => ['name' => 'High contrast (dark)'], ], 'diff_modes' => [ - 'no-diff' => ["name" => "No diff"], 'side-by-side' => ["name" => "Side-by-side"], 'inline' => ["name" => "Inline"], ], @@ -954,71 +955,44 @@ public function getMonacoModel(SubmissionFile $file): string ); } - public function showDiff(string $id, SubmissionFile $newFile, SubmissionFile $oldFile): string + /** @param array $otherFiles */ + public function showDiff(string $editorId, string $diffId, SubmissionFile $newFile, array $otherFiles): string { $editor = << +
HTML; + $others = []; + foreach ($otherFiles as $submissionId => $files) { + foreach ($files as $f) { + if ($f->getFilename() == $newFile->getFilename()) { + // TODO: add `tag` containing `previous` / `original` + $others[$submissionId] = [ + 'filename' => $f->getFilename(), + 'source' => $f->getSourcecode(), + ]; + } + } + } + return sprintf( - str_replace('__EDITOR__', $id, $editor), - $this->getMonacoModel($oldFile), + $editor, + $editorId, + $diffId, + $newFile->getRank(), + $this->serializer->serialize($others, 'json'), $this->getMonacoModel($newFile), ); } diff --git a/webapp/templates/jury/partials/submission_diff.html.twig b/webapp/templates/jury/partials/submission_diff.html.twig index 04d60dbda7..fca382c06e 100644 --- a/webapp/templates/jury/partials/submission_diff.html.twig +++ b/webapp/templates/jury/partials/submission_diff.html.twig @@ -1,112 +1,67 @@ -{% if files | length > 1 or oldFiles | length > 1 %} - - - - - - - - - - - - - - - - - -
Files added{{ oldFileStats.added | join(', ') }}
Files removed{{ oldFileStats.removed | join(', ') }}
Files changed{{ oldFileStats.changed | join(', ') }}
Files unchanged{{ oldFileStats.unchanged | join(', ') }}
-{% endif %} +
{# Mark the first tab that is shown as active. #} {% set extra_css_classes = "active" %} - + +{% set extra_css_classes = "show active" %} +
+ {%- for file in files %} + {% set diff_id = "diff" ~ file.submitfileid %} +
+ {{ showDiff(editor_id, diff_id, file, otherFiles) }}
{% set extra_css_classes = "" %} {%- endfor %}
+
diff --git a/webapp/templates/jury/submission_source.html.twig b/webapp/templates/jury/submission_source.html.twig index 2ddcf2b4c5..2cf2b79893 100644 --- a/webapp/templates/jury/submission_source.html.twig +++ b/webapp/templates/jury/submission_source.html.twig @@ -37,12 +37,8 @@

Entry point: {{ submission.entryPoint }}

{%- endif %} - {%- if submission.originalSubmission %} -

Go to diff to original submission

- {%- endif %} - - {% if oldSubmission %} - {%- include 'jury/partials/submission_diff.html.twig' with {oldSubmission: oldSubmission, oldFiles: oldFiles, oldFileStats: oldFileStats} %} + {% if otherFiles | length > 0 %} + {%- include 'jury/partials/submission_diff.html.twig' with {editor_id: "diffw" ~ submission.submitid} %} {% else %}