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:``,
+ html:``,
};
}
function _csvPreviewErrorHtml(path, errorKey){
- return `${esc(path.split('/').pop())}
${t(errorKey)}
`;
+ const fname=path.split('/').pop()||path;
+ const downloadUrl=_csvMediaUrl(path,{download:true});
+ return ``;
}
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")