Files
luckyyzh a70798e874 feat: webUI 文本文件编辑/保存 + 相关改动
- FileViewer 新增文本编辑模式(textarea + 保存/取消 + Ctrl+S)
- 后端 /api/files POST 新增 type=write 原子写入
- i18n 补充编辑相关文案(en/zh-CN)
- searxng-search: 加强 web_search 自动搜索条件
- worktree/next.config: 路径比较与 allowedDevOrigins 优化
2026-08-01 02:58:01 +08:00

1529 lines
54 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 {
Prism as SyntaxHighlighter,
createElement as renderSyntaxNode,
type SyntaxHighlighterProps,
} from "react-syntax-highlighter";
import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism";
import ReactMarkdown from "react-markdown";
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 { resolveLocalFileHref } from "@/lib/file-links";
import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
import { CodeBlock, MermaidBlock } from "./MermaidBlock";
import { parseUnifiedPatch } from "@/lib/patch";
import type { GitFileDiffResponse } from "@/lib/git-types";
import { useI18n } from "@/hooks/useI18n";
interface Props {
filePath: string;
cwd?: string;
sourceSessionId?: string | null;
onOpenFile?: (filePath: string) => void;
onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void;
onEditorSaved?: (filePath: string) => void;
gitRefreshKey?: number;
initialDisplayMode?: DisplayMode;
}
interface FileData {
content: string;
language: string;
size: number;
}
type DisplayMode = "source" | "preview" | "diff";
const DISPLAY_MODE_LABELS: Record<DisplayMode, string> = {
source: "Source",
preview: "Preview",
diff: "Diff",
};
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 MentionIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</svg>
);
}
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 getFileWriteApiUrl(
filePath: string,
sourceSessionId?: string | null,
): string {
const encoded = encodeFilePathForApi(filePath);
const searchParams = new URLSearchParams({ type: "write" });
if (sourceSessionId) searchParams.set("sessionId", sourceSessionId);
return `/api/files/${encoded}?${searchParams.toString()}`;
}
function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) {
const { t } = useI18n();
return (
<a
href={getFileApiUrl(filePath, "download", sourceSessionId)}
download={getFileName(filePath)}
title={t("i18n.downloadFile")}
aria-label={t("i18n.downloadFile")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</a>
);
}
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;
}));
}
function DiffView({ patch }: { patch: string }) {
const { t } = 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("i18n.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)",
}}
>
... {seg.count} unchanged lines ...
</div>
);
return result;
}
const lines = seg.lines.map((line, li) => {
const bg =
line.type === "added"
? "rgba(0,200,80,0.12)"
: line.type === "removed"
? "rgba(240,60,60,0.14)"
: "transparent";
const prefix =
line.type === "added" ? "+" : line.type === "removed" ? "-" : " ";
const prefixColor =
line.type === "added" ? "#4ade80" : line.type === "removed" ? "#f87171" : "var(--text-dim)";
return (
<div
key={li}
className="file-diff-line"
style={{
display: "flex",
minWidth: "100%",
background: bg,
borderLeft: line.type === "added"
? "3px solid #4ade80"
: line.type === "removed"
? "3px solid #f87171"
: "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 || "image"}</span>
{naturalSize && <span>{naturalSize.w} × {naturalSize.h}</span>}
{formatSizeStr && <span>{formatSizeStr}</span>}
<span
title={watching ? t("i18n.liveSync") : t("i18n.notWatching")}
style={{ display: "flex", alignItems: "center", gap: 4, color: watching ? "#4ade80" : "var(--text-dim)" }}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: watching ? "#4ade80" : "var(--border)",
display: "inline-block",
boxShadow: watching ? "0 0 4px #4ade80" : "none",
}}
/>
{watching ? "live" : "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: "#f87171", 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("Failed to load image")}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
}}
/>
)}
</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 || "audio"}</span>
{duration != null && <span>{formatDuration(duration)}</span>}
{size != null && <span>{formatSize(size)}</span>}
<span
title={watching ? t("i18n.liveSync") : t("i18n.notWatching")}
style={{ display: "flex", alignItems: "center", gap: 4, color: watching ? "#4ade80" : "var(--text-dim)" }}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: watching ? "#4ade80" : "var(--border)",
display: "inline-block",
boxShadow: watching ? "0 0 4px #4ade80" : "none",
}}
/>
{watching ? "live" : "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: "#f87171", fontSize: 13, marginBottom: 12, textAlign: "center" }}>
{error}
</div>
)}
<audio
key={src}
controls
preload="metadata"
src={src}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onError={() => setError("Failed to load audio")}
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;
}
fetch(getFileApiUrl(filePath, "meta", sourceSessionId))
.then((r) => r.json())
.then((d: { size?: number; error?: string }) => {
if (d.error) setError(d.error);
if (typeof d.size === "number") {
setSize(d.size);
if (!isPdf && d.size > DOCX_PREVIEW_MAX_BYTES) {
setError("DOCX too large for preview (>10MB)");
}
}
})
.catch((e) => 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("DOCX too large for preview (>10MB)");
return;
}
}
} catch { /* ignore */ }
setError(null);
setBust((b) => b + 1);
});
es.addEventListener("error", () => setWatching(false));
es.onerror = () => setWatching(false);
return () => {
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" ? "docx preview" : "pdf"}</span>
{size != null && <span>{formatSize(size)}</span>}
<DownloadLink filePath={filePath} sourceSessionId={sourceSessionId} />
<span
title={watching ? t("i18n.liveSync") : t("i18n.notWatching")}
style={{ display: "flex", alignItems: "center", gap: 4, color: watching ? "#4ade80" : "var(--text-dim)", flexShrink: 0 }}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: watching ? "#4ade80" : "var(--border)",
display: "inline-block",
boxShadow: watching ? "0 0 4px #4ade80" : "none",
}}
/>
{watching ? "live" : "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: "#f87171", fontSize: 13, textAlign: "center" }}>
{error}
</div>
) : (
<iframe
key={previewUrl}
src={previewUrl}
sandbox={isPdf ? undefined : ""}
title={t("i18n.previewFile", { file: getFileName(filePath) })}
style={{ width: "100%", height: "100%", border: "none", background: isPdf ? "var(--bg)" : "#eef1f5" }}
/>
)}
</div>
</div>
);
}
export function FileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: 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} onEditorSaved={onEditorSaved} gitRefreshKey={gitRefreshKey} initialDisplayMode={initialDisplayMode} />;
}
function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionLines, onEditorSaved, gitRefreshKey, initialDisplayMode }: Props) {
const { isDark } = useTheme();
const { t } = useI18n();
const [data, setData] = useState<FileData | null>(null);
const [gitDiff, setGitDiff] = useState<GitFileDiffResponse | null>(null);
const [gitDiffLoading, setGitDiffLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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 contentRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const [selectedLineRange, setSelectedLineRange] = useState<SelectedLineRange | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<string>("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const fetchContent = useCallback((filePath: string) => {
return fetch(getFileApiUrl(filePath, "read", sourceSessionId))
.then((r) => r.json())
.then((d: FileData & { error?: string }) => {
if (d.error) {
setError(d.error);
return null;
}
setError(null);
setData(d);
return d;
})
.catch((e) => {
setError(String(e));
return null;
});
}, [sourceSessionId]);
const fetchGitDiff = useCallback(async (targetPath: string) => {
const requestId = ++gitDiffRequestRef.current;
setGitDiffLoading(true);
if (!cwd) {
setGitDiff(null);
setGitDiffLoading(false);
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);
} finally {
if (requestId === gitDiffRequestRef.current) setGitDiffLoading(false);
}
}, [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;
}
fetchContent(filePath).finally(() => 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 () => {
es.close();
esRef.current = null;
};
}, [filePath, fetchContent, fetchGitDiff, sourceSessionId]);
useEffect(() => {
void fetchGitDiff(filePath);
}, [fetchGitDiff, filePath, gitRefreshKey]);
useEffect(() => {
if (data?.language === "markdown" && initialDisplayMode !== "diff") {
setDisplayMode("preview");
}
}, [data?.language, initialDisplayMode]);
const hasGitDiff = gitDiff?.supported === true && typeof gitDiff.patch === "string";
const isDeletedDiff = hasGitDiff && gitDiff.status === "deleted";
useEffect(() => {
if (!hasGitDiff && displayMode === "diff") setDisplayMode("source");
}, [displayMode, hasGitDiff]);
useEffect(() => {
if (!isDeletedDiff || !esRef.current) return;
esRef.current.close();
esRef.current = null;
setWatching(false);
}, [isDeletedDiff]);
// Opened from the Changes list (initialDisplayMode === "diff"): switch to the
// diff view once the git diff has resolved. We do this after the diff loads
// rather than at mount so files without a diff never flash an empty diff view.
const autoDiffAppliedRef = useRef(false);
useEffect(() => {
autoDiffAppliedRef.current = false;
}, [filePath]);
useEffect(() => {
if (initialDisplayMode === "diff" && hasGitDiff && !autoDiffAppliedRef.current) {
autoDiffAppliedRef.current = true;
setDisplayMode("diff");
}
}, [initialDisplayMode, hasGitDiff]);
const markdownPreview = useMemo(
() => (data?.language === "markdown" ? normalizeDisplayMath(data.content) : ""),
[data],
);
useEffect(() => {
const updateSelectedLineRange = () => {
const root = contentRef.current;
setSelectedLineRange(
onMentionLines && displayMode === "source" && root
? getSelectedSourceLineRange(root, window.getSelection())
: null,
);
};
updateSelectedLineRange();
if (!onMentionLines || displayMode !== "source") return;
document.addEventListener("selectionchange", updateSelectedLineRange);
return () => document.removeEventListener("selectionchange", updateSelectedLineRange);
}, [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]);
const isDeleted = isDeletedDiff;
// Every text file routed to this viewer (i.e. non-image/audio/document) can
// be edited; the backend independently rejects binary/read-only types.
const editable = !isDeleted && !!data;
const dirty = isEditing && draft !== (data?.content ?? "");
const stopEditing = useCallback(() => {
setConfirmDiscard(false);
setIsEditing(false);
setSaveError(null);
setDraft("");
}, []);
const exitEditingGuard = useCallback((handler: () => void) => {
if (dirty) {
setConfirmDiscard(true);
return;
}
stopEditing();
handler();
}, [dirty, stopEditing]);
const startEditing = useCallback(() => {
if (!data) return;
setDraft(data.content);
setSaveError(null);
setConfirmDiscard(false);
setIsEditing(true);
requestAnimationFrame(() => editorRef.current?.focus());
}, [data]);
const saveFile = useCallback(async () => {
if (saving || !dirty) {
if (!dirty && isEditing) stopEditing();
return;
}
setSaving(true);
setSaveError(null);
try {
const response = await fetch(
getFileWriteApiUrl(filePath, sourceSessionId),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: draft }),
},
);
const result = await response.json().catch(() => ({})) as { size?: number; error?: string };
if (!response.ok) {
setSaveError(result.error ?? `HTTP ${response.status}`);
return;
}
setData((prev) => (prev ? { ...prev, content: draft, size: result.size ?? prev.size } : prev));
stopEditing();
void fetchGitDiff(filePath);
onEditorSaved?.(filePath);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}, [dirty, draft, filePath, fetchGitDiff, isEditing, onEditorSaved, saving, sourceSessionId, stopEditing]);
// Reset edit state whenever the file path or content source changes.
useEffect(() => {
stopEditing();
setSaveError(null);
setSaving(false);
}, [filePath, sourceSessionId, stopEditing]);
// Ctrl/Cmd+S saves while editing.
useEffect(() => {
if (!isEditing) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat || event.key.toLowerCase() !== "s" || (!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return;
event.preventDefault();
void saveFile();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isEditing, saveFile]);
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 || (initialDisplayMode === "diff" && gitDiffLoading && !data)) {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: 13 }}>
{t("i18n.loading")}
</div>
);
}
if (error && !isDeletedDiff) {
return (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#f87171", fontSize: 13 }}>
{error}
</div>
);
}
if (!data && !isDeletedDiff) return null;
const language = data?.language ?? "text";
const content = data?.content ?? "";
const isHtml = language === "html";
const isMarkdown = language === "markdown";
const hasPreview = isHtml || isMarkdown;
const markdownDirectory = getFileDirectory(filePath);
const lines = content.split("\n");
const effectiveDisplayMode = isDeletedDiff ? "diff" : displayMode;
const displayModes: DisplayMode[] = isDeletedDiff
? ["diff"]
: [
"source",
...(hasPreview ? ["preview" as const] : []),
...(hasGitDiff ? ["diff" as const] : []),
];
const metadata = isDeletedDiff
? t("files.deleted")
: `${language} · ${lines.length} lines · ${formatSize(data!.size)}`;
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={filePath}>
{getRelativeFilePath(filePath, cwd)}
</span>
<span className="file-viewer-meta" title={metadata}>{metadata}</span>
{!isDeletedDiff && (
<span
title={watching ? t("i18n.liveSync") : t("i18n.notWatching")}
aria-label={watching ? t("i18n.liveSync") : t("i18n.notWatching")}
className="file-viewer-live-indicator"
style={{
background: watching ? "#4ade80" : "var(--border)",
boxShadow: watching ? "0 0 4px #4ade80" : "none",
}}
/>
)}
<div className="file-viewer-controls">
{displayModes.length > 1 && (
<div className="file-viewer-mode-switch" aria-label={t("i18n.fileViewMode")}>
{displayModes.map((mode) => {
const active = effectiveDisplayMode === mode;
return (
<button
key={mode}
type="button"
onClick={() => exitEditingGuard(() => setDisplayMode(mode))}
title={mode === "diff" ? t("i18n.compareHead") : undefined}
aria-pressed={active}
className="file-viewer-mode-button"
style={{
background: active ? "var(--bg-selected)" : "transparent",
color: active ? "var(--text)" : "var(--text-muted)",
}}
>
{DISPLAY_MODE_LABELS[mode]}
</button>
);
})}
</div>
)}
<div className="file-viewer-actions">
{effectiveDisplayMode === "source" && !isEditing && editable && (
<button
type="button"
onClick={startEditing}
title={t("i18n.editFile")}
aria-label={t("i18n.editFile")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
</button>
)}
{isEditing && (
<>
<button
type="button"
onClick={() => void saveFile()}
disabled={saving || !dirty}
title={t("i18n.saveShortcut")}
aria-label={t("i18n.saveFile")}
className="file-viewer-icon-button"
style={{
color: dirty ? "#22c55e" : "var(--text-muted)",
opacity: saving || !dirty ? 0.6 : 1,
}}
>
{saving ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M17 21v-8H7v8" />
<path d="M7 3v5h8" />
</svg>
)}
</button>
<button
type="button"
onClick={() => {
if (dirty) setConfirmDiscard(true);
else stopEditing();
}}
title={t("i18n.stopEditing")}
aria-label={t("i18n.stopEditing")}
className="file-viewer-icon-button"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</>
)}
{effectiveDisplayMode === "source" && !isEditing && (
<>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={handleMentionSelectedLines}
title={t("i18n.mentionSelectedLines")}
aria-label={t("i18n.mentionSelectedLines")}
disabled={!selectedLineRange}
className="file-viewer-icon-button"
>
<MentionIcon />
</button>
<button
type="button"
onClick={() => setWrapLines((value) => !value)}
title={wrapLines ? t("i18n.disableWrap") : t("i18n.enableWrap")}
aria-label={wrapLines ? t("i18n.disableWrap") : t("i18n.enableWrap")}
aria-pressed={wrapLines}
className="file-viewer-icon-button"
style={{
background: wrapLines ? "var(--bg-selected)" : "transparent",
color: wrapLines ? "var(--text)" : "var(--text-muted)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h18" />
<path d="M3 12h15a3 3 0 1 1 0 6h-4" />
<path d="m16 16-2 2 2 2" />
<path d="M3 18h7" />
</svg>
</button>
</>
)}
</div>
{!isDeletedDiff && <DownloadLink filePath={filePath} sourceSessionId={sourceSessionId} />}
</div>
</div>
{/* Content area */}
<div ref={contentRef} className="file-viewer-content" style={{ flex: 1, overflow: "auto", background: "var(--bg)" }}>
{isEditing ? (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
{saveError && (
<div
role="alert"
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 12px",
fontSize: 11,
color: "#f87171",
background: "color-mix(in srgb, #ef4444 8%, var(--bg))",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("i18n.saveError", { error: saveError })}
</span>
<button
type="button"
onClick={() => setSaveError(null)}
title={t("i18n.close")}
style={{ marginLeft: "auto", background: "none", border: "none", color: "var(--text-dim)", cursor: "pointer", padding: 2, display: "flex" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="m6 6 12 12" />
<path d="M18 6 6 18" />
</svg>
</button>
</div>
)}
<textarea
ref={editorRef}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
aria-label={t("i18n.editFile")}
style={{
flex: 1,
width: "100%",
minHeight: 0,
resize: "none",
border: "none",
outline: "none",
background: "var(--bg)",
color: "var(--text)",
padding: "14px 16px",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.6,
whiteSpace: "pre",
overflow: "auto",
tabSize: 2,
}}
onKeyDown={(e) => {
// Tab inserts spaces instead of moving focus.
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const indent = " ";
const next = draft.slice(0, start) + indent + draft.slice(end);
setDraft(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + indent.length;
});
}
}}
/>
</div>
) : effectiveDisplayMode === "diff" && hasGitDiff ? (
<DiffView patch={gitDiff.patch!} />
) : isHtml && effectiveDisplayMode === "preview" ? (
<iframe
srcDoc={content}
sandbox="allow-scripts"
style={{ width: "100%", height: "100%", border: "none", background: "var(--bg)" }}
title={t("i18n.htmlPreview")}
/>
) : isMarkdown && effectiveDisplayMode === "preview" ? (
<div
className="markdown-body markdown-file-preview"
style={{ padding: "24px 32px" }}
>
<ReactMarkdown
remarkPlugins={markdownPreviewRemarkPlugins}
rehypePlugins={markdownPreviewRehypePlugins}
components={{
code({ className, children, ...props }) {
const lang = className?.replace("language-", "").toLowerCase() ?? "";
const raw = String(children);
const isBlock = className?.includes("language-") || raw.includes("\n");
if (isBlock) {
if (lang === "mermaid") {
return <MermaidBlock code={raw.replace(/\n$/, "")} defaultPreview />;
}
return <CodeBlock code={raw.replace(/\n$/, "")} lang={lang} />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
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>
) : (
<SyntaxHighlighter
className={wrapLines ? "file-source-view is-wrapped" : "file-source-view"}
language={language === "text" ? "plaintext" : language}
style={isDark ? vscDarkPlus : vs}
showLineNumbers
lineNumberStyle={{
...FILE_LINE_NUMBER_STYLE,
}}
customStyle={{
margin: 0,
padding: 0,
border: 0,
background: "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}
>
{content}
</SyntaxHighlighter>
)}
</div>
{confirmDiscard && (
<div
role="alertdialog"
aria-modal="true"
aria-label={t("i18n.discardChanges")}
style={{
position: "absolute",
inset: 0,
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.32)",
}}
>
<div
style={{
width: "min(360px, calc(100% - 48px))",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: "16px 16px 12px",
boxShadow: "0 12px 32px rgba(0,0,0,0.28)",
}}
>
<div style={{ fontSize: 13, lineHeight: 1.45, color: "var(--text)" }}>
{t("i18n.closeWithoutSaving")}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 6, marginTop: 14 }}>
<button
type="button"
onClick={() => setConfirmDiscard(false)}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "var(--bg)", color: "var(--text)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.cancel")}
</button>
<button
type="button"
onClick={stopEditing}
style={{ height: 26, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 5, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 11 }}
>
{t("i18n.discardChanges")}
</button>
</div>
</div>
</div>
)}
</div>
);
}