Files
maestro/ui/src/components/files/FilePreview.tsx
T
oss-sync d061ad08d8
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

804 lines
32 KiB
TypeScript

import type { ReactNode } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '../../i18n';
import { Marked, Renderer } from 'marked';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
import hljs from 'highlight.js';
import { updateLocalFileContent } from '../../api';
import { EmbedBlock } from '../embed/EmbedBlock';
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
import { useBackdropClose } from '../../lib/useBackdropClose';
mermaid.initialize({ startOnLoad: false, theme: 'default' });
// --- MDXG outline helpers ---
interface OutlineEntry { depth: 1 | 2 | 3; text: string; slug: string; }
function slugify(text: string): string {
const cleaned = text
.replace(/<[^>]+>/g, '')
.toLowerCase()
.trim()
.replace(/[\s_]+/g, '-')
.replace(/[^\w\-぀-ヿ一-鿿]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned || 'section';
}
function buildSlugger() {
const counts = new Map<string, number>();
return (text: string): string => {
const base = slugify(text);
const n = counts.get(base) ?? 0;
counts.set(base, n + 1);
return n === 0 ? base : `${base}-${n}`;
};
}
function extractOutline(content: string, parser: Marked): OutlineEntry[] {
const tokens = parser.lexer(content);
const headings: OutlineEntry[] = [];
const slugger = buildSlugger();
for (const t of tokens) {
if (t.type === 'heading') {
const depth = (t as { depth?: number }).depth;
const text = (t as { text?: string }).text ?? '';
if (depth === 1 || depth === 2 || depth === 3) {
headings.push({ depth, text, slug: slugger(text) });
}
}
}
return headings;
}
interface FilePreviewProps {
name: string;
content: string;
imageSrc: string;
/** Markdown 内の相対パス画像を解決するためのベース URL (省略可) */
markdownImageBaseUrl?: string;
onClose: () => void;
taskId?: number;
section?: string;
filePath?: string;
editable?: boolean;
}
// --- CSV ---
function renderCsv(csv: string) {
const rows = csv.trim().split(/\r?\n/).map(r => r.split(','));
if (rows.length === 0) return null;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<tbody>
{rows.slice(0, 120).map((r, i) => (
<tr key={i}>
{r.slice(0, 20).map((c, j) => (
<td key={j} className={`border border-slate-200 px-2 py-1 ${i === 0 ? 'bg-slate-100 font-bold' : 'bg-canvas'}`}>
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- Markdown (marked) ---
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string }): Renderer {
const { imageBaseUrl, slugger } = opts;
const renderer = new Renderer();
renderer.link = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
const titleAttr = title ? ` title="${title}"` : '';
// Markdown link whose destination is an `output/...` workspace
// path: route the click through OutputPreviewProvider's
// delegation instead of opening a new tab. Visible text is the
// markdown label.
const isOutputHref = OUTPUT_PATH_REGEX.test(href);
OUTPUT_PATH_REGEX.lastIndex = 0;
if (isOutputHref) {
return `<a class="output-path-link" data-output-path="${href.replace(/"/g, '&quot;')}"${titleAttr} role="button" tabindex="0">${text}</a>`;
}
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
};
// Bare `output/...` paths in paragraph / list / blockquote text.
// Marked passes pre-escaped HTML strings here, so the linkifier
// runs on safe content.
//
// CRITICAL: when the text token has nested inline children (strong,
// em, codespan, link), `tokens` is set and we MUST defer to
// parser.parseInline. Otherwise the inline formatting is silently
// dropped — `- **bold** \`code\`` would render as literal `**bold**
// \`code\`` instead of formatted.
renderer.text = function ({ tokens, text }: { tokens?: unknown[]; text: string }) {
if (tokens && tokens.length > 0) {
const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } };
return self.parser.parseInline(tokens);
}
return linkifyOutputPathsInEscapedHtml(text);
};
// Inline `output/foo.md` in single-backtick spans. Fenced code
// blocks go through `renderer.code` (below) which deliberately
// doesn't linkify — fenced code is usually a copy-paste sample,
// not a real reference.
renderer.codespan = function ({ text }: { text: string }) {
return `<code>${linkifyOutputPathsInEscapedHtml(text)}</code>`;
};
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
if (lang === 'mermaid') {
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
}
let highlighted: string;
try {
if (lang && hljs.getLanguage(lang)) {
highlighted = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
} else {
highlighted = hljs.highlightAuto(text).value;
}
} catch {
highlighted = escapeHtml(text);
}
const langLabel = lang ? `<span class="mdxg-lang">${escapeHtml(lang)}</span>` : '';
const langClass = lang ? `language-${escapeHtml(lang)}` : '';
return `<pre>${langLabel}<button class="mdxg-copy" data-copy="1" type="button" aria-label="copy">copy</button><code class="hljs ${langClass}">${highlighted}</code></pre>`;
};
if (slugger) {
renderer.heading = function ({ tokens, depth, text }: { tokens: unknown[]; depth: number; text: string }) {
const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } };
const inner = self.parser.parseInline(tokens);
if (depth > 3) {
return `<h${depth}>${inner}</h${depth}>`;
}
const slug = slugger(text);
return `<h${depth} id="${slug}"><a class="mdxg-anchor" href="#${slug}" aria-hidden="true">#</a>${inner}</h${depth}>`;
};
}
if (imageBaseUrl) {
renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
let resolvedHref = href;
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
const cleanPath = href.replace(/^\.\//, '');
resolvedHref = `${imageBaseUrl}${encodeURIComponent(cleanPath)}`;
}
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
};
}
return renderer;
}
/** embed マーカーでコンテンツを分割する */
const EMBED_SPLIT_RE = /\[\[embed:([\w-]+)\]\]/g;
interface ContentSegment {
type: 'markdown' | 'embed';
value: string; // markdown: テキスト, embed: refId
}
function splitContentByEmbeds(content: string): ContentSegment[] {
const segments: ContentSegment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = EMBED_SPLIT_RE.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'markdown', value: content.slice(lastIndex, match.index) });
}
segments.push({ type: 'embed', value: match[1] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
segments.push({ type: 'markdown', value: content.slice(lastIndex) });
}
return segments;
}
/** 単一の Markdown 断片をレンダリングする内部コンポーネント */
function MarkdownSegment({ html }: { html: string }): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
mermaid.run({ nodes: ref.current.querySelectorAll('.mermaid') }).catch(() => {});
}
}, [html]);
return <div ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
}
const DOMPURIFY_CONFIG = {
// `data-output-path`, `role`, `tabindex` added for the output-path
// linkifier — defaults already permit `data-*`, but being explicit
// guards against future config tightening, same as MarkdownText.
ADD_ATTR: ['data-copy', 'aria-hidden', 'aria-label', 'id', 'target', 'rel', 'data-output-path', 'role', 'tabindex'] as string[],
};
interface MarkdownPreviewProps {
content: string;
imageBaseUrl?: string;
taskId?: number;
/** true で目次サイドバー + リーダースタイル (MDXG) を有効化。チャット吹き出し等では false 推奨。 */
showOutline?: boolean;
}
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element {
const { t } = useTranslation('files');
const truncated = content.slice(0, 100000);
const containerRef = useRef<HTMLDivElement>(null);
const [activeSlug, setActiveSlug] = useState<string>('');
// 目次抽出 (showOutline=true のときのみ)
const outline = useMemo<OutlineEntry[]>(() => {
if (!showOutline) return [];
try {
const parser = new Marked({ gfm: true });
return extractOutline(truncated, parser);
} catch {
return [];
}
}, [truncated, showOutline]);
// HTML 生成
const segments = useMemo(() => {
EMBED_SPLIT_RE.lastIndex = 0;
const slugger = showOutline ? buildSlugger() : undefined;
const renderer = buildMdRenderer({ imageBaseUrl, slugger });
const parser = new Marked({ gfm: true, renderer });
const hasEmbed = taskId != null && EMBED_SPLIT_RE.test(truncated);
EMBED_SPLIT_RE.lastIndex = 0;
if (!hasEmbed) {
const html = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG);
return [{ type: 'markdown' as const, html }];
}
return splitContentByEmbeds(truncated).map(seg => {
if (seg.type === 'embed') return { type: 'embed' as const, refId: seg.value };
const html = DOMPurify.sanitize(parser.parse(seg.value, { async: false }) as string, DOMPURIFY_CONFIG);
return { type: 'markdown' as const, html };
});
}, [truncated, imageBaseUrl, taskId, showOutline]);
// コピーボタン + アンカーリンクのイベント delegation
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const btn = target.closest<HTMLButtonElement>('button.mdxg-copy');
if (btn) {
const pre = btn.closest('pre');
const code = pre?.querySelector('code');
if (code) {
navigator.clipboard.writeText(code.textContent ?? '').then(() => {
btn.classList.add('copied');
const original = btn.textContent;
btn.textContent = '✓';
setTimeout(() => {
btn.classList.remove('copied');
btn.textContent = original ?? 'copy';
}, 1200);
}).catch(() => {});
}
return;
}
const anchor = target.closest<HTMLAnchorElement>('a.mdxg-anchor');
if (anchor) {
e.preventDefault();
const id = anchor.getAttribute('href')?.slice(1);
if (id) {
root.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
};
root.addEventListener('click', handler);
return () => root.removeEventListener('click', handler);
}, [segments]);
// scroll-spy: 表示中の見出しを active に
useEffect(() => {
if (!showOutline || outline.length === 0) return;
const root = containerRef.current;
if (!root) return;
const targets = Array.from(root.querySelectorAll<HTMLElement>('h1[id], h2[id], h3[id]'));
if (targets.length === 0) return;
const observer = new IntersectionObserver(
entries => {
const visible = entries.filter(e => e.isIntersecting);
if (visible.length > 0) {
visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
setActiveSlug(visible[0].target.id);
}
},
{ rootMargin: '0px 0px -70% 0px', threshold: 0 }
);
targets.forEach(t => observer.observe(t));
if (!activeSlug && targets[0]) setActiveSlug(targets[0].id);
return () => observer.disconnect();
}, [segments, showOutline, outline.length]);
const handleOutlineClick = (slug: string) => (e: React.MouseEvent) => {
e.preventDefault();
const root = containerRef.current;
root?.querySelector(`#${CSS.escape(slug)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const content_el = (
<div ref={containerRef} className={`${showOutline ? 'prose prose-slate dark:prose-invert max-w-none mdxg-reader' : 'prose prose-sm dark:prose-invert max-w-none'} min-w-0 break-words [&_a]:[overflow-wrap:anywhere] [&_a]:break-all [&_code]:[overflow-wrap:anywhere] [&_pre]:max-w-full [&_pre]:overflow-x-auto`}>
{segments.map((seg, i) => {
if (seg.type === 'embed') {
return <EmbedBlock key={`embed-${seg.refId}-${i}`} refId={seg.refId} taskId={taskId!} />;
}
return <MarkdownSegment key={`md-${i}`} html={seg.html} />;
})}
</div>
);
if (!showOutline || outline.length < 2) {
return content_el;
}
return (
<div className="flex gap-4 items-start">
<aside className="mdxg-outline hidden md:block flex-shrink-0 sticky top-0 max-h-[68vh] overflow-y-auto pr-2 border-r border-hairline" style={{ width: '200px' }}>
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide px-2 py-1.5">{t('toc')}</div>
<nav>
{outline.map(h => (
<a
key={h.slug}
href={`#${h.slug}`}
onClick={handleOutlineClick(h.slug)}
className={`depth-${h.depth} ${activeSlug === h.slug ? 'active' : ''}`}
title={h.text}
>
{h.text}
</a>
))}
</nav>
</aside>
<div className="flex-1 min-w-0">{content_el}</div>
</div>
);
}
// --- Print / PDF helpers ---
const PRINT_STYLE = `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
color: #1e293b;
line-height: 1.7;
max-width: 760px;
margin: 32px auto;
padding: 0 24px;
font-size: 14px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
h1, h2, h3, h4, h5, h6 { color: #0f172a; font-weight: 600; margin-top: 1.6em; margin-bottom: 0.5em; line-height: 1.3; }
h1 { font-size: 1.9em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.2em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1.05em; }
p, ul, ol, blockquote { margin: 0.7em 0; }
ul, ol { padding-left: 1.5em; }
li { margin: 0.2em 0; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
a.mdxg-anchor { display: none; }
a.output-path-link { color: #2563eb; cursor: text; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #f1f5f9; padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }
pre { background: #f8fafc; color: #0f172a; padding: 14px 16px; border-radius: 6px; overflow-x: auto; font-size: 0.85em; border: 1px solid #e2e8f0; position: relative; white-space: pre-wrap; word-break: break-word; }
pre code { background: transparent; color: inherit; padding: 0; font-size: 1em; }
pre .mdxg-copy, pre .mdxg-lang { display: none; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.9em; }
th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; vertical-align: top; }
th { background: #f8fafc; font-weight: 600; }
blockquote { border-left: 3px solid #cbd5e1; margin: 1em 0; padding: 0.3em 1em; color: #475569; background: #f8fafc; }
img { max-width: 100%; height: auto; display: block; margin: 0.5em 0; }
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 1.5em 0; }
.mermaid-rendered { margin: 1em 0; text-align: center; }
.mermaid-rendered svg { max-width: 100%; height: auto; }
.embed-placeholder { border: 1px dashed #cbd5e1; background: #f8fafc; color: #64748b; padding: 8px 12px; border-radius: 6px; margin: 0.7em 0; font-size: 0.85em; }
/* hljs minimal light theme */
.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #d73a49; }
.hljs-function .hljs-keyword { color: #d73a49; }
.hljs-subst { color: #24292e; }
.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-template-variable { color: #032f62; }
.hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #6f42c1; }
.hljs-number, .hljs-meta { color: #005cc5; }
.hljs-built_in, .hljs-builtin-name, .hljs-class .hljs-title { color: #e36209; }
.hljs-deletion { color: #b31d28; background: #ffeef0; }
.hljs-regexp, .hljs-link { color: #032f62; }
@media print {
body { margin: 0; padding: 12mm; max-width: 100%; }
a { color: inherit; text-decoration: none; }
a.output-path-link { color: inherit; }
pre { background: #f8fafc !important; border: 1px solid #e2e8f0; }
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; }
pre, table, blockquote, .mermaid-rendered, img { page-break-inside: avoid; break-inside: avoid; }
}
`;
const EMBED_MARKER_RE = /\[\[embed:([\w-]+)\]\]/g;
async function buildPrintHtml(content: string, opts: { title: string; imageBaseUrl?: string }): Promise<string> {
const truncated = content.slice(0, 100000);
const slugger = buildSlugger();
const absoluteImageBaseUrl = opts.imageBaseUrl
? (opts.imageBaseUrl.startsWith('http') ? opts.imageBaseUrl : `${window.location.origin}${opts.imageBaseUrl}`)
: undefined;
const renderer = buildMdRenderer({ imageBaseUrl: absoluteImageBaseUrl, slugger });
const parser = new Marked({ gfm: true, renderer });
const renderedHtml = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG);
// Replace [[embed:xxx]] markers with placeholders BEFORE parsing into DOM
// (marked passes them through as literal text inside paragraphs).
EMBED_MARKER_RE.lastIndex = 0;
const withEmbedPlaceholders = renderedHtml.replace(
EMBED_MARKER_RE,
(_, refId) => `<div class="embed-placeholder">📎 Embedded: <code>${escapeHtml(String(refId))}</code></div>`,
);
// Parse to a temporary DOM so we can swap mermaid <pre> blocks for rendered SVG.
const doc = new DOMParser().parseFromString(`<body>${withEmbedPlaceholders}</body>`, 'text/html');
const mermaidBlocks = Array.from(doc.body.querySelectorAll<HTMLPreElement>('pre.mermaid'));
for (let i = 0; i < mermaidBlocks.length; i++) {
const block = mermaidBlocks[i];
const source = (block.textContent ?? '').trim();
if (!source) continue;
try {
const id = `mermaid-print-${Date.now()}-${i}`;
const { svg } = await mermaid.render(id, source);
const wrapper = doc.createElement('div');
wrapper.className = 'mermaid-rendered';
wrapper.innerHTML = svg;
block.replaceWith(wrapper);
} catch {
// Leave the original <pre> in place if render fails.
}
}
const bodyHtml = doc.body.innerHTML;
const safeTitle = escapeHtml(opts.title);
return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>${safeTitle}</title>
<style>${PRINT_STYLE}</style>
</head>
<body>
${bodyHtml}
<script>
(function () {
function triggerPrint() {
try { window.focus(); } catch (e) {}
setTimeout(function () { window.print(); }, 150);
}
function waitForImages(done) {
var imgs = Array.prototype.slice.call(document.images);
if (imgs.length === 0) { done(); return; }
var remaining = imgs.length;
var settled = false;
function finish() {
if (settled) return;
settled = true;
done();
}
imgs.forEach(function (img) {
if (img.complete) {
remaining--;
if (remaining === 0) finish();
return;
}
var onDone = function () {
remaining--;
if (remaining === 0) finish();
};
img.addEventListener('load', onDone, { once: true });
img.addEventListener('error', onDone, { once: true });
});
setTimeout(finish, 3000);
}
function ready() { waitForImages(triggerPrint); }
if (document.readyState === 'complete') ready();
else window.addEventListener('load', ready);
})();
</script>
</body>
</html>`;
}
// --- JSONL ---
function badgeClass(color: 'green' | 'orange' | 'red' | 'gray'): string {
if (color === 'green') return 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-300';
if (color === 'orange') return 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300';
if (color === 'red') return 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-300';
return 'bg-slate-100 text-slate-600';
}
function outcomeColor(value: string): 'green' | 'orange' | 'red' | 'gray' {
if (value === 'success') return 'green';
if (value === 'ssrf_blocked' || value === 'pdf_blocked' || value === 'binary_blocked') return 'orange';
if (value === 'error' || value === 'http_error' || value === 'invalid_url') return 'red';
return 'gray';
}
function formatCell(key: string, value: unknown): ReactNode {
if (value === null || value === undefined) return <span className="text-slate-300"></span>;
if (key === 'url' && typeof value === 'string') {
return (
<a href={value} target="_blank" rel="noopener noreferrer"
className="text-blue-600 underline break-all max-w-[300px] block">
{value.length > 60 ? `${value.slice(0, 60)}…` : value}
</a>
);
}
if (key === 'timestamp' && typeof value === 'string') {
try {
return new Date(value).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return String(value); }
}
if (key === 'outcome' && typeof value === 'string') {
const color = outcomeColor(value);
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{value}</span>;
}
if (key === 'status') {
if (typeof value === 'string') {
const color: 'green' | 'red' | 'gray' = value === 'success' ? 'green' : value === 'error' ? 'red' : 'gray';
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{value}</span>;
}
if (typeof value === 'number') {
const color: 'green' | 'red' | 'gray' = value >= 200 && value < 300 ? 'green' : value >= 400 ? 'red' : 'gray';
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{String(value)}</span>;
}
}
if (key === 'exitCode') {
const color: 'green' | 'red' = value === 0 ? 'green' : 'red';
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{String(value)}</span>;
}
if (Array.isArray(value)) return value.join(' ');
const str = typeof value === 'string' ? value : JSON.stringify(value);
return str.length > 80 ? `${str.slice(0, 80)}…` : str;
}
function renderJsonl(content: string): JSX.Element {
const lines = content.trim().split('\n').filter(Boolean).slice(0, 1000);
const records: Record<string, unknown>[] = [];
for (const line of lines) {
try { records.push(JSON.parse(line) as Record<string, unknown>); }
catch { /* skip invalid lines */ }
}
if (records.length === 0) {
return <p className="text-slate-400 text-sm">{i18n.t('files:records.empty')}</p>;
}
const columns = [...new Set(records.flatMap(r => Object.keys(r)))];
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-slate-100">
{columns.map(col => (
<th key={col} className="px-3 py-2 text-left text-xs font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{records.map((record, i) => (
<tr key={i} className={i % 2 === 0 ? 'bg-canvas' : 'bg-slate-50'}>
{columns.map(col => (
<td key={col} className="px-3 py-1.5 border-b border-slate-100 align-top">
{formatCell(col, record[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// --- FilePreview ---
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
const { t } = useTranslation('files');
const [mode, setMode] = useState<'view' | 'edit'>('view');
const [editContent, setEditContent] = useState(content);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [currentContent, setCurrentContent] = useState(content);
const [printing, setPrinting] = useState(false);
const backdrop = useBackdropClose(onClose);
const canEdit = editable && taskId != null && section && filePath;
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
const handlePrint = async () => {
if (printing) return;
setPrinting(true);
setError('');
try {
const html = await buildPrintHtml(currentContent, { title: name, imageBaseUrl: markdownImageBaseUrl });
const win = window.open('', '_blank');
if (!win) {
setError(t('print.popupBlocked'));
return;
}
win.document.open();
win.document.write(html);
win.document.close();
} catch (err) {
setError(err instanceof Error ? err.message : t('print.prepFailed'));
} finally {
setPrinting(false);
}
};
const handleSave = async () => {
if (!taskId || !section || !filePath) return;
setSaving(true);
setError('');
try {
await updateLocalFileContent(taskId, section, filePath, editContent);
setCurrentContent(editContent);
setMode('view');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setSaving(false);
}
};
const body = (() => {
if (mode === 'edit') {
return (
<div className="flex flex-col gap-2">
<textarea
className="w-full min-h-[60vh] font-mono text-xs p-3 border border-hairline rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-accent-ring transition-shadow"
value={editContent}
onChange={e => setEditContent(e.target.value)}
/>
{error && <p className="text-red-600 text-xs">{error}</p>}
<div className="flex gap-2 justify-end">
<button
onClick={() => { setMode('view'); setError(''); }}
className="px-3 h-8 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors"
>
{t('cancel')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 h-8 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 transition-colors"
>
{saving ? t('saving') : t('save')}
</button>
</div>
</div>
);
}
// view mode
if (imageSrc) {
if (/\.html?$/i.test(name)) {
return (
<iframe
src={imageSrc}
// No allow-same-origin / allow-scripts: workspace HTML is
// untrusted (agent- or web-sourced). The pair would let embedded
// scripts run on our origin and ride the viewer's session. Render
// the markup in an opaque, scriptless sandbox; the raw endpoint
// also sends `Content-Security-Policy: sandbox` as a second layer.
sandbox=""
className="w-full rounded-lg border-0"
style={{ height: '80vh' }}
title={name}
/>
);
}
if (/\.pdf$/i.test(name)) {
return (
<embed
src={imageSrc}
type="application/pdf"
className="w-full rounded-lg"
style={{ height: '80vh' }}
/>
);
}
return (
<div className="flex justify-center">
<img src={imageSrc} alt={name} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
</div>
);
}
if (/\.(md|markdown)$/i.test(name)) return <MarkdownPreview content={currentContent} imageBaseUrl={markdownImageBaseUrl} showOutline taskId={taskId} />;
if (/\.csv$/i.test(name)) return renderCsv(currentContent);
if (/\.jsonl$/i.test(name)) return renderJsonl(currentContent);
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
})();
// All preview types share the wide modal so HTML / PDF / images / tables get
// the same generous viewport as Markdown (previously non-MD was ~40% narrower).
const modalWidth = 'min(1400px, 96vw)';
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" {...backdrop}>
<div
className="bg-surface rounded-md border border-hairline shadow-md flex flex-col overflow-hidden"
style={{ width: modalWidth, maxHeight: 'min(90vh, calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px))' }}
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2">
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
<div className="flex items-center gap-1 flex-shrink-0">
{isMarkdownFile && mode === 'view' && (
<button
onClick={handlePrint}
disabled={printing}
title={t('print.tooltip')}
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 5V2h8v3M4 11H2.5A1.5 1.5 0 0 1 1 9.5v-3A1.5 1.5 0 0 1 2.5 5h11A1.5 1.5 0 0 1 15 6.5v3a1.5 1.5 0 0 1-1.5 1.5H12M4 9.5h8v4.5H4z" />
</svg>
{printing ? t('print.preparing') : t('print.button')}
</button>
)}
{canEdit && mode === 'view' && (
<button
onClick={() => { setEditContent(currentContent); setMode('edit'); }}
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11.5 2.5l2 2L5 13l-2.5.5L3 11l8.5-8.5z" />
</svg>
{t('edit')}
</button>
)}
<button
onClick={onClose}
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors"
aria-label={t('close')}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
</div>
<div className="p-4 overflow-auto flex-1">
{mode === 'view' && error && (
<div className="mb-2 px-3 py-2 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-red-700 dark:text-red-300 text-xs rounded">{error}</div>
)}
{body}
</div>
</div>
</div>
);
}