Merge pull request #4174 from nesquena/stage-uxnight
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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;}
|
||||
|
||||
113
static/ui.js
113
static/ui.js
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ? `💾 ${t('save')}` : `✎ ${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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
82
tests/test_issue4075_mermaid_lightbox.py
Normal file
82
tests/test_issue4075_mermaid_lightbox.py
Normal 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
|
||||
Reference in New Issue
Block a user