feat(codebase): add syntax highlighting via react-syntax-highlighter with vs-dark-plus theme to the file viewer

This commit is contained in:
2026-06-14 13:02:21 -07:00
parent 249d88f405
commit 759ad99cd8
3 changed files with 233 additions and 42 deletions

View File

@@ -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,
};