Files
vibn-agent-runner/vibn-frontend/components/project/gitea-file-viewer.tsx

314 lines
7.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Loader2, AlertCircle, FileText, Copy, Check } from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { THEME } from "@/components/project/dashboard-ui";
interface GiteaFileViewerProps {
projectId: string;
/** Repo path of the file to view, e.g. "apps/web/package.json".
* When null, an empty state prompts the user to pick a file. */
path: string | null;
}
interface ApiResponse {
type: "file" | "dir";
content?: string;
encoding?: string;
name?: string;
error?: string;
}
export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
const [content, setContent] = useState<string | null>(null);
const [fileData, setFileData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const copyCode = () => {
if (content) {
navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
useEffect(() => {
if (!path) {
setContent(null);
setFileData(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setContent(null);
setFileData(null);
fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, {
credentials: "include",
})
.then(async (r) => {
const data = (await r.json()) as ApiResponse;
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
if (data.type !== "file") throw new Error("Not a file");
return data;
})
.then((data) => {
if (!cancelled) {
setContent(data.content ?? "");
setFileData(data);
}
})
.catch((err) => {
if (!cancelled) setError(err.message || "Failed to load file");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, path]);
if (!path) {
return (
<Centered path={path}>
<FileText size={18} style={{ color: THEME.muted }} />
<span>Pick a file from the codebase to preview it here.</span>
</Centered>
);
}
if (loading) {
return (
<Centered path={path}>
<Loader2
size={14}
className="animate-spin"
style={{ color: THEME.muted }}
/>
<span>Loading {basename(path)}</span>
</Centered>
);
}
if (error) {
return (
<Centered path={path}>
<AlertCircle size={14} style={{ color: THEME.danger }} />
<span style={{ color: THEME.danger }}>{error}</span>
</Centered>
);
}
const extension = path.split(".").pop() || "text";
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
json: "json",
html: "html",
css: "css",
scss: "scss",
md: "markdown",
sh: "bash",
yml: "yaml",
yaml: "yaml",
sql: "sql",
py: "python",
rs: "rust",
go: "go",
};
const language = languageMap[extension] || "text";
const isImage =
fileData?.encoding === "base64" &&
fileData.name &&
/\.(png|jpg|jpeg|gif|webp|svg|ico)$/i.test(fileData.name);
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
position: "relative",
background: THEME.cardBg,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 16px",
background: THEME.subtleBg,
borderBottom: `1px solid ${THEME.borderSoft}`,
height: "41px",
boxSizing: "border-box",
}}
>
<div
style={{
fontSize: "0.85rem",
color: THEME.mid,
fontWeight: 500,
}}
>
{path}
</div>
{!isImage && (
<button
onClick={copyCode}
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: "transparent",
border: "none",
color: copied ? "#059669" : THEME.mid,
fontSize: "0.75rem",
cursor: "pointer",
fontWeight: 500,
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? "Copied" : "Copy"}
</button>
)}
</div>
<div style={wrap}>
{isImage ? (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 32,
minHeight: 300,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/${extension === "svg" ? "svg+xml" : extension};base64,${(
fileData?.content || ""
).replace(/\n/g, "")}`}
alt={basename(path)}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
/>
</div>
) : (
<SyntaxHighlighter
language={language}
style={oneLight}
customStyle={{
margin: 0,
padding: "24px 16px 24px 0",
background: THEME.cardBg,
fontSize: "13px",
lineHeight: "24px",
fontFamily:
'"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
flex: 1,
}}
showLineNumbers={true}
lineNumberStyle={{
minWidth: "48px",
paddingRight: "24px",
color: "#94a3b8",
textAlign: "right",
userSelect: "none",
}}
>
{content || ""}
</SyntaxHighlighter>
)}
</div>
</div>
);
}
function basename(p: string) {
return p.split("/").pop() || p;
}
function Centered({
children,
path,
}: {
children: React.ReactNode;
path: string | null;
}) {
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
minHeight: 0,
background: THEME.cardBg,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 16px",
background: THEME.subtleBg,
borderBottom: `1px solid ${THEME.borderSoft}`,
height: "41px",
boxSizing: "border-box",
}}
>
<div
style={{
fontSize: "0.85rem",
color: THEME.mid,
fontWeight: 500,
}}
>
{path || "Select a file"}
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: THEME.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
</div>
);
}
const wrap: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflow: "auto",
background: THEME.cardBg,
};