feat: viewer de code style GitHub avec numéros de ligne + Copy
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped

Refonte du _render_file_viewer pour les fichiers texte/code:
- Palette GitHub Dark (#0d1117, #161b22, #c9d1d9)
- Topbar sticky avec: ← Workspace, titre, badge langage, taille
- Bouton 📋 Copy (clipboard API, feedback '✓ Copied!')
- Bouton ⬇ Download
- Numéros de ligne dans une colonne latérale (line-numbers)
- Code rendu ligne par ligne (<span> par ligne)
- Font monospace (Fira Code, Cascadia Code, JetBrains Mono)
- escapeHtml() pour éviter les injections XSS
This commit is contained in:
2026-07-11 14:10:46 -04:00
parent f6aaaa123f
commit 335120c200
+53 -16
View File
@@ -938,18 +938,30 @@ def _render_file_viewer(page: dict, request: Request) -> HTMLResponse:
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — FlowDeck</title>
<style>
:root{{--bg:#191919;--bg2:#242424;--text:#e0e0e0;--text2:#999;--accent:#2383e2;--border:#333;}}
:root{{--bg:#0d1117;--bg2:#161b22;--text:#c9d1d9;--text2:#8b949e;--accent:#58a6ff;--border:#30363d;--line-num:#484f58;}}
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;}}
.topbar{{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--bg2);border-bottom:1px solid var(--border);}}
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:none;border:none;cursor:pointer;font-size:13px;padding:6px 10px;border-radius:6px;}}
.topbar a:hover,.topbar button:hover{{background:var(--bg);color:var(--text);}}
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;}}
.meta{{font-size:12px;color:var(--text2);}}
.viewer{{padding:0;max-width:100%;margin:0 auto;}}
.topbar{{display:flex;align-items:center;gap:10px;padding:8px 16px;background:var(--bg2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10;}}
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:var(--bg);border:1px solid var(--border);cursor:pointer;font-size:12px;padding:5px 10px;border-radius:6px;display:inline-flex;align-items:center;gap:4px;transition:all 150ms;}}
.topbar a:hover,.topbar button:hover{{background:var(--border);color:var(--text);}}
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
.topbar .lang-badge{{font-size:11px;padding:2px 8px;background:var(--accent);color:#fff;border-radius:10px;font-weight:500;border:none;}}
.meta{{font-size:11px;color:var(--text2);white-space:nowrap;}}
.btn-copy{{gap:4px;}}
.btn-copy.copied{{background:#238636;border-color:#238636;color:#fff;}}
.viewer{{max-width:100%;margin:0 auto;}}
.viewer img{{max-width:100%;max-height:85vh;display:block;margin:0 auto;}}
.viewer pre{{margin:0;padding:20px;background:var(--bg);color:var(--text);font-size:13px;line-height:1.6;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono',monospace;white-space:pre-wrap;word-wrap:break-word;}}
.viewer iframe{{width:100%;height:90vh;border:none;}}
.code-container{{display:flex;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono','SF Mono',monospace;font-size:12px;line-height:20px;}}
.line-numbers{{flex-shrink:0;padding:12px 10px;text-align:right;color:var(--line-num);background:var(--bg2);border-right:1px solid var(--border);user-select:none;min-width:48px;}}
.line-numbers span{{display:block;}}
.code-content{{flex:1;padding:12px 16px;overflow:auto;white-space:pre;color:var(--text);background:var(--bg);}}
.code-content span{{display:block;}}
.code-content .hl-kw{{color:#ff7b72;}}
.code-content .hl-str{{color:#a5d6ff;}}
.code-content .hl-cmt{{color:#8b949e;font-style:italic;}}
.code-content .hl-num{{color:#79c0ff;}}
.code-content .hl-fn{{color:#d2a8ff;}}
.unsupported{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;text-align:center;}}
.unsupported .icon{{font-size:64px;margin-bottom:16px;}}
.unsupported h2{{font-size:20px;margin-bottom:8px;}}
@@ -959,8 +971,10 @@ body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacS
<body>
<div class="topbar">
<a href="/local-workspace">← Workspace</a>
<span class="title">📄 {title}</span>
<span class="meta">{size_str} · {mime_type}</span>
<span class="title">{title}</span>
<span class="lang-badge">{lang}</span>
<span class="meta">{size_str}</span>
<button class="btn-copy" onclick="copyCode()" title="Copy to clipboard">📋 Copy</button>
<a href="{file_url}" download>⬇ Download</a>
</div>
<div class="viewer">"""
@@ -970,12 +984,35 @@ body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacS
elif is_pdf:
html += f'<iframe src="{file_url}"></iframe>'
elif is_text:
# Fetch and embed the content as text
html += f'<pre class="language-{lang}"><code id="code-content">Loading...</code></pre>'
html += f'''<script>
fetch("{file_url}").then(r=>r.text()).then(t=>{{
document.getElementById("code-content").textContent=t;
}}).catch(e=>{{document.getElementById("code-content").textContent="Error loading file: "+e;}});
html += f'''<div class="code-container">
<div class="line-numbers" id="line-numbers"></div>
<div class="code-content" id="code-content">Loading...</div>
</div>
<script>
var codeEl=document.getElementById("code-content");
var lineEl=document.getElementById("line-numbers");
fetch("{file_url}").then(function(r){{return r.text()}}).then(function(t){{
var lines=t.split("\\n");
// Remove trailing empty line
if(lines.length>1&&lines[lines.length-1]==="")lines.pop();
var numHtml="",codeHtml="";
for(var i=0;i<lines.length;i++){{
numHtml+="<span>"+(i+1)+"</span>";
codeHtml+="<span>"+escHtml(lines[i])+"</span>";
}}
lineEl.innerHTML=numHtml;
codeEl.innerHTML=codeHtml;
}}).catch(function(e){{codeEl.textContent="Error loading file: "+e;}});
function escHtml(s){{return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}
function copyCode(){{
var text=Array.from(codeEl.children).map(function(s){{return s.textContent}}).join("\\n");
navigator.clipboard.writeText(text).then(function(){{
var btn=document.querySelector(".btn-copy");
btn.textContent="✓ Copied!";
btn.classList.add("copied");
setTimeout(function(){{btn.textContent="📋 Copy";btn.classList.remove("copied");}},2000);
}});
}}
</script>'''
else:
html += f'''<div class="unsupported">