mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-14 05:28:39 +00:00
feat(api,ui): add multiline comment on pullrequest (#12582)
Closes https://codeberg.org/forgejo/forgejo/issues/6093 This PR adds support for **multi-line review comments** on pull requests, allowing reviewers to select a range of lines in diffs instead of only a single line — similar to GitHub's implementation. ### Tests for Go changes - I added test coverage for Go changes... - [X] in their respective `*_test.go` for unit tests. - [X] `make pr-go` before pushing Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12582 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
parent
f576a1a21e
commit
6a27eb051d
26 changed files with 1185 additions and 103 deletions
|
|
@ -282,3 +282,16 @@
|
|||
width: 100%;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Multi-line comment selection highlight (shift+drag in progress) */
|
||||
/* Unified view highlights the whole row; split view highlights only the commented side's cells */
|
||||
tr.diff-line-selected > td,
|
||||
td.diff-line-selected {
|
||||
background-color: var(--color-primary-alpha-20) !important;
|
||||
}
|
||||
|
||||
/* Existing multi-line comment range highlight */
|
||||
tr.diff-line-commented-range > td,
|
||||
td.diff-line-commented-range {
|
||||
background-color: var(--color-yellow-badge-bg) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {initImageDiff} from './imagediff.js';
|
|||
import {showErrorToast} from '../modules/toast.js';
|
||||
import {submitEventSubmitter, queryElemSiblings, hideElem, showElem} from '../utils/dom.js';
|
||||
import {POST, GET} from '../modules/fetch.js';
|
||||
import {clearMultiLineSelection, refreshMultiLineCommentHighlights} from './repo-issue.js';
|
||||
|
||||
const {pageData, i18n} = window.config;
|
||||
|
||||
|
|
@ -80,6 +81,33 @@ function initRepoDiffConversationForm() {
|
|||
const {path, side, idx} = $newConversationHolder.data();
|
||||
|
||||
$form.closest('.conversation-holder').replaceWith($newConversationHolder);
|
||||
// The backend may store the comment on the other side than it was drawn (e.g. a "previous" selection over
|
||||
// unchanged lines becomes a "proposed" comment). Move the rendered box into its final side's cell.
|
||||
const holderEl = $newConversationHolder[0];
|
||||
const currentTd = holderEl?.nodeType === 1 ? holderEl.closest('td') : null;
|
||||
const addCommentRow = currentTd?.closest('tr.add-comment');
|
||||
let movedSides = false;
|
||||
if (currentTd && addCommentRow && side) {
|
||||
const wantClass = side === 'left' ? 'add-comment-left' : 'add-comment-right';
|
||||
if (!currentTd.classList.contains(wantClass)) {
|
||||
const targetTd = addCommentRow.querySelector(`.${wantClass}`);
|
||||
if (targetTd && targetTd !== currentTd) {
|
||||
targetTd.append(holderEl);
|
||||
movedSides = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
clearMultiLineSelection(); // Clear line selection
|
||||
refreshMultiLineCommentHighlights(); // refresh highlighted on all multilines comments without a page reload
|
||||
// When the comment moved to the other side, hide the add-code-comment button at the range's display
|
||||
// line (last line) on the final side too, so no leftover '+' button sits next to the moved box.
|
||||
if (movedSides) {
|
||||
const extra = parseInt(holderEl.getAttribute('data-extra-lines-count')) || 0;
|
||||
const displayIdx = parseInt(idx) + extra;
|
||||
for (const el of document.querySelectorAll(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${displayIdx}"]`)) {
|
||||
el.classList.add('tw-invisible');
|
||||
}
|
||||
}
|
||||
let selector;
|
||||
if ($form.closest('tr').data('line-type') === 'same') {
|
||||
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ export function initRepoIssueCommentDelete() {
|
|||
conversationHolder.remove();
|
||||
}
|
||||
|
||||
// Recompute multi-line comment highlights so a removed comment no longer leaves a stale range highlight.
|
||||
refreshMultiLineCommentHighlights();
|
||||
|
||||
// Check if there is no review content, move the time avatar upward to avoid overlapping the content below.
|
||||
if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) {
|
||||
const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar');
|
||||
|
|
@ -188,6 +191,8 @@ export function initRepoIssueCodeCommentCancel() {
|
|||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.matches('.cancel-code-comment')) return;
|
||||
|
||||
clearMultiLineSelection();
|
||||
|
||||
const form = e.target.closest('form');
|
||||
if (form?.classList.contains('comment-form')) {
|
||||
hideElem(form);
|
||||
|
|
@ -376,7 +381,84 @@ export async function handleReply($el) {
|
|||
return editor;
|
||||
}
|
||||
|
||||
// Multi-line comment selection helpers
|
||||
|
||||
export function clearMultiLineSelection() {
|
||||
for (const el of document.querySelectorAll('.diff-line-selected')) {
|
||||
el.classList.remove('diff-line-selected');
|
||||
}
|
||||
}
|
||||
|
||||
function findDiffLineRow(path, side, lineNum) {
|
||||
const fileBox = document.querySelector(`[data-path="${path}"]`);
|
||||
if (!fileBox) return null;
|
||||
|
||||
const numClass = side === 'left' ? 'lines-num-old' : 'lines-num-new';
|
||||
const cells = fileBox.querySelectorAll(`.${numClass}[data-line-num="${lineNum}"]`);
|
||||
for (const cell of cells) {
|
||||
const row = cell.closest('tr');
|
||||
if (row) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// addLineHighlight applies a highlight class to a single diff line.
|
||||
// In split view a row holds both sides, so only the cells of `side` are highlighted to avoid
|
||||
// lighting up the opposite column. In unified view there is a single code column, so the whole
|
||||
// row is highlighted.
|
||||
function addLineHighlight(row, side, className) {
|
||||
if (!row.closest('.code-diff-split')) {
|
||||
row.classList.add(className);
|
||||
return;
|
||||
}
|
||||
const suffix = side === 'left' ? 'old' : 'new';
|
||||
for (const cell of row.querySelectorAll(`.lines-num-${suffix}, .lines-escape-${suffix}, .lines-type-marker-${suffix}, .lines-code-${suffix}`)) {
|
||||
cell.classList.add(className);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightLineRange(path, side, startLine, endLine) {
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
const row = findDiffLineRow(path, side, i);
|
||||
if (row) {
|
||||
addLineHighlight(row, side, 'diff-line-selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshMultiLineCommentHighlights recomputes the highlight of every existing multi-line
|
||||
// comment range from the current DOM. It is idempotent: it first clears all previous range
|
||||
// highlights so that comments removed since the last call no longer leave stale highlighting,
|
||||
// then re-applies the highlight for every conversation holder still present.
|
||||
// Call it on page load and after any dynamic create/delete of a code comment.
|
||||
export function refreshMultiLineCommentHighlights() {
|
||||
for (const el of document.querySelectorAll('.diff-line-commented-range')) {
|
||||
el.classList.remove('diff-line-commented-range');
|
||||
}
|
||||
|
||||
for (const holder of document.querySelectorAll('.conversation-holder[data-extra-lines-count]')) {
|
||||
const extraLinesCount = parseInt(holder.getAttribute('data-extra-lines-count'));
|
||||
if (!extraLinesCount) continue;
|
||||
|
||||
const side = holder.getAttribute('data-side');
|
||||
const idx = parseInt(holder.getAttribute('data-idx'));
|
||||
const path = holder.getAttribute('data-path');
|
||||
|
||||
// idx is UnsignedLine (first line), the comment displays at idx + extraLinesCount (last line)
|
||||
// Highlight from idx to idx + extraLinesCount
|
||||
for (let i = idx; i <= idx + extraLinesCount; i++) {
|
||||
const row = findDiffLineRow(path, side, i);
|
||||
if (row) {
|
||||
addLineHighlight(row, side, 'diff-line-commented-range');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoPullRequestReview() {
|
||||
// Highlight existing multi-line comment ranges
|
||||
refreshMultiLineCommentHighlights();
|
||||
|
||||
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
|
||||
// set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
|
||||
if (window.history.scrollRestoration !== 'manual') {
|
||||
|
|
@ -467,8 +549,124 @@ export function initRepoPullRequestReview() {
|
|||
});
|
||||
}
|
||||
|
||||
// Multi-line comment selection state (drag-based like GitHub)
|
||||
let multiLineDrag = null; // {side, startIdx, path, isSplit, newCommentUrl}
|
||||
|
||||
// Mousedown on + button with Shift held: start multi-line drag
|
||||
$(document).on('mousedown', '.add-code-comment', function (e) {
|
||||
if (e.target.classList.contains('btn-add-single')) return;
|
||||
if (!e.shiftKey) return;
|
||||
if (e.button !== 0) return; // left click only
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isSplit = this.closest('.code-diff')?.classList.contains('code-diff-split');
|
||||
const side = this.getAttribute('data-side');
|
||||
const idx = parseInt(this.getAttribute('data-idx'));
|
||||
const path = this.closest('[data-path]')?.getAttribute('data-path');
|
||||
const newCommentUrl = this.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url');
|
||||
|
||||
clearMultiLineSelection();
|
||||
multiLineDrag = {side, startIdx: idx, currentIdx: idx, path, isSplit, newCommentUrl};
|
||||
|
||||
// Highlight the initial line
|
||||
const row = findDiffLineRow(path, side, idx);
|
||||
if (row) addLineHighlight(row, side, 'diff-line-selected');
|
||||
|
||||
// Prevent text selection during drag
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
// Mouseover on diff table rows during drag: extend selection
|
||||
$(document).on('mouseover', 'tr[data-line-type]', function () {
|
||||
if (!multiLineDrag) return;
|
||||
|
||||
// Find the line number from the appropriate side's cell
|
||||
const numClass = multiLineDrag.side === 'left' ? 'lines-num-old' : 'lines-num-new';
|
||||
const numCell = this.querySelector(`.${numClass}[data-line-num]`);
|
||||
if (!numCell) return;
|
||||
|
||||
const lineNum = parseInt(numCell.getAttribute('data-line-num'));
|
||||
if (!lineNum || Number.isNaN(lineNum)) return;
|
||||
|
||||
// Check same file
|
||||
const filePath = this.closest('[data-path]')?.getAttribute('data-path');
|
||||
if (filePath !== multiLineDrag.path) return;
|
||||
|
||||
// Only extend downward (end >= start)
|
||||
if (lineNum < multiLineDrag.startIdx) return;
|
||||
|
||||
multiLineDrag.currentIdx = lineNum;
|
||||
|
||||
// Update highlight
|
||||
clearMultiLineSelection();
|
||||
highlightLineRange(multiLineDrag.path, multiLineDrag.side, multiLineDrag.startIdx, multiLineDrag.currentIdx);
|
||||
});
|
||||
|
||||
// Mouseup: finalize multi-line selection
|
||||
$(document).on('mouseup', async () => {
|
||||
if (!multiLineDrag) return;
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
const {side, startIdx, currentIdx, path, isSplit, newCommentUrl} = multiLineDrag;
|
||||
const lineStart = Math.min(startIdx, currentIdx);
|
||||
const lineEnd = Math.max(startIdx, currentIdx);
|
||||
const extraLinesCount = lineEnd - lineStart;
|
||||
multiLineDrag = null;
|
||||
|
||||
if (extraLinesCount === 0) {
|
||||
// Single line: clear highlight, no multi-line form
|
||||
clearMultiLineSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open comment form below the last line of the range
|
||||
const endTr = findDiffLineRow(path, side, lineEnd);
|
||||
if (!endTr) return;
|
||||
|
||||
const lineType = endTr.getAttribute('data-line-type') || 'same';
|
||||
const ntr = endTr.nextElementSibling;
|
||||
let $ntr = $(ntr);
|
||||
if (!ntr?.classList.contains('add-comment')) {
|
||||
$ntr = $(`
|
||||
<tr class="add-comment" data-line-type="${lineType}">
|
||||
${isSplit ? `
|
||||
<td class="add-comment-left" colspan="4"></td>
|
||||
<td class="add-comment-right" colspan="4"></td>
|
||||
` : `
|
||||
<td class="add-comment-left add-comment-right" colspan="5"></td>
|
||||
`}
|
||||
</tr>`);
|
||||
$(endTr).after($ntr);
|
||||
}
|
||||
|
||||
const $td = $ntr.find(`.add-comment-${side}`);
|
||||
const $commentCloud = $td.find('.comment-code-cloud');
|
||||
if (!$commentCloud.length && !$ntr.find('button[name="pending_review"]').length) {
|
||||
try {
|
||||
const response = await GET(newCommentUrl);
|
||||
const html = await response.text();
|
||||
$td.html(html);
|
||||
$td.find("input[name='line']").val(lineStart);
|
||||
$td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
|
||||
$td.find("input[name='path']").val(path);
|
||||
$td.find("input[name='extra_lines_count']").val(extraLinesCount);
|
||||
|
||||
await initDropzone($td.find('.dropzone')[0]);
|
||||
const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor'));
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Normal click (no shift): single-line comment (existing behavior)
|
||||
$(document).on('click', '.add-code-comment', async function (e) {
|
||||
if (e.target.classList.contains('btn-add-single')) return; // https://github.com/go-gitea/gitea/issues/4745
|
||||
if (e.shiftKey) return; // handled by mousedown/mouseup above
|
||||
e.preventDefault();
|
||||
|
||||
const isSplit = this.closest('.code-diff')?.classList.contains('code-diff-split');
|
||||
|
|
@ -512,6 +710,19 @@ export function initRepoPullRequestReview() {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear multi-line selection when pressing Escape or cancelling
|
||||
$(document).on('click', '.cancel-code-comment', () => {
|
||||
clearMultiLineSelection();
|
||||
multiLineDrag = null;
|
||||
});
|
||||
$(document).on('keydown', (e) => {
|
||||
if (e.key === 'Escape' && multiLineDrag) {
|
||||
document.body.style.userSelect = '';
|
||||
clearMultiLineSelection();
|
||||
multiLineDrag = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueReferenceIssue() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue