Merge pull request #4174 from nesquena/stage-uxnight
Some checks failed
Release & Docker / release (push) Has been cancelled

Release NW (v0.51.410): chat Mermaid lightbox (#4075) + workspace CSV table preview (#4025)
This commit is contained in:
nesquena-hermes
2026-06-13 23:21:12 -07:00
committed by GitHub
7 changed files with 317 additions and 61 deletions

View File

@@ -3,6 +3,13 @@
## [Unreleased]
## [v0.51.410] — 2026-06-14 — Release NW (chat Mermaid lightbox + workspace CSV table preview, #4075/#4025)
### Added
- **Rendered Mermaid diagrams can now be enlarged in chat (#4075).** Clicking a Mermaid diagram opens it in the existing fullscreen lightbox so larger graphs are readable without leaving the conversation. (#4075)
- **CSV files now preview as formatted tables in the workspace (#4025).** The workspace file preview reuses the existing chat CSV table renderer (with a 256 KB cap and clear error states for oversized/empty/malformed files) instead of showing raw comma-separated text, matching how CSVs already render inline in messages. (#4025)
## [v0.51.409] — 2026-06-14 — Release NV (provider-gate title-gen reasoning extra_body, #4161/#2083)
### Fixed

View File

@@ -1741,6 +1741,7 @@
.img-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:lb-in .15s ease;}
@keyframes lb-in{from{opacity:0}to{opacity:1}}
.img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.img-lightbox .mermaid-lightbox-svg{max-width:90vw;max-height:90vh;width:auto;height:auto;display:block;background:var(--code-bg);box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;}
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
.img-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,opacity .15s;z-index:1;opacity:.6;}
@@ -4559,7 +4560,7 @@ main.main > #mainPlugin{display:none;}
/* ── Mermaid diagrams ── */
.mermaid-block{background:var(--code-bg);border-radius:8px;padding:16px;margin:8px 0;overflow-x:auto;}
.mermaid-rendered{background:transparent;padding:8px 0;}
.mermaid-rendered svg{max-width:100%;height:auto;}
.mermaid-rendered svg{max-width:100%;height:auto;cursor:zoom-in;}
/* ── Session projects ── */
.session-source-tabs{display:flex;gap:4px;padding:4px 10px 8px;flex-shrink:0;}

View File

@@ -675,10 +675,70 @@ function _openImgLightbox(imgEl) {
}
_openImgLightboxWithNav(src, alt, allImages, startIndex);
}
function _openMermaidLightbox(svgEl) {
if(!svgEl) return;
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-label', 'Mermaid diagram');
const clone = svgEl.cloneNode(true);
const idMap = new Map();
const idPrefix = 'mermaid-lightbox-'+Math.random().toString(36).slice(2,10)+'-';
const idNodes = [clone, ...clone.querySelectorAll('[id]')].filter(el => el.id);
idNodes.forEach(el => {
const nextId = idPrefix + el.id;
idMap.set(el.id, nextId);
el.id = nextId;
});
if(idMap.size){
const refAttrs = ['href','xlink:href','fill','stroke','filter','clip-path','mask','marker-start','marker-mid','marker-end','aria-labelledby','aria-describedby'];
[clone, ...clone.querySelectorAll('*')].forEach(el => {
refAttrs.forEach(attr => {
const value = el.getAttribute(attr);
if(!value) return;
let nextValue = value.replace(/url\(#([^)]+)\)/g, (match, refId) => idMap.has(refId) ? `url(#${idMap.get(refId)})` : match);
if(nextValue.startsWith('#') && idMap.has(nextValue.slice(1))){
nextValue = '#'+idMap.get(nextValue.slice(1));
}
if(nextValue !== value){
el.setAttribute(attr, nextValue);
}
});
});
clone.querySelectorAll('style').forEach(styleEl => {
let styleText = styleEl.textContent || '';
idMap.forEach((nextId, originalId) => {
const escapedId = originalId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
styleText = styleText.replace(new RegExp(`url\\(#${escapedId}\\)`, 'g'), `url(#${nextId})`);
styleText = styleText.replace(new RegExp(`(^|[^\\w-])#${escapedId}(?=$|[^\\w-])`, 'g'), (match, prefix) => `${prefix}#${nextId}`);
});
styleEl.textContent = styleText;
});
}
clone.classList.add('mermaid-lightbox-svg');
clone.removeAttribute('width');
clone.removeAttribute('height');
clone.onclick = e => e.stopPropagation();
const cls = document.createElement('button');
cls.className = 'img-lightbox-close';
cls.setAttribute('aria-label', 'Close');
cls.textContent = '×';
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(clone);
lb.appendChild(cls);
lb.onclick = () => _closeImgLightbox(lb);
lb._keyHandler = e => {
if(e.key==='Escape') _closeImgLightbox(lb);
};
document.body.appendChild(lb);
document.addEventListener('keydown', lb._keyHandler);
}
function _openImgLightboxWithNav(src, alt, images, index) {
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-label', alt || 'Image');
const img = document.createElement('img');
img.src = src;
@@ -772,6 +832,8 @@ document.addEventListener('click', e => {
// Message-attached images (already wired since v0.50.x).
let img = e.target.closest('.msg-media-img');
if(img){ _openImgLightbox(img); return; }
const mermaidSvg = e.target.closest('.mermaid-rendered svg');
if(mermaidSvg){ _openMermaidLightbox(mermaidSvg); return; }
// Composer attach-tray image thumbnails — click any pasted/dropped image
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
@@ -11195,8 +11257,33 @@ function loadDiffInline(container){
});
}
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
function buildCsvTablePreview(path, text){
if(typeof text!=='string') return {errorKey:'csv_error'};
if(text.length>CSV_MAX_SIZE) return {errorKey:'csv_too_large'};
const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim());
if(rows.length<2) return {errorKey:'csv_no_data'};
// Auto-detect separator (comma, semicolon, tab)
// Heuristic: uses the first separator found in the header row. Edge case:
// quoted fields containing commas without non-quoted commas in the header
// could cause misdetection — acceptable trade-off for a preview renderer.
const firstLine=rows[0];
const separators=[',',';','\t'];
const sep=separators.find(s=>firstLine.includes(s))||',';
const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,''));
const bodyRows=rows.slice(1).map(r=>'<tr>'+r.split(sep).map(c=>`<td>${esc(c.trim().replace(/^["']|["']$/g,''))}</td>`).join('')+'</tr>').join('');
const headerRow=headers.map(h=>`<th>${esc(h)}</th>`).join('');
return {
html:`<div class="csv-table-wrap"><div class="pre-header">${esc(path.split('/').pop())} <span style="opacity:.5;font-size:11px">${t('csv_header_note')}</span></div><table class="csv-table"><thead><tr>${headerRow}</tr></thead><tbody>${bodyRows}</tbody></table></div>`,
};
}
function _csvPreviewErrorHtml(path, errorKey){
return `<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t(errorKey)}</span></div>`;
}
function loadCsvInline(container){
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
const root=container||document;
root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1');
@@ -11204,29 +11291,11 @@ function loadCsvInline(container){
fetch('api/media?path='+encodeURIComponent(path))
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
.then(text=>{
if(text.length>CSV_MAX_SIZE){
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_too_large')}</span></div>`;
return;
}
const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim());
if(rows.length<2){
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_no_data')}</span></div>`;
return;
}
// Auto-detect separator (comma, semicolon, tab)
// Heuristic: uses the first separator found in the header row. Edge case:
// quoted fields containing commas without non-quoted commas in the header
// could cause misdetection — acceptable trade-off for a preview renderer.
const firstLine=rows[0];
const separators=[',',';','\t'];
let sep=separators.find(s=>firstLine.includes(s))||',';
const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,''));
const bodyRows=rows.slice(1).map(r=>'<tr>'+r.split(sep).map(c=>`<td>${esc(c.trim().replace(/^["']|["']$/g,''))}</td>`).join('')+'</tr>').join('');
const headerRow=headers.map(h=>`<th>${esc(h)}</th>`).join('');
el.outerHTML=`<div class="csv-table-wrap"><div class="pre-header">${esc(path.split('/').pop())} <span style="opacity:.5;font-size:11px">${t('csv_header_note')}</span></div><table class="csv-table"><thead><tr>${headerRow}</tr></thead><tbody>${bodyRows}</tbody></table></div>`;
const preview=buildCsvTablePreview(path, text);
el.outerHTML=preview.html||_csvPreviewErrorHtml(path, preview.errorKey||'csv_error');
})
.catch(()=>{
el.outerHTML=`<div class="diff-inline-error">${esc(path.split('/').pop())}<br><span style="color:var(--muted);font-size:12px">${t('csv_error')}</span></div>`;
el.outerHTML=_csvPreviewErrorHtml(path, 'csv_error');
});
});
}

View File

@@ -507,6 +507,51 @@ function renderMarkdownPreviewContent(data){
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
}
function renderCodePreviewContent(path, content){
showPreview('code');
const codeEl=document.createElement('code');
codeEl.textContent=content;
const lang=_prismLanguageForPath(path);
if(lang) codeEl.className='language-'+lang;
const pre=$('previewCode');
pre.textContent='';
// Prism.highlightElement() propagates the language-* class onto the
// parent <pre>, so a previously-previewed code file leaves e.g.
// "language-css" on #previewCode. A subsequent plain-text file builds a
// class-less <code>, and Prism walks up to that stale ancestor class and
// mis-highlights prose. Strip any inherited language-* token from the
// <pre> before each render so highlighting never leaks across files.
pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
pre.appendChild(codeEl);
// Only invoke Prism when we actually assigned a language; otherwise the
// class-less <code> would inherit any ancestor language-* class.
if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
Prism.highlightElement(codeEl);
}
}
function renderCsvPreviewContent(path, content){
if(typeof buildCsvTablePreview!=='function') return false;
const preview=buildCsvTablePreview(path, content);
if(!preview) return false;
showPreview('csv');
// Preserve the raw CSV text so the Edit flow can repopulate the textarea and
// a save can re-render the table from the edited source (#4025 review, Codex).
if(typeof content==='string'){
_previewRawContent = content;
_previewRawContentPath = path;
}
if(preview.html){
$('previewMd').innerHTML=preview.html;
return true;
}
if(preview.errorKey&&typeof _csvPreviewErrorHtml==='function'){
$('previewMd').innerHTML=_csvPreviewErrorHtml(path, preview.errorKey);
return true;
}
return false;
}
function forceRenderMarkdownPreview(){
// #3378 review (Codex): don't force-render from a dirty/open editor — the
// cached raw content would not reflect the unsaved edit. Require a saved,
@@ -518,21 +563,21 @@ function forceRenderMarkdownPreview(){
}
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
let _previewCurrentMode = ''; // 'code' | 'csv' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
let _previewDirty = false; // true when edits are unsaved
function showPreview(mode){
// mode: 'code' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
// mode: 'code' | 'csv' | 'image' | 'md' | 'html' | 'pdf' | 'audio' | 'video'
$('previewCode').style.display = mode==='code' ? '' : 'none';
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
const mediaWrap=$('previewMediaWrap'); if(mediaWrap) mediaWrap.style.display = (mode==='audio'||mode==='video') ? '' : 'none';
const pdfWrap=$('previewPdfWrap'); if(pdfWrap) pdfWrap.style.display = mode==='pdf' ? '' : 'none';
$('previewMd').style.display = mode==='md' ? '' : 'none';
$('previewMd').style.display = (mode==='md'||mode==='csv') ? '' : 'none';
$('previewHtmlWrap').style.display = mode==='html' ? '' : 'none';
$('previewEditArea').style.display = 'none'; // start in read-only
const badge=$('previewBadge');
badge.className='preview-badge '+mode;
badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
badge.textContent = mode==='image'?'image':mode==='audio'?'audio':mode==='video'?'video':mode==='pdf'?'pdf':mode==='csv'?'csv':mode==='md'?'md':mode==='html'?'html':fileExt($('previewPathText').textContent)||'text';
_previewCurrentMode = mode;
_previewDirty = false;
updateEditBtn();
@@ -545,7 +590,7 @@ function showPreview(mode){
function updateEditBtn(){
const btn=$('btnEditFile');
if(!btn)return;
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv';
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? `&#128190; ${t('save')}` : `&#9998; ${t('edit')}`;
@@ -571,6 +616,7 @@ async function toggleEditMode(){
_previewRawContent = content;
_previewRawContentPath = _previewCurrentPath;
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
else if(_previewCurrentMode==='csv') renderCsvPreviewContent(_previewCurrentPath, content);
else renderMarkdownPreviewContent({content});
$('previewEditArea').style.display='none';
if(_previewCurrentMode==='code') $('previewCode').style.display='';
@@ -725,6 +771,18 @@ async function openFile(path, opts={}){
iframe.src=''; // clear first to avoid stale content
iframe.src=url;
}
} else if(ext==='.csv'){
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
if(data.binary){
downloadFile(path);
return;
}
if(renderCsvPreviewContent(path, data.content)) return;
renderCodePreviewContent(path, data.content);
}catch(e){
downloadFile(path);
}
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
@@ -734,27 +792,7 @@ async function openFile(path, opts={}){
downloadFile(path);
return;
}
showPreview('code');
// Syntax highlighting with Prism.js (already loaded on the page).
const codeEl=document.createElement('code');
codeEl.textContent=data.content;
const lang=_prismLanguageForPath(path);
if(lang) codeEl.className='language-'+lang;
const pre=$('previewCode');
pre.textContent='';
// Prism.highlightElement() propagates the language-* class onto the
// parent <pre>, so a previously-previewed code file leaves e.g.
// "language-css" on #previewCode. A subsequent plain-text file builds a
// class-less <code>, and Prism walks up to that stale ancestor class and
// mis-highlights prose. Strip any inherited language-* token from the
// <pre> before each render so highlighting never leaks across files.
pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
pre.appendChild(codeEl);
// Only invoke Prism when we actually assigned a language; otherwise the
// class-less <code> would inherit any ancestor language-* class.
if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
Prism.highlightElement(codeEl);
}
renderCodePreviewContent(path, data.content);
}catch(e){
// If it's a 400/too-large error, offer download instead
downloadFile(path);

View File

@@ -1,5 +1,8 @@
"""Test: CSV table rendering (#485)"""
import re
from pathlib import Path
WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
def test_csv_extension_regex():
@@ -47,6 +50,12 @@ def test_csv_media_file_handler():
src = f.read()
assert 'csv-inline-load' in src, "Missing csv-inline-load class for MEDIA: CSV"
assert 'csv_loading' in src, "Missing csv_loading i18n key usage"
open_file = WORKSPACE_JS[WORKSPACE_JS.index("async function openFile(path, opts={}){"):WORKSPACE_JS.index("\nfunction downloadFile")]
csv_pos = open_file.find("} else if(ext==='.csv'){")
generic_pos = open_file.find("} else {\n // Plain code / text -- but fall back to download if server signals binary")
assert csv_pos != -1, "openFile() should handle .csv before the generic code branch"
assert generic_pos != -1, "generic code branch missing from openFile()"
assert csv_pos < generic_pos
def test_loadCsvInline_function():
@@ -54,22 +63,23 @@ def test_loadCsvInline_function():
with open('static/ui.js') as f:
src = f.read()
assert 'function loadCsvInline' in src, "Missing loadCsvInline function"
assert 'function buildCsvTablePreview(path, text)' in src, "Missing shared CSV preview helper"
def test_csv_inline_max_size():
"""Verify CSV inline rendering has a size cap."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
assert 'CSV_MAX_SIZE' in csv_section, "Should have CSV_MAX_SIZE constant"
assert 'csv_too_large' in csv_section, "Should use csv_too_large i18n for oversized files"
assert 'const CSV_MAX_SIZE=256*1024' in src, "Should have CSV_MAX_SIZE constant"
helper_section = src[src.find('function buildCsvTablePreview'):src.find('function buildCsvTablePreview') + 2000]
assert 'csv_too_large' in helper_section, "Should use csv_too_large i18n for oversized files"
def test_csv_auto_detect_separator():
"""Verify CSV handler auto-detects separator."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
csv_section = src[src.find('function buildCsvTablePreview'):src.find('function buildCsvTablePreview') + 2000]
assert 'separators' in csv_section, "Should have separator detection"
assert ';' in csv_section, "Should detect semicolon separator"
assert 'tab' in csv_section.lower() or '\\t' in csv_section, "Should detect tab separator"
@@ -86,9 +96,14 @@ def test_csv_error_handling():
"""Verify CSV error and empty data handling."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2500]
csv_section = src[src.find('function buildCsvTablePreview'):src.find('function loadCsvInline') + 1000]
assert 'csv_error' in csv_section, "Should use csv_error i18n on fetch failure"
assert 'csv_no_data' in csv_section, "Should use csv_no_data i18n for insufficient data"
helper_start = WORKSPACE_JS.index("function renderCsvPreviewContent(path, content){")
helper_end = WORKSPACE_JS.index("\nfunction forceRenderMarkdownPreview", helper_start)
helper_body = WORKSPACE_JS[helper_start:helper_end]
assert "if(preview.errorKey&&typeof _csvPreviewErrorHtml==='function'){" in helper_body
assert "$('previewMd').innerHTML=_csvPreviewErrorHtml(path, preview.errorKey);" in helper_body
def test_csv_loadCsvInline_called_after_render():
@@ -99,13 +114,28 @@ def test_csv_loadCsvInline_called_after_render():
idx = src.find('function postProcessRenderedMessages')
body = src[idx:idx + 500]
assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render"
load_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 1200]
assert 'buildCsvTablePreview(path, text)' in load_section, "Inline loader should reuse the shared helper"
open_file = WORKSPACE_JS[WORKSPACE_JS.index("async function openFile(path, opts={}){"):WORKSPACE_JS.index("\nfunction downloadFile")]
csv_pos = open_file.find("} else if(ext==='.csv'){")
generic_pos = open_file.find("} else {\n // Plain code / text -- but fall back to download if server signals binary")
branch = open_file[csv_pos:generic_pos]
assert "if(renderCsvPreviewContent(path, data.content)) return;" in branch
assert "renderCodePreviewContent(path, data.content);" in branch
assert "showPreview('csv');" in WORKSPACE_JS
assert "$('previewMd').innerHTML=preview.html;" in WORKSPACE_JS
assert "(mode==='md'||mode==='csv')" in WORKSPACE_JS
assert "mode==='csv'?'csv'" in WORKSPACE_JS
# csv files keep the workspace Edit affordance (regression #4025: previously
# csv fell through to the code preview which exposed the Edit button).
assert "_previewCurrentMode==='md'||_previewCurrentMode==='csv'" in WORKSPACE_JS
def test_csv_line_ending_normalization():
"""Verify CSV handler normalizes line endings."""
with open('static/ui.js') as f:
src = f.read()
csv_section = src[src.find('function loadCsvInline'):src.find('function loadCsvInline') + 2000]
csv_section = src[src.find('function buildCsvTablePreview'):src.find('function buildCsvTablePreview') + 2000]
assert '\\r\\n' in csv_section, "Should handle \\r\\n line endings"
assert '\\r' in csv_section, "Should handle \\r line endings"
@@ -139,3 +169,24 @@ def test_csv_not_matched_by_image_exts():
assert match
exts = match.group(1)
assert 'csv' not in exts.lower(), ".csv should NOT be in _IMAGE_EXTS"
def test_csv_preview_preserves_edit_flow():
"""Regression (#4025 review): the CSV table preview must not strip the
workspace Edit affordance that .csv had when it fell through to the code
preview. csv mode must be editable, the preview must cache raw content for
the textarea, and a save must re-render the table (not markdown)."""
with open('static/workspace.js') as f:
src = f.read()
# csv mode is editable
assert "_previewCurrentMode==='csv'" in src, "csv mode should be editable / handled in workspace edit flow"
edit_btn = src[src.find('function updateEditBtn'):src.find('function updateEditBtn') + 400]
assert "==='csv'" in edit_btn, "updateEditBtn must allow editing csv mode"
# renderCsvPreviewContent caches the raw text for the edit textarea
csv_render = src[src.find('function renderCsvPreviewContent'):src.find('function renderCsvPreviewContent') + 700]
assert '_previewRawContent = content' in csv_render or '_previewRawContent=content' in csv_render, \
"renderCsvPreviewContent must cache raw CSV content for the edit flow"
# save path re-renders the CSV table for csv mode
save_section = src[src.find('async function toggleEditMode'):src.find('async function toggleEditMode') + 1200]
assert "renderCsvPreviewContent(_previewCurrentPath, content)" in save_section, \
"saving an edited CSV must re-render the table, not markdown"

View File

@@ -18,11 +18,19 @@ def _open_file_body() -> str:
return WORKSPACE_JS[start:start + 8000]
def _code_preview_helper() -> str:
start = WORKSPACE_JS.index("function renderCodePreviewContent(path, content){")
end = WORKSPACE_JS.index("\nfunction renderCsvPreviewContent(path, content){", start)
return WORKSPACE_JS[start:end]
def test_workspace_preview_assigns_prism_language_class():
body = _open_file_body()
assert "_prismLanguageForPath(path)" in body
assert "codeEl.className='language-'+lang" in body
assert "Prism.highlightElement(codeEl)" in body
helper = _code_preview_helper()
assert "renderCodePreviewContent(path, data.content);" in body
assert "_prismLanguageForPath(path)" in helper
assert "codeEl.className='language-'+lang" in helper
assert "Prism.highlightElement(codeEl)" in helper
def test_prism_language_map_covers_common_extensions():
@@ -61,12 +69,12 @@ def test_plain_text_files_do_not_inherit_prior_file_highlighting():
2. Prism.highlightElement is only called when a non-empty language was
assigned, so a class-less <code> never walks up to an ancestor class.
"""
body = _open_file_body()
helper = _code_preview_helper()
# Guard 1: stale language-* token stripped from the <pre> before append.
assert "pre.className=pre.className.replace(/\\blanguage-\\S+/g,'')" in body, (
assert "pre.className=pre.className.replace(/\\blanguage-\\S+/g,'')" in helper, (
"previewCode <pre> must have stale language-* classes stripped each render"
)
# Guard 2: highlightElement gated on a truthy lang.
assert "if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function')" in body, (
assert "if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function')" in helper, (
"Prism.highlightElement must only run when a language was assigned"
)

View File

@@ -0,0 +1,82 @@
"""Static regression coverage for Mermaid diagram lightbox wiring."""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parent.parent
UI = ROOT / "static" / "ui.js"
STYLE = ROOT / "static" / "style.css"
def _ui_js() -> str:
return UI.read_text(encoding="utf-8")
def _style_css() -> str:
return STYLE.read_text(encoding="utf-8")
class TestMermaidLightboxHelper:
def test_mermaid_lightbox_has_dedicated_helper(self):
src = _ui_js()
assert re.search(r"function\s+_openMermaidLightbox\(svgEl\)\s*\{", src)
assert "svgEl.cloneNode(true)" in src
assert "mermaid-lightbox-svg" in src
def test_mermaid_lightbox_reuses_existing_modal_chrome(self):
src = _ui_js()
assert "img-lightbox" in src
assert "img-lightbox-close" in src
assert "_closeImgLightbox(lb)" in src
def test_mermaid_lightbox_rewrites_cloned_svg_ids(self):
src = _ui_js()
assert "const idMap = new Map();" in src
assert "const idPrefix = 'mermaid-lightbox-'" in src
assert "replace(/url\\(#([^)]+)\\)/g" in src
def test_mermaid_lightbox_rewrites_embedded_style_selectors(self):
src = _ui_js()
assert "clone.querySelectorAll('style').forEach(styleEl => {" in src
assert "styleText = styleText.replace(new RegExp(`url\\\\(#${escapedId}\\\\)`" in src
assert "styleText = styleText.replace(new RegExp(`(^|[^\\\\w-])#${escapedId}(?=$|[^\\\\w-])`" in src
class TestDocumentClickDelegate:
def test_delegate_routes_rendered_mermaid_svgs_before_attach_thumb(self):
src = _ui_js()
mermaid_branch = (
" const mermaidSvg = e.target.closest('.mermaid-rendered svg');\n"
" if(mermaidSvg){ _openMermaidLightbox(mermaidSvg); return; }\n"
)
attach_branch = (
" img = e.target.closest('.attach-thumb');\n"
" if(img && img.tagName === 'IMG'){\n"
)
assert mermaid_branch in src
assert attach_branch in src
assert src.index(mermaid_branch) < src.index(attach_branch)
def test_delegate_still_handles_message_images(self):
src = _ui_js()
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img); return; }"
assert msg_branch in src
class TestMermaidLightboxCss:
def test_rendered_mermaid_svg_advertises_zoom(self):
src = _style_css()
assert ".mermaid-rendered svg{max-width:100%;height:auto;cursor:zoom-in;}" in src
def test_lightbox_svg_uses_modal_viewport_limits(self):
src = _style_css()
rule = ".img-lightbox .mermaid-lightbox-svg{max-width:90vw;max-height:90vh;"
assert rule in src
assert "background:var(--code-bg);" in src
class TestLightboxAria:
def test_lightboxes_set_aria_modal(self):
src = _ui_js()
assert src.count("setAttribute('aria-modal', 'true')") >= 2