feat: add inline mobile review comments (#165)
This commit is contained in:
1 parent
4b7405aa65
commit
58376f6d45
7 files changed
+436
-16
No files matched your search
@@ -97,6 +97,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.review-inline-actions button { min-height:44px; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
@@ -426,6 +433,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<h2>Changed files</h2>
|
||||
<div id="review-files" class="muted"></div>
|
||||
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
||||
<strong id="review-inline-title">Comment on changed line</strong>
|
||||
<label class="small" id="review-inline-location" for="review-inline-body"></label>
|
||||
<textarea id="review-inline-body" maxlength="10000" placeholder="Write a line-specific comment"></textarea>
|
||||
<div class="review-inline-actions">
|
||||
<button id="save-inline-comment" type="button">Save draft comment</button>
|
||||
<button id="delete-inline-comment" type="button">Delete draft</button>
|
||||
<button id="cancel-inline-comment" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="review-progress-actions">
|
||||
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
||||
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
||||
@@ -520,6 +537,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let draft = null;
|
||||
let reviewFiles = [];
|
||||
let selectedReviewHead = '';
|
||||
let activeInlineTarget = null;
|
||||
let bulkConfirmationPending = false;
|
||||
let bulkMarkPending = false;
|
||||
let reviewHandoffPending = false;
|
||||
@@ -1019,6 +1037,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#new-issue').focus();
|
||||
}
|
||||
|
||||
function inlineAnchor(target) {
|
||||
return {
|
||||
path: target.dataset.reviewFilename,
|
||||
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
|
||||
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sameInlineAnchor(comment, anchor) {
|
||||
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
|
||||
comment.old_position === anchor.old_position;
|
||||
}
|
||||
|
||||
function closeInlineComposer() {
|
||||
qs('#review-inline-composer').hidden = true;
|
||||
qs('#review-inline-body').value = '';
|
||||
activeInlineTarget = null;
|
||||
}
|
||||
|
||||
function openInlineComposer(target) {
|
||||
if (!draft) return;
|
||||
activeInlineTarget = target;
|
||||
const anchor = inlineAnchor(target);
|
||||
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
|
||||
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
|
||||
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
|
||||
qs('#review-inline-body').value = existing?.body || '';
|
||||
qs('#delete-inline-comment').disabled = !existing;
|
||||
qs('#review-inline-composer').hidden = false;
|
||||
qs('#review-inline-body').focus();
|
||||
}
|
||||
|
||||
async function openReviewSheet(item, trigger) {
|
||||
selectedReview = item;
|
||||
reviewTrigger = trigger;
|
||||
@@ -1042,6 +1092,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#review-handoff-status').textContent = '';
|
||||
qs('#review-submit-status').textContent = '';
|
||||
qs('#submit-review').disabled = true;
|
||||
closeInlineComposer();
|
||||
draft = null;
|
||||
reviewFiles = [];
|
||||
selectedReviewHead = '';
|
||||
@@ -1083,6 +1134,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
||||
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
||||
});
|
||||
document.querySelectorAll('.review-inline-target').forEach(target => {
|
||||
const anchor = inlineAnchor(target);
|
||||
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
|
||||
sameInlineAnchor(comment, anchor)
|
||||
));
|
||||
target.addEventListener('click', () => openInlineComposer(target));
|
||||
});
|
||||
document.querySelectorAll('.review-mark').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
||||
@@ -1114,6 +1172,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
draft = null;
|
||||
reviewFiles = [];
|
||||
selectedReviewHead = '';
|
||||
closeInlineComposer();
|
||||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||||
}
|
||||
|
||||
@@ -1481,6 +1540,32 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
||||
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
||||
qs('#save-inline-comment').addEventListener('click', () => {
|
||||
if (!draft || !activeInlineTarget) return;
|
||||
const body = qs('#review-inline-body').value.trim();
|
||||
if (!body) {
|
||||
qs('#review-inline-body').focus();
|
||||
return;
|
||||
}
|
||||
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
|
||||
activeInlineTarget.classList.add('has-draft');
|
||||
const target = activeInlineTarget;
|
||||
closeInlineComposer();
|
||||
target.focus();
|
||||
});
|
||||
qs('#delete-inline-comment').addEventListener('click', () => {
|
||||
if (!draft || !activeInlineTarget) return;
|
||||
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
|
||||
activeInlineTarget.classList.remove('has-draft');
|
||||
const target = activeInlineTarget;
|
||||
closeInlineComposer();
|
||||
target.focus();
|
||||
});
|
||||
qs('#cancel-inline-comment').addEventListener('click', () => {
|
||||
const target = activeInlineTarget;
|
||||
closeInlineComposer();
|
||||
target?.focus();
|
||||
});
|
||||
qs('#submit-review').addEventListener('click', async () => {
|
||||
if (!draft || !selectedReview || !selectedReviewHead) return;
|
||||
const snapshot = draft.snapshot();
|
||||
@@ -1496,6 +1581,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
decision: snapshot.decision,
|
||||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||||
expected_head_sha: selectedReviewHead,
|
||||
comments: snapshot.comments,
|
||||
});
|
||||
draft.clear();
|
||||
progress?.clear();
|
||||
|
||||
+94
-11
@@ -34,14 +34,54 @@ function diffLineClass(line) {
|
||||
return 'context';
|
||||
}
|
||||
|
||||
function parseDiffLines(lines) {
|
||||
let oldLine = null;
|
||||
let newLine = null;
|
||||
return (lines || []).map(value => {
|
||||
const text = String(value);
|
||||
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunk) {
|
||||
oldLine = Number(hunk[1]);
|
||||
newLine = Number(hunk[2]);
|
||||
return { text, kind: 'hunk', commentable: false };
|
||||
}
|
||||
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
|
||||
if (oldLine === null || newLine === null) {
|
||||
return { text, kind: diffLineClass(text), commentable: false };
|
||||
}
|
||||
if (text.startsWith('-')) {
|
||||
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
|
||||
oldLine += 1;
|
||||
return row;
|
||||
}
|
||||
if (text.startsWith('+')) {
|
||||
const row = { text, kind: 'added', commentable: true, new_position: newLine };
|
||||
newLine += 1;
|
||||
return row;
|
||||
}
|
||||
const row = { text, kind: 'context', commentable: true, new_position: newLine };
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiffFile(file, index, escapeHtml) {
|
||||
const panelId = 'review-diff-' + index;
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = (file.diff_lines || []).map(line =>
|
||||
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
|
||||
escapeHtml(String(line)) + '</span>'
|
||||
).join('');
|
||||
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||
if (!row.commentable) {
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
|
||||
}
|
||||
const position = row.old_position
|
||||
? ' data-old-position="' + row.old_position + '"'
|
||||
: ' data-new-position="' + row.new_position + '"';
|
||||
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||||
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||||
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||||
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
|
||||
}).join('');
|
||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||
'</pre>';
|
||||
@@ -115,17 +155,27 @@ function createProgress({ storage, repository, number, headSha, files }) {
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
||||
let draft = { notes: {}, summary: '', decision: 'comment' };
|
||||
let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || '{}');
|
||||
if (saved && typeof saved === 'object') {
|
||||
draft.notes = Object.fromEntries(filenames
|
||||
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
||||
.map(filename => [filename, saved.notes[filename]]));
|
||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) {
|
||||
draft.decision = saved.decision;
|
||||
if (Array.isArray(saved.comments)) {
|
||||
draft.comments = saved.comments.filter(comment =>
|
||||
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
|
||||
Boolean(comment.body.trim()) &&
|
||||
((Number.isInteger(comment.new_position) && !comment.old_position) ||
|
||||
(Number.isInteger(comment.old_position) && !comment.new_position))
|
||||
).map(comment => ({
|
||||
path: comment.path, body: comment.body,
|
||||
...(comment.new_position ? { new_position: comment.new_position } : {}),
|
||||
...(comment.old_position ? { old_position: comment.old_position } : {}),
|
||||
}));
|
||||
}
|
||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
|
||||
}
|
||||
} catch (error) { /* start with an empty in-memory draft */ }
|
||||
|
||||
@@ -134,7 +184,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
|
||||
return {
|
||||
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
|
||||
summary: draft.summary, decision: draft.decision,
|
||||
};
|
||||
}
|
||||
|
||||
function setNote(filename, note) {
|
||||
@@ -145,6 +198,35 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function inlineKey(comment) {
|
||||
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
|
||||
}
|
||||
|
||||
function setInlineComment(anchor, body) {
|
||||
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
|
||||
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
|
||||
? { new_position: anchor.new_position }
|
||||
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
|
||||
? { old_position: anchor.old_position } : null;
|
||||
const text = String(body || '').trim();
|
||||
if (!position || !text) return snapshot();
|
||||
const comment = { path: anchor.path, body: text, ...position };
|
||||
const key = inlineKey(comment);
|
||||
const index = draft.comments.findIndex(item => inlineKey(item) === key);
|
||||
if (index >= 0) draft.comments[index] = comment;
|
||||
else draft.comments.push(comment);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function removeInlineComment(anchor) {
|
||||
if (!anchor) return snapshot();
|
||||
const key = inlineKey(anchor);
|
||||
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
draft.summary = String(summary || '');
|
||||
persist();
|
||||
@@ -160,12 +242,12 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||
}
|
||||
|
||||
function clear() {
|
||||
draft = { notes: {}, summary: '', decision: 'comment' };
|
||||
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setSummary, setDecision, clear };
|
||||
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
@@ -208,6 +290,7 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
|
||||
Reference in new issue
Block a user