feat(codebase): add syntax highlighting via react-syntax-highlighter with vs-dark-plus theme to the file viewer
This commit is contained in:
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Read-only file viewer that pulls a file's content from Gitea via
|
||||
* `GET /api/projects/[projectId]/file?path=…`. No syntax highlight
|
||||
* yet — just monospaced text. Phase 2 can swap in Shiki/Prism if
|
||||
* the founders ever read enough code here to need it.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, AlertCircle, FileText } from "lucide-react";
|
||||
import { Loader2, AlertCircle, FileText, Copy, Check } from "lucide-react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { THEME } from "@/components/project/dashboard-ui";
|
||||
|
||||
interface GiteaFileViewerProps {
|
||||
projectId: string;
|
||||
@@ -29,6 +25,15 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
const [content, setContent] = useState<string | 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) {
|
||||
@@ -46,16 +51,16 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
.then(async r => {
|
||||
.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.content ?? "";
|
||||
})
|
||||
.then(c => {
|
||||
.then((c) => {
|
||||
if (!cancelled) setContent(c);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message || "Failed to load file");
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -70,7 +75,7 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
if (!path) {
|
||||
return (
|
||||
<Centered>
|
||||
<FileText size={18} style={{ color: INK.muted }} />
|
||||
<FileText size={18} style={{ color: THEME.muted }} />
|
||||
<span>Pick a file from the codebase to preview it here.</span>
|
||||
</Centered>
|
||||
);
|
||||
@@ -79,7 +84,11 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Centered>
|
||||
<Loader2 size={14} className="animate-spin" style={{ color: INK.muted }} />
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin"
|
||||
style={{ color: THEME.muted }}
|
||||
/>
|
||||
<span>Loading {basename(path)}…</span>
|
||||
</Centered>
|
||||
);
|
||||
@@ -88,17 +97,105 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<Centered>
|
||||
<AlertCircle size={14} style={{ color: INK.muted }} />
|
||||
<span>{error}</span>
|
||||
<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";
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
<pre style={pre}>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
background: "#1e1e1e",
|
||||
borderTopLeftRadius: THEME.radiusSm,
|
||||
borderTopRightRadius: THEME.radiusSm,
|
||||
borderBottom: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "#a1a1aa",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
}}
|
||||
>
|
||||
{basename(path)}
|
||||
</div>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: copied ? "#10b981" : "#a1a1aa",
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={wrap}>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "16px",
|
||||
background: "#1e1e1e",
|
||||
fontSize: "0.8rem",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
flex: 1,
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
lineNumberStyle={{
|
||||
minWidth: "3em",
|
||||
paddingRight: "1em",
|
||||
color: "#6e7681",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{content || ""}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,36 +206,29 @@ function basename(p: string) {
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
|
||||
gap: 10, color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
|
||||
}}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const INK = {
|
||||
ink: "#1a1a1a",
|
||||
mid: "#5f5e5a",
|
||||
muted: "#a09a90",
|
||||
border: "#e8e4dc",
|
||||
} as const;
|
||||
|
||||
const wrap: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
margin: "-4px -10px",
|
||||
};
|
||||
|
||||
const pre: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: "0.78rem",
|
||||
lineHeight: 1.55,
|
||||
color: INK.ink,
|
||||
whiteSpace: "pre",
|
||||
tabSize: 2,
|
||||
background: "#1e1e1e",
|
||||
borderBottomLeftRadius: THEME.radiusSm,
|
||||
borderBottomRightRadius: THEME.radiusSm,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user