diff --git a/README.md b/README.md index f9051f3..7767130 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository write permission. Native Comment, Approve, and Request changes reviews support head-scoped draft comments anchored to changed lines; the dashboard validates each comment path and submits the summary, decision, and inline comments in one review -request. The dashboard rechecks the current pull-request head, CI success, draft +request. On phone-width review sheets, changed lines wrap inside a stable old/new +line-number gutter by default so long source lines remain readable and commentable +without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving +horizontal inspection, and an explicit choice persists on the device across files, +review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable +until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft state, and mergeability immediately before every merge. Serve the dashboard only to trusted users on its own origin; cross-origin API access is intentionally disabled. Authentication defaults to fail-closed diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a307500..f72881b 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -187,9 +187,18 @@ textarea { resize: vertical; min-height: 120px; } .review-copy-fallback { min-height:140px; } .review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; } .review-progress-actions button { min-height:44px; max-width:100%; } +.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; } +.review-display-tools button { min-height:44px; flex:0 0 auto; } +.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; } .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-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; } +.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; } +.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; } +.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; } +.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; } +.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; } +.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; } +.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; } .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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index d6906db..3523262 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -237,6 +237,10 @@ return payload; } const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage }); + const wrapPreference = createReviewController.createWrapPreference({ + storage: localStorage, + mobile: window.matchMedia('(max-width: 600px)').matches, + }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const planningLoader = createIssueSheet.createPlanningLoader({ loadLabels: item => issueController.loadLabels(item), @@ -1765,6 +1769,14 @@ ); } + function applyReviewWrap(snapshot = wrapPreference.snapshot()) { + const reviewFilesElement = qs('#review-files'); + const button = qs('#review-wrap-lines'); + reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped); + button.setAttribute('aria-pressed', String(snapshot.wrapped)); + button.textContent = snapshot.wrapped ? 'Lines wrapped' : 'Wrap lines'; + } + function showReviewProgress(snapshot) { const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total; qs('#review-progress').textContent = complete ? @@ -2582,6 +2594,7 @@ qs('#retry-review-load').hidden = true; qs('#review-sheet-body').textContent = ''; qs('#review-files').textContent = ''; + applyReviewWrap(); qs('#review-progress').textContent = '0 of 0 files reviewed'; qs('#next-unreviewed-review').disabled = true; qs('#review-history').textContent = ''; @@ -3910,6 +3923,10 @@ qs('#next-unreviewed-review').addEventListener('click', () => { if (progress) openNextUnreviewed(progress.snapshot()); }); + qs('#review-wrap-lines').addEventListener('click', () => { + const next = !wrapPreference.snapshot().wrapped; + applyReviewWrap(wrapPreference.setWrapped(next)); + }); 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', () => { diff --git a/frontend/index.html b/frontend/index.html index 813ac2c..156ce30 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -539,6 +539,10 @@
' + lines +
(file.diff_truncated ? 'Preview truncated ยท open in Gitea for the full diff.' : '') +
@@ -325,6 +356,7 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
}
createReviewController.renderDiffFile = renderDiffFile;
+createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index e0b7cb8..f5e8fd4 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -3950,12 +3950,45 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
assert 'data-old-position="1"' in output["html"]
assert 'data-new-position="1"' in output["html"]
+ assert '' in output["html"]
+ assert '' in output["html"]
+ assert '-old <token>' in output["html"]
+ assert '+new & safe' in output["html"]
assert 'aria-label="Comment on src/<api>.py line 1"' in output["html"]
assert 'Mark reviewed' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
+def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
+ script = f"""
+const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
+const values = new Map();
+const storage = {{
+ getItem: key => values.has(key) ? values.get(key) : null,
+ setItem: (key, value) => values.set(key, value),
+}};
+const phone = reviewSheet.createWrapPreference({{storage, mobile: true}});
+const initial = phone.snapshot();
+const disabled = phone.setWrapped(false);
+const restoredPhone = reviewSheet.createWrapPreference({{storage, mobile: true}}).snapshot();
+const restoredDesktop = reviewSheet.createWrapPreference({{storage, mobile: false}}).snapshot();
+process.stdout.write(JSON.stringify({{initial, disabled, restoredPhone, restoredDesktop,
+ stored: values.get('stackchain.review-wrap.v1')}}));
+"""
+ result = subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ )
+
+ assert json.loads(result.stdout) == {
+ "initial": {"wrapped": True, "explicit": False},
+ "disabled": {"wrapped": False, "explicit": True},
+ "restoredPhone": {"wrapped": False, "explicit": True},
+ "restoredDesktop": {"wrapped": False, "explicit": True},
+ "stored": "false",
+ }
+
+
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -3973,14 +4006,14 @@ process.stdout.write(JSON.stringify(rows));
assert json.loads(result.stdout) == [
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
- {"text": " context", "kind": "context", "commentable": True, "new_position": 20},
- {"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
- {"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
- {"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
+ {"text": " context", "kind": "context", "commentable": True, "old_line": 10, "new_line": 20, "new_position": 20},
+ {"text": "-removed", "kind": "removed", "commentable": True, "old_line": 11, "old_position": 11},
+ {"text": "+added", "kind": "added", "commentable": True, "new_line": 21, "new_position": 21},
+ {"text": "+second", "kind": "added", "commentable": True, "new_line": 22, "new_position": 22},
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
- {"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
- {"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
+ {"text": "-old tail", "kind": "removed", "commentable": True, "old_line": 40, "old_position": 40},
+ {"text": "+new tail", "kind": "added", "commentable": True, "new_line": 51, "new_position": 51},
]
@@ -4207,6 +4240,25 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
assert '.review-diff' in html and 'overflow-x:auto' in html
+@pytest.mark.anyio
+async def test_mobile_review_wraps_long_lines_with_gutters_and_persisted_toggle():
+ html = await dashboard()
+
+ assert 'id="review-wrap-lines"' in html
+ assert 'aria-pressed="false"' in html
+ assert 'aria-controls="review-files"' in html
+ assert "createReviewController.createWrapPreference" in html
+ assert "matchMedia('(max-width: 600px)').matches" in html
+ assert "reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped)" in html
+ assert "wrapPreference.setWrapped" in html
+ assert '.review-display-tools button' in html and 'min-height:44px' in html
+ assert '.review-line-numbers' in html and 'grid-template-columns:4ch 4ch' in html
+ assert '.review-line-code' in html and 'overflow-wrap:anywhere' in html
+ assert '.wrap-lines .review-diff' in html and 'overflow-x:hidden' in html
+ assert '.wrap-lines .review-diff-line' in html and 'min-width:0' in html
+ assert '.wrap-lines .review-line-code' in html and 'white-space:pre-wrap' in html
+
+
@pytest.mark.anyio
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
html = await dashboard()