mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
- FileViewer 新增文本编辑模式(textarea + 保存/取消 + Ctrl+S) - 后端 /api/files POST 新增 type=write 原子写入 - i18n 补充编辑相关文案(en/zh-CN) - searxng-search: 加强 web_search 自动搜索条件 - worktree/next.config: 路径比较与 allowedDevOrigins 优化
1529 lines
54 KiB
TypeScript
1529 lines
54 KiB
TypeScript
"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>
|
||
);
|
||
}
|