Files
Andre Brait 3f0810f10d Scope Select All to active content with opt-in native-menu support (#86)
* 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
2026-09-13 17:11:28 +03:00

1280 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}