mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-10 22:01:38 -10:00
* feat: scope Select All to active content * fix: honor complete message selection and pane focus * feat: add opt-in native Select All scoping * fix: exclude hidden-message labels from selection
1280 lines
45 KiB
TypeScript
1280 lines
45 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useRef, useCallback, useMemo, type CSSProperties, type MouseEvent } from "react";
|
||
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
|
||
import {
|
||
createSyntaxElement as renderSyntaxNode,
|
||
ensureLanguageRegistered,
|
||
isLanguageRegistered,
|
||
SyntaxHighlighter,
|
||
vs,
|
||
vscDarkPlus,
|
||
} from "@/lib/syntax-highlight";
|
||
import ReactMarkdown from "react-markdown";
|
||
import { AtSign, Download, WrapText } from "lucide-react";
|
||
import { useTheme } from "@/hooks/useTheme";
|
||
import {
|
||
DOCX_PREVIEW_MAX_BYTES,
|
||
getFileExt,
|
||
isAudioPath,
|
||
isDocumentPreviewPath,
|
||
isImagePath,
|
||
} from "@/lib/file-types";
|
||
import { encodeFilePathForApi, getFileDirectory, getFileName, getRelativeFilePath } from "@/lib/file-paths";
|
||
import { translate, useI18n } from "@/lib/i18n";
|
||
import { resolveLocalFileHref } from "@/lib/file-links";
|
||
import { normalizeDisplayMath, useMarkdownPlugins } from "@/lib/markdown";
|
||
import { markdownCodeRenderer } from "./MarkdownCode";
|
||
import { Tooltip } from "./ui/primitives";
|
||
import { parseUnifiedPatch } from "@/lib/patch";
|
||
import type { GitFileDiffResponse } from "@/lib/git-types";
|
||
import { parseFrontmatter } from "@/lib/frontmatter";
|
||
import { FrontmatterCard } from "./FrontmatterCard";
|
||
|
||
interface Props {
|
||
filePath: string;
|
||
cwd?: string;
|
||
sourceSessionId?: string | null;
|
||
onOpenFile?: (filePath: string) => void;
|
||
onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void;
|
||
gitRefreshKey?: number;
|
||
}
|
||
|
||
interface FileData {
|
||
content: string;
|
||
language: string;
|
||
size: number;
|
||
}
|
||
|
||
type DisplayMode = "source" | "preview" | "diff";
|
||
|
||
const DISPLAY_MODE_LABEL_KEYS: Record<DisplayMode, string> = {
|
||
source: "fileViewer.modeSource",
|
||
preview: "fileViewer.modePreview",
|
||
diff: "fileViewer.modeDiff",
|
||
};
|
||
|
||
const FILE_CODE_STYLE: CSSProperties = {
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 13,
|
||
lineHeight: 1.6,
|
||
};
|
||
|
||
const FILE_LINE_NUMBER_STYLE: CSSProperties = {
|
||
width: 48,
|
||
minWidth: 48,
|
||
padding: "0 10px",
|
||
textAlign: "right",
|
||
color: "var(--text-dim)",
|
||
background: "var(--bg-panel)",
|
||
borderRight: "1px solid var(--border)",
|
||
fontFamily: "var(--font-mono)",
|
||
fontSize: 11,
|
||
fontStyle: "normal",
|
||
fontVariantNumeric: "tabular-nums",
|
||
lineHeight: "20.8px",
|
||
userSelect: "none",
|
||
flexShrink: 0,
|
||
verticalAlign: "top",
|
||
};
|
||
|
||
type SourceCodeRendererProps = Parameters<NonNullable<SyntaxHighlighterProps["renderer"]>>[0] & {
|
||
wrapLines: boolean;
|
||
};
|
||
|
||
interface SelectedLineRange {
|
||
startLine: number;
|
||
endLine: number;
|
||
}
|
||
|
||
function closestSourceLine(node: Node): HTMLElement | null {
|
||
const element = node.nodeType === Node.ELEMENT_NODE
|
||
? node as Element
|
||
: node.parentElement;
|
||
return element?.closest<HTMLElement>(".file-source-line[data-line-number]") ?? null;
|
||
}
|
||
|
||
function getSelectedSourceLineRange(root: HTMLElement, selection: Selection | null): SelectedLineRange | null {
|
||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
|
||
|
||
const range = selection.getRangeAt(0);
|
||
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
|
||
|
||
let startElement = closestSourceLine(range.startContainer);
|
||
let endElement = closestSourceLine(range.endContainer);
|
||
if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) return null;
|
||
|
||
let startLine = Number(startElement.dataset.lineNumber);
|
||
let endLine = Number(endElement.dataset.lineNumber);
|
||
if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) return null;
|
||
|
||
if (startLine < endLine) {
|
||
// Browser ranges can start at the end of the preceding line or end at the
|
||
// start of the following line. Exclude either boundary line when none of
|
||
// its source text is actually selected.
|
||
const startContent = startElement.querySelector<HTMLElement>(".file-source-line-content");
|
||
if (startContent?.contains(range.startContainer)) {
|
||
const selectedSuffix = document.createRange();
|
||
selectedSuffix.selectNodeContents(startContent);
|
||
selectedSuffix.setStart(range.startContainer, range.startOffset);
|
||
if (selectedSuffix.toString().length === 0) {
|
||
const nextLine = startElement.nextElementSibling;
|
||
if (nextLine instanceof HTMLElement && nextLine.matches(".file-source-line[data-line-number]")) {
|
||
startElement = nextLine;
|
||
startLine = Number(startElement.dataset.lineNumber);
|
||
}
|
||
}
|
||
}
|
||
|
||
const endContent = endElement.querySelector<HTMLElement>(".file-source-line-content");
|
||
if (endContent?.contains(range.endContainer)) {
|
||
const selectedPrefix = document.createRange();
|
||
selectedPrefix.selectNodeContents(endContent);
|
||
selectedPrefix.setEnd(range.endContainer, range.endOffset);
|
||
if (selectedPrefix.toString().length === 0) {
|
||
const previousLine = endElement.previousElementSibling;
|
||
if (previousLine instanceof HTMLElement && previousLine.matches(".file-source-line[data-line-number]")) {
|
||
endElement = previousLine;
|
||
endLine = Number(endElement.dataset.lineNumber);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (startLine > endLine) return null;
|
||
return { startLine, endLine };
|
||
}
|
||
|
||
function SourceCodeRenderer({ rows, stylesheet, useInlineStyles, wrapLines }: SourceCodeRendererProps) {
|
||
return rows.map((row, lineIndex) => {
|
||
const children = row.children ?? [];
|
||
const firstChildClasses = children[0]?.properties?.className;
|
||
const hasLineNumber = Array.isArray(firstChildClasses)
|
||
&& firstChildClasses.includes("react-syntax-highlighter-line-number");
|
||
const lineNumberNode = hasLineNumber ? children[0] : null;
|
||
const contentNodes = hasLineNumber ? children.slice(1) : children;
|
||
|
||
return (
|
||
<span
|
||
className="file-source-line"
|
||
data-line-number={lineIndex + 1}
|
||
key={`source-line-${lineIndex}`}
|
||
style={{ display: "flex", minWidth: "100%" }}
|
||
>
|
||
{lineNumberNode && renderSyntaxNode({
|
||
node: lineNumberNode,
|
||
stylesheet,
|
||
useInlineStyles,
|
||
key: `source-line-number-${lineIndex}`,
|
||
})}
|
||
<span
|
||
className="file-source-line-content"
|
||
style={{
|
||
flex: "1 1 auto",
|
||
minWidth: 0,
|
||
overflowWrap: wrapLines ? "anywhere" : "normal",
|
||
whiteSpace: wrapLines ? "pre-wrap" : "pre",
|
||
}}
|
||
>
|
||
{contentNodes.map((node, tokenIndex) => renderSyntaxNode({
|
||
node,
|
||
stylesheet,
|
||
useInlineStyles,
|
||
key: `source-token-${lineIndex}-${tokenIndex}`,
|
||
}))}
|
||
</span>
|
||
</span>
|
||
);
|
||
});
|
||
}
|
||
|
||
function getFileApiUrl(
|
||
filePath: string,
|
||
type: "read" | "download" | "meta" | "preview" | "watch",
|
||
sourceSessionId?: string | null,
|
||
params: Record<string, string | number | undefined> = {},
|
||
): string {
|
||
const encoded = encodeFilePathForApi(filePath);
|
||
const searchParams = new URLSearchParams({ type });
|
||
if (sourceSessionId) searchParams.set("sessionId", sourceSessionId);
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (value !== undefined) searchParams.set(key, String(value));
|
||
}
|
||
return `/api/files/${encoded}?${searchParams.toString()}`;
|
||
}
|
||
|
||
function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) {
|
||
const { t } = useI18n();
|
||
const label = t("fileViewer.downloadFile");
|
||
return (
|
||
<Tooltip content={label}>
|
||
<a
|
||
href={getFileApiUrl(filePath, "download", sourceSessionId)}
|
||
download={getFileName(filePath)}
|
||
aria-label={label}
|
||
className="file-viewer-icon-button"
|
||
style={{
|
||
borderRadius: "var(--radius-control)",
|
||
transition: `background var(--dur-fast) var(--ease-out-warm), color var(--dur-fast) var(--ease-out-warm)`,
|
||
}}
|
||
>
|
||
<Download size={14} strokeWidth={2.2} aria-hidden="true" />
|
||
</a>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
type DiffLine = {
|
||
type: "unchanged" | "removed" | "added";
|
||
text: string;
|
||
oldLineNo: number | null;
|
||
newLineNo: number | null;
|
||
};
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function diffLines(patch: string): DiffLine[] {
|
||
const files = parseUnifiedPatch(patch);
|
||
if (!files) return [];
|
||
|
||
return files.flatMap((file) => file.rows.flatMap((row): DiffLine[] => {
|
||
if (row.type === "hunk") return [];
|
||
if (row.left.type === "context" && row.right.type === "context") {
|
||
return [{
|
||
type: "unchanged",
|
||
text: row.right.text,
|
||
oldLineNo: row.left.lineNo,
|
||
newLineNo: row.right.lineNo,
|
||
}];
|
||
}
|
||
|
||
const lines: DiffLine[] = [];
|
||
if (row.left.type === "removed") {
|
||
lines.push({
|
||
type: "removed",
|
||
text: row.left.text,
|
||
oldLineNo: row.left.lineNo,
|
||
newLineNo: null,
|
||
});
|
||
}
|
||
if (row.right.type === "added") {
|
||
lines.push({
|
||
type: "added",
|
||
text: row.right.text,
|
||
oldLineNo: null,
|
||
newLineNo: row.right.lineNo,
|
||
});
|
||
}
|
||
return lines;
|
||
}));
|
||
}
|
||
|
||
export function DiffView({ patch }: { patch: string }) {
|
||
const { t, tn } = useI18n();
|
||
const diff = diffLines(patch);
|
||
|
||
const hasChanges = diff.some((l) => l.type !== "unchanged");
|
||
if (!hasChanges) {
|
||
return (
|
||
<div style={{ padding: "12px 16px", fontSize: 12, color: "var(--text-dim)", fontFamily: "var(--font-mono)" }}>
|
||
{t("fileViewer.noChanges")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Render with context: show 3 lines around each change, collapse the rest
|
||
const CONTEXT = 3;
|
||
const changed = new Set(diff.flatMap((l, i) => (l.type !== "unchanged" ? [i] : [])));
|
||
const visible = new Set<number>();
|
||
for (const ci of changed) {
|
||
for (let j = Math.max(0, ci - CONTEXT); j <= Math.min(diff.length - 1, ci + CONTEXT); j++) {
|
||
visible.add(j);
|
||
}
|
||
}
|
||
|
||
const segments: Array<{ hidden: true; count: number } | { hidden: false; lines: DiffLine[] }> = [];
|
||
let i = 0;
|
||
while (i < diff.length) {
|
||
if (visible.has(i)) {
|
||
const block: DiffLine[] = [];
|
||
while (i < diff.length && visible.has(i)) {
|
||
block.push(diff[i]);
|
||
i++;
|
||
}
|
||
segments.push({ hidden: false, lines: block });
|
||
} else {
|
||
let count = 0;
|
||
while (i < diff.length && !visible.has(i)) {
|
||
count++;
|
||
i++;
|
||
}
|
||
segments.push({ hidden: true, count });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="file-diff-view"
|
||
style={{
|
||
width: "max-content",
|
||
minWidth: "100%",
|
||
...FILE_CODE_STYLE,
|
||
}}
|
||
>
|
||
{segments.map((seg, si) => {
|
||
if (seg.hidden) {
|
||
const result = (
|
||
<div
|
||
key={si}
|
||
style={{
|
||
padding: "2px 16px",
|
||
color: "var(--text-dim)",
|
||
background: "var(--bg-panel)",
|
||
fontSize: 11,
|
||
borderTop: "1px solid var(--border)",
|
||
borderBottom: "1px solid var(--border)",
|
||
}}
|
||
>
|
||
{tn("fileViewer.unchangedLines", seg.count)}
|
||
</div>
|
||
);
|
||
return result;
|
||
}
|
||
const lines = seg.lines.map((line, li) => {
|
||
const bg =
|
||
line.type === "added"
|
||
? "color-mix(in srgb, var(--status-success) 12%, transparent)"
|
||
: line.type === "removed"
|
||
? "color-mix(in srgb, var(--status-error) 14%, transparent)"
|
||
: "transparent";
|
||
const prefix =
|
||
line.type === "added" ? "+" : line.type === "removed" ? "-" : " ";
|
||
const prefixColor =
|
||
line.type === "added" ? "var(--status-success)" : line.type === "removed" ? "var(--status-error)" : "var(--text-dim)";
|
||
|
||
return (
|
||
<div
|
||
key={li}
|
||
className="file-diff-line"
|
||
style={{
|
||
display: "flex",
|
||
minWidth: "100%",
|
||
background: bg,
|
||
borderLeft: line.type === "added"
|
||
? "3px solid var(--status-success)"
|
||
: line.type === "removed"
|
||
? "3px solid var(--status-error)"
|
||
: "3px solid transparent",
|
||
}}
|
||
>
|
||
<span
|
||
style={FILE_LINE_NUMBER_STYLE}
|
||
>
|
||
{line.type === "removed" ? line.oldLineNo : line.newLineNo}
|
||
</span>
|
||
<span
|
||
style={{
|
||
minWidth: 16,
|
||
padding: "0 6px",
|
||
color: prefixColor,
|
||
userSelect: "none",
|
||
flexShrink: 0,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{prefix}
|
||
</span>
|
||
<span
|
||
className="file-diff-line-content"
|
||
style={{
|
||
flexShrink: 0,
|
||
padding: "0 8px 0 0",
|
||
whiteSpace: "pre",
|
||
color: "var(--text)",
|
||
}}
|
||
>
|
||
{line.text || "\u00a0"}
|
||
</span>
|
||
</div>
|
||
);
|
||
});
|
||
return <div key={si}>{lines}</div>;
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImageViewer({ filePath, cwd, sourceSessionId }: Props) {
|
||
const { t } = useI18n();
|
||
const [watching, setWatching] = useState(false);
|
||
const [bust, setBust] = useState(0);
|
||
const [size, setSize] = useState<number | null>(null);
|
||
const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const esRef = useRef<EventSource | null>(null);
|
||
|
||
const ext = getFileName(filePath).toLowerCase().split(".").pop() ?? "";
|
||
|
||
useEffect(() => {
|
||
setBust(0);
|
||
setSize(null);
|
||
setNaturalSize(null);
|
||
setError(null);
|
||
setWatching(false);
|
||
|
||
if (esRef.current) {
|
||
esRef.current.close();
|
||
esRef.current = null;
|
||
}
|
||
|
||
const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId));
|
||
esRef.current = es;
|
||
|
||
es.addEventListener("connected", () => setWatching(true));
|
||
es.addEventListener("change", (e) => {
|
||
try {
|
||
const d = JSON.parse((e as MessageEvent).data) as { size?: number };
|
||
if (typeof d.size === "number") setSize(d.size);
|
||
} catch { /* ignore */ }
|
||
setBust((b) => b + 1);
|
||
});
|
||
es.addEventListener("error", () => setWatching(false));
|
||
es.onerror = () => setWatching(false);
|
||
|
||
return () => {
|
||
es.close();
|
||
esRef.current = null;
|
||
};
|
||
}, [filePath, sourceSessionId]);
|
||
|
||
const src = getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined);
|
||
|
||
const formatSizeStr = size != null ? formatSize(size) : null;
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
padding: "4px 16px",
|
||
borderBottom: "1px solid var(--border)",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
background: "var(--bg)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{ fontFamily: "var(--font-mono)" }} title={filePath}>
|
||
{getRelativeFilePath(filePath, cwd)}
|
||
</span>
|
||
<span style={{ marginLeft: "auto" }}>{ext || t("fileViewer.imageType")}</span>
|
||
{naturalSize && <span>{naturalSize.w} × {naturalSize.h}</span>}
|
||
{formatSizeStr && <span>{formatSizeStr}</span>}
|
||
<span
|
||
title={watching ? t("fileViewer.liveSyncActive") : t("fileViewer.notWatching")}
|
||
style={{ display: "flex", alignItems: "center", gap: 4, color: watching ? "var(--status-success)" : "var(--text-dim)" }}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: watching ? "var(--status-success)" : "var(--border)",
|
||
display: "inline-block",
|
||
boxShadow: watching ? "0 0 4px var(--status-success)" : "none",
|
||
}}
|
||
/>
|
||
{watching ? t("fileViewer.live") : t("fileViewer.static")}
|
||
</span>
|
||
<DownloadLink filePath={filePath} sourceSessionId={sourceSessionId} />
|
||
</div>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
overflow: "auto",
|
||
background: "var(--bg-panel)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 16,
|
||
backgroundImage:
|
||
"linear-gradient(45deg, var(--bg) 25%, transparent 25%), linear-gradient(-45deg, var(--bg) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--bg) 75%), linear-gradient(-45deg, transparent 75%, var(--bg) 75%)",
|
||
backgroundSize: "16px 16px",
|
||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||
}}
|
||
>
|
||
{error ? (
|
||
<div style={{ color: "var(--status-error)", fontSize: 13 }}>{error}</div>
|
||
) : (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={src}
|
||
alt={filePath}
|
||
onLoad={(e) => {
|
||
const img = e.currentTarget;
|
||
setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
|
||
}}
|
||
onError={() => setError(t("fileViewer.imageLoadFailed"))}
|
||
style={{
|
||
maxWidth: "100%",
|
||
maxHeight: "100%",
|
||
objectFit: "contain",
|
||
boxShadow: "var(--shadow-pop)",
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatDuration(seconds: number): string {
|
||
if (!Number.isFinite(seconds)) return "";
|
||
const totalSeconds = Math.round(seconds);
|
||
const mins = Math.floor(totalSeconds / 60);
|
||
const secs = totalSeconds % 60;
|
||
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||
}
|
||
|
||
function AudioViewer({ filePath, cwd, sourceSessionId }: Props) {
|
||
const { t } = useI18n();
|
||
const [watching, setWatching] = useState(false);
|
||
const [bust, setBust] = useState(0);
|
||
const [size, setSize] = useState<number | null>(null);
|
||
const [duration, setDuration] = useState<number | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const esRef = useRef<EventSource | null>(null);
|
||
|
||
const ext = getFileName(filePath).toLowerCase().split(".").pop() ?? "";
|
||
|
||
useEffect(() => {
|
||
setBust(0);
|
||
setSize(null);
|
||
setDuration(null);
|
||
setError(null);
|
||
setWatching(false);
|
||
|
||
if (esRef.current) {
|
||
esRef.current.close();
|
||
esRef.current = null;
|
||
}
|
||
|
||
const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId));
|
||
esRef.current = es;
|
||
|
||
es.addEventListener("connected", () => setWatching(true));
|
||
es.addEventListener("change", (e) => {
|
||
try {
|
||
const d = JSON.parse((e as MessageEvent).data) as { size?: number };
|
||
if (typeof d.size === "number") setSize(d.size);
|
||
} catch { /* ignore */ }
|
||
setDuration(null);
|
||
setError(null);
|
||
setBust((b) => b + 1);
|
||
});
|
||
es.addEventListener("error", () => setWatching(false));
|
||
es.onerror = () => setWatching(false);
|
||
|
||
return () => {
|
||
es.close();
|
||
esRef.current = null;
|
||
};
|
||
}, [filePath, sourceSessionId]);
|
||
|
||
const src = getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
padding: "4px 16px",
|
||
borderBottom: "1px solid var(--border)",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
background: "var(--bg)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{ fontFamily: "var(--font-mono)" }} title={filePath}>
|
||
{getRelativeFilePath(filePath, cwd)}
|
||
</span>
|
||
<span style={{ marginLeft: "auto" }}>{ext || t("fileViewer.audioType")}</span>
|
||
{duration != null && <span>{formatDuration(duration)}</span>}
|
||
{size != null && <span>{formatSize(size)}</span>}
|
||
<span
|
||
title={watching ? t("fileViewer.liveSyncActive") : t("fileViewer.notWatching")}
|
||
style={{ display: "flex", alignItems: "center", gap: 4, color: watching ? "var(--status-success)" : "var(--text-dim)" }}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: watching ? "var(--status-success)" : "var(--border)",
|
||
display: "inline-block",
|
||
boxShadow: watching ? "0 0 4px var(--status-success)" : "none",
|
||
}}
|
||
/>
|
||
{watching ? t("fileViewer.live") : t("fileViewer.static")}
|
||
</span>
|
||
<DownloadLink filePath={filePath} sourceSessionId={sourceSessionId} />
|
||
</div>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
background: "var(--bg-panel)",
|
||
}}
|
||
>
|
||
<div style={{ width: "min(680px, 100%)" }}>
|
||
{error && (
|
||
<div style={{ color: "var(--status-error)", fontSize: 13, marginBottom: 12, textAlign: "center" }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
<audio
|
||
key={src}
|
||
controls
|
||
preload="metadata"
|
||
src={src}
|
||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
||
onError={() => setError(t("fileViewer.audioLoadFailed"))}
|
||
style={{ width: "100%" }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DocumentViewer({ filePath, cwd, sourceSessionId }: Props) {
|
||
const { t } = useI18n();
|
||
const [watching, setWatching] = useState(false);
|
||
const [bust, setBust] = useState(0);
|
||
const [size, setSize] = useState<number | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const esRef = useRef<EventSource | null>(null);
|
||
|
||
const ext = getFileExt(filePath);
|
||
const isPdf = ext === "pdf";
|
||
const previewUrl = isPdf
|
||
? getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined)
|
||
: getFileApiUrl(filePath, "preview", sourceSessionId, bust ? { v: bust } : undefined);
|
||
|
||
useEffect(() => {
|
||
setBust(0);
|
||
setSize(null);
|
||
setError(null);
|
||
setWatching(false);
|
||
|
||
if (esRef.current) {
|
||
esRef.current.close();
|
||
esRef.current = null;
|
||
}
|
||
|
||
// Guard against stale metadata responses: a fetch started for a previous
|
||
// filePath must not overwrite the current file's size/error.
|
||
let cancelled = false;
|
||
fetch(getFileApiUrl(filePath, "meta", sourceSessionId))
|
||
.then((r) => r.json())
|
||
.then((d: { size?: number; error?: string }) => {
|
||
if (cancelled) return;
|
||
if (d.error) setError(d.error);
|
||
if (typeof d.size === "number") {
|
||
setSize(d.size);
|
||
if (!isPdf && d.size > DOCX_PREVIEW_MAX_BYTES) {
|
||
setError(translate("fileViewer.docxTooLarge"));
|
||
}
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) setError(String(e));
|
||
});
|
||
|
||
const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId));
|
||
esRef.current = es;
|
||
|
||
es.addEventListener("connected", () => setWatching(true));
|
||
es.addEventListener("change", (e) => {
|
||
try {
|
||
const d = JSON.parse((e as MessageEvent).data) as { size?: number };
|
||
if (typeof d.size === "number") {
|
||
setSize(d.size);
|
||
if (!isPdf && d.size > DOCX_PREVIEW_MAX_BYTES) {
|
||
setError(translate("fileViewer.docxTooLarge"));
|
||
return;
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
setError(null);
|
||
setBust((b) => b + 1);
|
||
});
|
||
es.addEventListener("error", () => setWatching(false));
|
||
es.onerror = () => setWatching(false);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
es.close();
|
||
esRef.current = null;
|
||
};
|
||
}, [filePath, isPdf, sourceSessionId]);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
padding: "4px 16px",
|
||
borderBottom: "1px solid var(--border)",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
background: "var(--bg)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{ fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={filePath}>
|
||
{getRelativeFilePath(filePath, cwd)}
|
||
</span>
|
||
<span style={{ marginLeft: "auto" }}>{ext === "docx" ? t("fileViewer.docxPreview") : "pdf"}</span>
|
||
{size != null && <span>{formatSize(size)}</span>}
|
||
<DownloadLink filePath={filePath} sourceSessionId={sourceSessionId} />
|
||
<span
|
||
title={watching ? t("fileViewer.liveSyncActive") : t("fileViewer.notWatching")}
|
||
style={{ display: "flex", alignItems: "center", gap: 4, color: watching ? "var(--status-success)" : "var(--text-dim)", flexShrink: 0 }}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: "50%",
|
||
background: watching ? "var(--status-success)" : "var(--border)",
|
||
display: "inline-block",
|
||
boxShadow: watching ? "0 0 4px var(--status-success)" : "none",
|
||
}}
|
||
/>
|
||
{watching ? t("fileViewer.live") : t("fileViewer.static")}
|
||
</span>
|
||
</div>
|
||
<div style={{ flex: 1, minHeight: 0, background: "var(--bg-panel)" }}>
|
||
{error ? (
|
||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, color: "var(--status-error)", fontSize: 13, textAlign: "center" }}>
|
||
{error}
|
||
</div>
|
||
) : (
|
||
<iframe
|
||
key={previewUrl}
|
||
src={previewUrl}
|
||
sandbox={isPdf ? undefined : "allow-same-origin"}
|
||
title={t("fileViewer.previewTitle", { name: getFileName(filePath) })}
|
||
style={{ width: "100%", height: "100%", border: "none", background: isPdf ? "var(--bg)" : "var(--bg-subtle)" }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey }: Props) {
|
||
if (isImagePath(filePath)) {
|
||
return <ImageViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
|
||
}
|
||
if (isAudioPath(filePath)) {
|
||
return <AudioViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
|
||
}
|
||
if (isDocumentPreviewPath(filePath)) {
|
||
return <DocumentViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} />;
|
||
}
|
||
return <TextFileViewer filePath={filePath} cwd={cwd} sourceSessionId={sourceSessionId} onOpenFile={onOpenFile} onMentionLines={onMentionLines} gitRefreshKey={gitRefreshKey} />;
|
||
}
|
||
|
||
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, gitRefreshKey }: Props) {
|
||
const { t } = useI18n();
|
||
const { isDark } = useTheme();
|
||
const [data, setData] = useState<FileData | null>(null);
|
||
const [gitDiff, setGitDiff] = useState<GitFileDiffResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [highlightReady, setHighlightReady] = useState(true);
|
||
|
||
// Load the PrismLight grammar for this file's language on demand instead of
|
||
// paying for every grammar at cold start (see lib/syntax-highlight.ts).
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
let cancelled = false;
|
||
setHighlightReady(isLanguageRegistered(data.language));
|
||
const promise = ensureLanguageRegistered(data.language);
|
||
if (promise) {
|
||
promise.then(() => { if (!cancelled) setHighlightReady(true); });
|
||
}
|
||
return () => { cancelled = true; };
|
||
}, [data]);
|
||
const [displayMode, setDisplayMode] = useState<DisplayMode>("source");
|
||
const [wrapLines, setWrapLines] = useState(false);
|
||
const [watching, setWatching] = useState(false);
|
||
const esRef = useRef<EventSource | null>(null);
|
||
const gitDiffRequestRef = useRef(0);
|
||
const contentRequestRef = useRef(0);
|
||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||
const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null);
|
||
|
||
const fetchContent = useCallback((filePath: string) => {
|
||
// Guard against stale responses: bump the request id and ignore any
|
||
// resolve that is no longer the latest fetch for this component, so a
|
||
// slower older request cannot overwrite newer content/error state.
|
||
const requestId = ++contentRequestRef.current;
|
||
return fetch(getFileApiUrl(filePath, "read", sourceSessionId))
|
||
.then((r) => r.json())
|
||
.then((d: FileData & { error?: string }) => {
|
||
if (requestId !== contentRequestRef.current) return null;
|
||
if (d.error) {
|
||
setError(d.error);
|
||
return null;
|
||
}
|
||
setError(null);
|
||
setData(d);
|
||
return d;
|
||
})
|
||
.catch((e) => {
|
||
if (requestId !== contentRequestRef.current) return null;
|
||
setError(String(e));
|
||
return null;
|
||
});
|
||
}, [sourceSessionId]);
|
||
|
||
const fetchGitDiff = useCallback(async (targetPath: string) => {
|
||
const requestId = ++gitDiffRequestRef.current;
|
||
if (!cwd) {
|
||
setGitDiff(null);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({ cwd, path: targetPath });
|
||
const response = await fetch(`/api/git/diff?${params.toString()}`);
|
||
const next = await response.json() as GitFileDiffResponse & { error?: string };
|
||
if (requestId !== gitDiffRequestRef.current) return;
|
||
setGitDiff(response.ok && next.supported && typeof next.patch === "string" ? next : null);
|
||
} catch {
|
||
if (requestId === gitDiffRequestRef.current) setGitDiff(null);
|
||
}
|
||
}, [cwd]);
|
||
|
||
// Initial load + SSE watch setup
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
setError(null);
|
||
setData(null);
|
||
setGitDiff(null);
|
||
setDisplayMode("source");
|
||
setWrapLines(false);
|
||
setWatching(false);
|
||
|
||
if (esRef.current) {
|
||
esRef.current.close();
|
||
esRef.current = null;
|
||
}
|
||
|
||
// Cancel effect-local work on filePath change so a stale in-flight load
|
||
// cannot revert display mode or flip loading off for the new file.
|
||
let cancelled = false;
|
||
void fetchContent(filePath).then((d) => {
|
||
if (cancelled) return;
|
||
if (d?.language === "markdown") setDisplayMode("preview");
|
||
}).finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
|
||
// Set up SSE watch
|
||
const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId));
|
||
esRef.current = es;
|
||
|
||
es.addEventListener("connected", () => {
|
||
setWatching(true);
|
||
});
|
||
|
||
es.addEventListener("change", () => {
|
||
void fetchContent(filePath);
|
||
void fetchGitDiff(filePath);
|
||
});
|
||
|
||
es.addEventListener("error", () => {
|
||
setWatching(false);
|
||
});
|
||
|
||
es.onerror = () => {
|
||
setWatching(false);
|
||
};
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
es.close();
|
||
esRef.current = null;
|
||
};
|
||
}, [filePath, fetchContent, fetchGitDiff, sourceSessionId]);
|
||
|
||
useEffect(() => {
|
||
void fetchGitDiff(filePath);
|
||
}, [fetchGitDiff, filePath, gitRefreshKey]);
|
||
|
||
const hasGitDiff = gitDiff?.supported === true && typeof gitDiff.patch === "string";
|
||
|
||
useEffect(() => {
|
||
if (!hasGitDiff && displayMode === "diff") setDisplayMode("source");
|
||
}, [displayMode, hasGitDiff]);
|
||
|
||
const parsedFrontmatter = useMemo(
|
||
() => (data?.language === "markdown" ? parseFrontmatter(data.content) : null),
|
||
[data],
|
||
);
|
||
const frontmatter = parsedFrontmatter?.data ?? null;
|
||
const markdownPreview = useMemo(
|
||
() => (data?.language === "markdown" ? normalizeDisplayMath(parsedFrontmatter?.rest ?? data.content) : ""),
|
||
[data, parsedFrontmatter],
|
||
);
|
||
const markdownPlugins = useMarkdownPlugins(markdownPreview);
|
||
|
||
useEffect(() => {
|
||
const updateSelectedLineRange = () => {
|
||
const root = contentRef.current;
|
||
setSelectedLineRange(
|
||
onMentionLines && displayMode === "source" && root
|
||
? getSelectedSourceLineRange(root, window.getSelection())
|
||
: null,
|
||
);
|
||
};
|
||
|
||
updateSelectedLineRange();
|
||
if (!onMentionLines || displayMode !== "source") return;
|
||
|
||
// selectionchange fires on every caret/selection movement; coalesce into
|
||
// one update per animation frame so dragging a selection does not trigger
|
||
// a re-render of the syntax highlighter on every event.
|
||
let frame: number | null = null;
|
||
const schedule = () => {
|
||
if (frame !== null) return;
|
||
frame = requestAnimationFrame(() => {
|
||
frame = null;
|
||
updateSelectedLineRange();
|
||
});
|
||
};
|
||
document.addEventListener("selectionchange", schedule);
|
||
return () => {
|
||
document.removeEventListener("selectionchange", schedule);
|
||
if (frame !== null) cancelAnimationFrame(frame);
|
||
};
|
||
}, [data?.content, displayMode, onMentionLines]);
|
||
|
||
const mentionLineRange = useCallback((lineRange: SelectedLineRange | null) => {
|
||
if (!onMentionLines || !lineRange) return;
|
||
onMentionLines(
|
||
getRelativeFilePath(filePath, cwd),
|
||
lineRange.startLine,
|
||
lineRange.endLine,
|
||
);
|
||
}, [cwd, filePath, onMentionLines]);
|
||
|
||
const handleMentionSelectedLines = useCallback(() => {
|
||
mentionLineRange(selectedLineRange);
|
||
}, [mentionLineRange, selectedLineRange]);
|
||
|
||
useEffect(() => {
|
||
if (!onMentionLines || displayMode !== "source") return;
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.repeat || event.key.toLowerCase() !== "i" || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return;
|
||
|
||
const target = event.target;
|
||
if (target instanceof Element && target.closest("input, textarea, [contenteditable='true']")) return;
|
||
|
||
const root = contentRef.current;
|
||
const lineRange = root ? getSelectedSourceLineRange(root, window.getSelection()) : null;
|
||
if (!lineRange) return;
|
||
|
||
event.preventDefault();
|
||
mentionLineRange(lineRange);
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
}, [displayMode, mentionLineRange, onMentionLines]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: 13 }}>
|
||
{t("fileViewer.loading")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--status-error)", fontSize: 13 }}>
|
||
{error}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!data) return null;
|
||
|
||
const isHtml = data.language === "html";
|
||
const isMarkdown = data.language === "markdown";
|
||
const hasPreview = isHtml || isMarkdown;
|
||
const markdownDirectory = getFileDirectory(filePath);
|
||
const lines = data.content.split("\n");
|
||
const displayModes: DisplayMode[] = [
|
||
"source",
|
||
...(hasPreview ? ["preview" as const] : []),
|
||
...(hasGitDiff ? ["diff" as const] : []),
|
||
];
|
||
const metadata = t("fileViewer.metadata", { language: data.language, lines: lines.length, size: formatSize(data.size) });
|
||
const fullRelativePath = getRelativeFilePath(filePath, cwd);
|
||
const pathSepIndex = fullRelativePath.lastIndexOf("/");
|
||
const breadcrumbDir = pathSepIndex >= 0 ? fullRelativePath.slice(0, pathSepIndex + 1) : "";
|
||
const breadcrumbFile = pathSepIndex >= 0 ? fullRelativePath.slice(pathSepIndex + 1) : fullRelativePath;
|
||
|
||
return (
|
||
<div className="file-viewer-shell" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden" }}>
|
||
<div
|
||
className="file-viewer-toolbar"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "5px 12px",
|
||
borderBottom: "1px solid var(--border)",
|
||
fontSize: 11,
|
||
color: "var(--text-dim)",
|
||
background: "var(--bg)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<span className="file-viewer-path" style={{ fontFamily: "var(--font-mono)" }} title={fullRelativePath}>
|
||
{breadcrumbDir && (
|
||
<span style={{ color: "var(--text-muted)" }}>{breadcrumbDir}</span>
|
||
)}
|
||
<span className="display-serif" style={{ color: "var(--text)", fontWeight: 600, letterSpacing: "0.005em" }}>{breadcrumbFile}</span>
|
||
</span>
|
||
|
||
<span className="file-viewer-meta" title={metadata}>{metadata}</span>
|
||
<span
|
||
title={watching ? t("fileViewer.liveSyncActive") : t("fileViewer.notWatching")}
|
||
aria-label={watching ? t("fileViewer.liveSyncActive") : t("fileViewer.notWatching")}
|
||
className="file-viewer-live-indicator"
|
||
style={{
|
||
background: watching ? "var(--status-success)" : "var(--border)",
|
||
boxShadow: watching ? "0 0 4px var(--status-success)" : "none",
|
||
}}
|
||
/>
|
||
|
||
<div className="file-viewer-controls">
|
||
{displayModes.length > 1 && (
|
||
<div
|
||
className="file-viewer-mode-switch"
|
||
aria-label={t("fileViewer.viewMode")}
|
||
style={{
|
||
borderRadius: "var(--radius-control)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{displayModes.map((mode) => {
|
||
const active = displayMode === mode;
|
||
const label = mode === "diff" ? t("fileViewer.compareWithHead") : t(DISPLAY_MODE_LABEL_KEYS[mode]);
|
||
return (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => setDisplayMode(mode)}
|
||
aria-label={label}
|
||
title={label}
|
||
aria-pressed={active}
|
||
className="file-viewer-mode-button"
|
||
style={{
|
||
background: active ? "var(--bg-selected)" : "transparent",
|
||
color: active ? "var(--text)" : "var(--text-muted)",
|
||
transition: `background var(--dur-fast) var(--ease-out-warm), color var(--dur-fast) var(--ease-out-warm)`,
|
||
}}
|
||
>
|
||
{t(DISPLAY_MODE_LABEL_KEYS[mode])}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div className="file-viewer-actions">
|
||
{displayMode === "source" && (
|
||
<>
|
||
<Tooltip content={t("fileViewer.mentionSelectedLines")}>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onClick={handleMentionSelectedLines}
|
||
aria-label={t("fileViewer.mentionSelectedLines")}
|
||
disabled={!selectedLineRange}
|
||
className="file-viewer-icon-button"
|
||
style={{
|
||
borderRadius: "var(--radius-control)",
|
||
transition: `background var(--dur-fast) var(--ease-out-warm), color var(--dur-fast) var(--ease-out-warm)`,
|
||
}}
|
||
>
|
||
<AtSign size={14} strokeWidth={2.2} aria-hidden="true" />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip content={wrapLines ? t("fileViewer.disableWordWrap") : t("fileViewer.enableWordWrap")}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setWrapLines((value) => !value)}
|
||
aria-label={wrapLines ? t("fileViewer.disableWordWrap") : t("fileViewer.enableWordWrap")}
|
||
aria-pressed={wrapLines}
|
||
className="file-viewer-icon-button"
|
||
style={{
|
||
background: wrapLines ? "var(--bg-selected)" : "transparent",
|
||
color: wrapLines ? "var(--text)" : "var(--text-muted)",
|
||
borderRadius: "var(--radius-control)",
|
||
transition: `background var(--dur-fast) var(--ease-out-warm), color var(--dur-fast) var(--ease-out-warm)`,
|
||
}}
|
||
>
|
||
<WrapText size={14} strokeWidth={2} aria-hidden="true" />
|
||
</button>
|
||
</Tooltip>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<DownloadLink filePath={filePath} sourceSessionId={sourceSessionId} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content area */}
|
||
<div ref={contentRef} data-selection-scope="document" tabIndex={-1} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
|
||
{displayMode === "diff" && hasGitDiff ? (
|
||
<DiffView patch={gitDiff.patch!} />
|
||
) : isHtml && displayMode === "preview" ? (
|
||
<iframe
|
||
srcDoc={data.content}
|
||
sandbox="allow-scripts"
|
||
style={{ width: "100%", height: "100%", border: "none", background: "var(--bg)" }}
|
||
title={t("fileViewer.htmlPreview")}
|
||
/>
|
||
) : isMarkdown && displayMode === "preview" ? (
|
||
<div
|
||
className="markdown-body markdown-file-preview"
|
||
style={{ padding: "24px 32px" }}
|
||
>
|
||
{frontmatter && <FrontmatterCard data={frontmatter} />}
|
||
<ReactMarkdown
|
||
remarkPlugins={markdownPlugins.remarkPlugins}
|
||
rehypePlugins={markdownPlugins.rehypePlugins}
|
||
components={{
|
||
code: markdownCodeRenderer({ defaultPreview: true }),
|
||
pre({ children }) {
|
||
// Render the code block directly — CodeBlock provides its own wrapping.
|
||
// For non-mermaid blocks, pass through to default pre rendering.
|
||
return <>{children}</>;
|
||
},
|
||
a({ href, children, ...props }) {
|
||
delete props.node;
|
||
const linkedFile = onOpenFile
|
||
? resolveLocalFileHref(href, markdownDirectory, cwd ?? markdownDirectory)
|
||
: null;
|
||
if (!linkedFile || !onOpenFile) {
|
||
return <a href={href} {...props}>{children}</a>;
|
||
}
|
||
|
||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||
if (event.defaultPrevented || event.button !== 0) return;
|
||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||
event.preventDefault();
|
||
onOpenFile(linkedFile);
|
||
};
|
||
|
||
return <a href={href} {...props} onClick={handleClick}>{children}</a>;
|
||
},
|
||
img({ src, alt, ...props }) {
|
||
delete props.node;
|
||
const imagePath = typeof src === "string"
|
||
? resolveLocalFileHref(src, markdownDirectory, cwd ?? markdownDirectory)
|
||
: null;
|
||
const imageSrc = imagePath
|
||
? getFileApiUrl(imagePath, "read", sourceSessionId)
|
||
: src;
|
||
// Dynamic local paths are served directly by the file API.
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
return <img src={imageSrc} alt={alt ?? ""} loading="lazy" {...props} />;
|
||
},
|
||
}}
|
||
>
|
||
{markdownPreview}
|
||
</ReactMarkdown>
|
||
</div>
|
||
) : highlightReady ? (
|
||
<SyntaxHighlighter
|
||
className={wrapLines ? "file-source-view is-wrapped" : "file-source-view"}
|
||
language={data.language === "text" ? "plaintext" : data.language}
|
||
style={isDark ? vscDarkPlus : vs}
|
||
showLineNumbers
|
||
lineNumberStyle={{
|
||
...FILE_LINE_NUMBER_STYLE,
|
||
}}
|
||
customStyle={{
|
||
margin: 0,
|
||
padding: 0,
|
||
border: 0,
|
||
backgroundColor: "var(--bg)",
|
||
...FILE_CODE_STYLE,
|
||
width: wrapLines ? "100%" : "max-content",
|
||
minWidth: "100%",
|
||
minHeight: "100%",
|
||
overflow: "visible",
|
||
}}
|
||
codeTagProps={{
|
||
style: {
|
||
fontFamily: "var(--font-mono)",
|
||
overflowWrap: wrapLines ? "anywhere" : "normal",
|
||
},
|
||
}}
|
||
renderer={(rendererProps) => (
|
||
<SourceCodeRenderer {...rendererProps} wrapLines={wrapLines} />
|
||
)}
|
||
wrapLongLines={wrapLines}
|
||
>
|
||
{data.content}
|
||
</SyntaxHighlighter>
|
||
) : (
|
||
<pre
|
||
style={{
|
||
margin: 0,
|
||
padding: "11px 13px",
|
||
fontSize: 12.5,
|
||
lineHeight: 1.62,
|
||
whiteSpace: "pre-wrap",
|
||
wordBreak: "break-word",
|
||
color: "var(--text)",
|
||
fontFamily: "var(--font-mono)",
|
||
minWidth: "100%",
|
||
minHeight: "100%",
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
{data.content}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|