314 lines
7.6 KiB
TypeScript
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,
|
|
};
|