fix(csv): keep media downloads visible in previews

This commit is contained in:
Anthony Ruiz committed 2026-07-08 19:51:38 +00:00
1 parent 16272dd646
commit 87a9e25e41
3 files changed
+41 -7

No files matched your search

+3
View File
@@ -2474,6 +2474,9 @@
/* ── CSV table rendering ── */ /* ── CSV table rendering ── */
.csv-table-wrap{margin:6px 0;overflow-x:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface);} .csv-table-wrap{margin:6px 0;overflow-x:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface);}
.csv-preview-header{justify-content:space-between;flex-wrap:wrap;row-gap:6px;}
.csv-preview-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.csv-download-link{margin-left:auto;text-transform:none;letter-spacing:0;font-size:12px;}
.csv-table{width:100%;border-collapse:collapse;font-size:13px;} .csv-table{width:100%;border-collapse:collapse;font-size:13px;}
.csv-table thead{position:sticky;top:0;z-index:1;} .csv-table thead{position:sticky;top:0;z-index:1;}
.csv-table th{background:var(--hover-bg);font-weight:600;text-align:left;padding:8px 12px;border-bottom:2px solid var(--border2);white-space:nowrap;user-select:none;} .csv-table th{background:var(--hover-bg);font-weight:600;text-align:left;padding:8px 12px;border-bottom:2px solid var(--border2);white-space:nowrap;user-select:none;}
+24 -5
View File
@@ -16894,7 +16894,18 @@ function loadDiffInline(container){
const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering const CSV_MAX_SIZE=256*1024; // 256 KB cap for inline CSV rendering
function buildCsvTablePreview(path, text){ function _mediaSessionQuery(){
const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):'';
return mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'';
}
function _csvMediaUrl(path, opts={}){
let url='api/media?path='+encodeURIComponent(path)+_mediaSessionQuery();
if(opts.download) url+='&download=1';
return url;
}
function buildCsvTablePreview(path, text, downloadUrl=''){
if(typeof text!=='string') return {errorKey:'csv_error'}; if(typeof text!=='string') return {errorKey:'csv_error'};
if(text.length>CSV_MAX_SIZE) return {errorKey:'csv_too_large'}; 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()); const rows=text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(r=>r.trim());
@@ -16909,13 +16920,19 @@ function buildCsvTablePreview(path, text){
const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,'')); 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 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(''); const headerRow=headers.map(h=>`<th>${esc(h)}</th>`).join('');
const fname=path.split('/').pop()||path;
const downloadLink=downloadUrl
? `<a class="csv-download-link msg-media-link" href="${esc(downloadUrl)}" download="${esc(fname)}">📎 ${esc(fname)}</a>`
: '';
return { 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>`, html:`<div class="csv-table-wrap"><div class="pre-header csv-preview-header"><span class="csv-preview-title">${esc(fname)} <span style="opacity:.5;font-size:11px">${t('csv_header_note')}</span></span>${downloadLink}</div><table class="csv-table"><thead><tr>${headerRow}</tr></thead><tbody>${bodyRows}</tbody></table></div>`,
}; };
} }
function _csvPreviewErrorHtml(path, errorKey){ 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>`; const fname=path.split('/').pop()||path;
const downloadUrl=_csvMediaUrl(path,{download:true});
return `<div class="diff-inline-error">${esc(fname)}<br><a class="msg-media-link" href="${esc(downloadUrl)}" download="${esc(fname)}">📎 ${esc(fname)}</a><br><span style="color:var(--muted);font-size:12px">${t(errorKey)}</span></div>`;
} }
function loadCsvInline(container){ function loadCsvInline(container){
@@ -16923,10 +16940,12 @@ function loadCsvInline(container){
root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{ root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{
el.setAttribute('data-loaded','1'); el.setAttribute('data-loaded','1');
const path=el.dataset.path; const path=el.dataset.path;
fetch('api/media?path='+encodeURIComponent(path)) const mediaUrl=_csvMediaUrl(path);
const downloadUrl=_csvMediaUrl(path,{download:true});
fetch(mediaUrl)
.then(r=>{if(!r.ok) throw new Error(r.status);return r.text();}) .then(r=>{if(!r.ok) throw new Error(r.status);return r.text();})
.then(text=>{ .then(text=>{
const preview=buildCsvTablePreview(path, text); const preview=buildCsvTablePreview(path, text, downloadUrl);
el.outerHTML=preview.html||_csvPreviewErrorHtml(path, preview.errorKey||'csv_error'); el.outerHTML=preview.html||_csvPreviewErrorHtml(path, preview.errorKey||'csv_error');
}) })
.catch(()=>{ .catch(()=>{
+14 -2
View File
@@ -65,7 +65,19 @@ def test_loadCsvInline_function():
with open('static/ui.js', encoding="utf-8") as f: with open('static/ui.js', encoding="utf-8") as f:
src = f.read() src = f.read()
assert 'function loadCsvInline' in src, "Missing loadCsvInline function" assert 'function loadCsvInline' in src, "Missing loadCsvInline function"
assert 'function buildCsvTablePreview(path, text)' in src, "Missing shared CSV preview helper" assert 'function buildCsvTablePreview(path, text, downloadUrl' in src, "Missing shared CSV preview helper"
assert 'function _csvMediaUrl(path, opts={})' in src, "Missing CSV media URL helper"
def test_csv_media_file_keeps_download_affordance():
"""MEDIA: CSV preview must keep a visible downloadable attachment link."""
with open('static/ui.js', encoding="utf-8") as f:
src = f.read()
csv_section = src[src.find('function buildCsvTablePreview'):src.find('function loadCsvInline') + 1600]
assert 'csv-download-link msg-media-link' in csv_section
assert '_csvMediaUrl(path,{download:true})' in src
assert 'download="${esc(fname)}"' in csv_section
assert 'buildCsvTablePreview(path, text, downloadUrl)' in src
def test_csv_inline_max_size(): def test_csv_inline_max_size():
@@ -126,7 +138,7 @@ def test_csv_loadCsvInline_called_after_render():
body = src[idx:idx + 500] body = src[idx:idx + 500]
assert 'loadCsvInline(container)' in body, "post-process should call loadCsvInline once per render" 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] 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" assert 'buildCsvTablePreview(path, text, downloadUrl)' 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")] 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'){") 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") generic_pos = open_file.find("} else {\n // Plain code / text -- but fall back to download if server signals binary")