diff --git a/static/style.css b/static/style.css index b3890ebc..cfcefcf3 100644 --- a/static/style.css +++ b/static/style.css @@ -2474,6 +2474,9 @@ /* ── CSV table rendering ── */ .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 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;} diff --git a/static/ui.js b/static/ui.js index 236611fb..b8c3b9b2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -16894,7 +16894,18 @@ function loadDiffInline(container){ 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(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()); @@ -16909,13 +16920,19 @@ function buildCsvTablePreview(path, text){ const headers=rows[0].split(sep).map(c=>c.trim().replace(/^["']|["']$/g,'')); const bodyRows=rows.slice(1).map(r=>''+r.split(sep).map(c=>`${esc(c.trim().replace(/^["']|["']$/g,''))}`).join('')+'').join(''); const headerRow=headers.map(h=>`${esc(h)}`).join(''); + const fname=path.split('/').pop()||path; + const downloadLink=downloadUrl + ? `📎 ${esc(fname)}` + : ''; return { - html:`
${esc(path.split('/').pop())} ${t('csv_header_note')}
${headerRow}${bodyRows}
`, + html:`
${esc(fname)} ${t('csv_header_note')}${downloadLink}
${headerRow}${bodyRows}
`, }; } function _csvPreviewErrorHtml(path, errorKey){ - return `
${esc(path.split('/').pop())}
${t(errorKey)}
`; + const fname=path.split('/').pop()||path; + const downloadUrl=_csvMediaUrl(path,{download:true}); + return `
${esc(fname)}
📎 ${esc(fname)}
${t(errorKey)}
`; } function loadCsvInline(container){ @@ -16923,10 +16940,12 @@ function loadCsvInline(container){ root.querySelectorAll('.csv-inline-load:not([data-loaded])').forEach(el=>{ el.setAttribute('data-loaded','1'); 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(text=>{ - const preview=buildCsvTablePreview(path, text); + const preview=buildCsvTablePreview(path, text, downloadUrl); el.outerHTML=preview.html||_csvPreviewErrorHtml(path, preview.errorKey||'csv_error'); }) .catch(()=>{ diff --git a/tests/test_csv_table_rendering.py b/tests/test_csv_table_rendering.py index 2069a0cb..03f33230 100644 --- a/tests/test_csv_table_rendering.py +++ b/tests/test_csv_table_rendering.py @@ -65,7 +65,19 @@ def test_loadCsvInline_function(): with open('static/ui.js', encoding="utf-8") 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" + 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(): @@ -126,7 +138,7 @@ def test_csv_loadCsvInline_called_after_render(): 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" + 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")] 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")