feat: implement file editor with syntax highlighting and editing

- Install react-simple-code-editor and prismjs dependencies
- Create language detection utility with 50+ file extensions
- Create SyntaxHighlighter component with Prism.js highlighting
- Create CodeEditor component with syntax-highlighted editing
- Create CommitDialog with diff preview and commit message
- Create FileEditor component integrating view/edit/commit flow
- Replace FileViewer with FileEditor in RepoWorkspace
- Add comprehensive CSS styles for editor, highlighter, and dialog
- Support keyboard shortcuts: Ctrl+E (toggle edit), Ctrl+S (save)
- Quality gates: typecheck ✓ lint ✓ build ✓
This commit is contained in:
Fusion
2026-05-19 16:04:48 +02:00
parent 84bf7e4aeb
commit 7261c75bb2
14 changed files with 1533 additions and 90 deletions
+28
View File
@@ -8,10 +8,13 @@
"name": "headquarter-web",
"version": "0.1.0",
"dependencies": {
"@types/prismjs": "^1.26.6",
"axios": "^1.6.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
},
"devDependencies": {
@@ -2127,6 +2130,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -5091,6 +5100,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -5205,6 +5223,16 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-simple-code-editor": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz",
"integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+3
View File
@@ -11,10 +11,13 @@
"test": "vitest run"
},
"dependencies": {
"@types/prismjs": "^1.26.6",
"axios": "^1.6.0",
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-simple-code-editor": "^0.14.1",
"tailwindcss": "^3.3.0"
},
"devDependencies": {
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect } from "react";
import Editor from "react-simple-code-editor";
import { highlightCode, loadLanguage } from "../utils/language";
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language: string;
readOnly?: boolean;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
value,
onChange,
language,
readOnly = false,
}) => {
useEffect(() => {
const highlight = async () => {
await loadLanguage(language);
};
void highlight();
}, [language]);
const hightlightWithLineNumbers = (input: string) =>
input
.split("\n")
.map(
(line, i) =>
`<div class="editor-line"><span class="editor-line-number">${
i + 1
}</span><span class="editor-line-content">${highlightCode(
line || " ",
language
)}</span></div>`
)
.join("");
return (
<div className="code-editor">
<Editor
value={value}
onValueChange={onChange}
highlight={hightlightWithLineNumbers}
padding={0}
className="editor-textarea"
textareaClassName="editor-textarea-input"
readOnly={readOnly}
style={{
fontFamily: '"Fira Code", "Monaco", "Courier New", monospace',
fontSize: 14,
}}
/>
</div>
);
};
+146
View File
@@ -0,0 +1,146 @@
import React, { useState } from "react";
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
originalContent: string;
newContent: string;
onCommit: (message: string) => Promise<void>;
onCancel: () => void;
}
export const CommitDialog: React.FC<CommitDialogProps> = ({
isOpen,
filePath,
originalContent,
newContent,
onCommit,
onCancel,
}) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(">");
if (!isOpen) return null;
const generateDiff = () => {
const originalLines = originalContent.split("\n");
const newLines = newContent.split("\n");
const maxLines = Math.max(originalLines.length, newLines.length);
const diff: { type: "same" | "added" | "removed"; line: string; lineNum: number }[] = [];
for (let i = 0; i < maxLines; i++) {
const original = originalLines[i] || "";
const updated = newLines[i] || "";
if (original === updated) {
diff.push({ type: "same", line: updated, lineNum: i + 1 });
} else {
if (original) {
diff.push({ type: "removed", line: original, lineNum: i + 1 });
}
if (updated) {
diff.push({ type: "added", line: updated, lineNum: i + 1 });
}
}
}
return diff;
};
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError("");
try {
await onCommit(message);
} catch {
setError("Failed to commit changes");
} finally {
setLoading(false);
}
};
const diff = generateDiff();
const hasChanges = diff.some((d) => d.type !== "same");
return (
<div className="dialog-overlay">
<div className="commit-dialog">
<div className="dialog-header">
<h3>Commit Changes</h3>
<button className="dialog-close" onClick={onCancel} type="button">
×
</button>
</div>
<div className="dialog-body">
<p className="file-info">
Editing: <strong>{filePath}</strong>
</p>
{!hasChanges && (
<div className="warning-message">No changes to commit</div>
)}
{hasChanges && (
<div className="diff-preview">
<h4>Changes</h4>
<div className="diff-content">
{diff.map((line, i) => (
<div
key={i}
className={`diff-line diff-${line.type}`}
>
<span className="diff-line-number">{line.lineNum}</span>
<span className="diff-marker">
{line.type === "added" && "+"}
{line.type === "removed" && "-"}
{line.type === "same" && " "}
</span>
<span className="diff-line-content">{line.line}</span>
</div>
))}
</div>
</div>
)}
<div className="form-group">
<label>Commit Message *</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Describe your changes..."
rows={3}
className="form-textarea"
/>
</div>
{error && <div className="error-message">{error}</div>}
</div>
<div className="dialog-footer">
<button
className="btn-secondary"
onClick={onCancel}
type="button"
>
Cancel
</button>
<button
className="btn-primary"
onClick={handleCommit}
disabled={loading || !hasChanges || !message.trim()}
type="button"
>
{loading ? "Committing..." : "Commit Changes"}
</button>
</div>
</div>
</div>
);
};
+228
View File
@@ -0,0 +1,228 @@
import React, { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { useAuth } from "../state/auth";
import { CodeEditor } from "../components/code-editor";
import { CommitDialog } from "../components/commit-dialog";
import { SyntaxHighlighter } from "../components/syntax-highlighter";
import { detectLanguage } from "../utils/language";
interface FileEditorProps {
projectId: string;
repoId: string;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const [mode, setMode] = useState<"view" | "edit">("view");
const [content, setContent] = useState(">");
const [originalContent, setOriginalContent] = useState(">");
const [language, setLanguage] = useState("plaintext");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
setOriginalContent("");
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
setLanguage(detectLanguage(filePath));
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
const handleEdit = () => {
if (isBinary) return;
setMode("edit");
};
const handleCancel = () => {
setContent(originalContent);
setMode("view");
setShowCommitDialog(false);
};
const handleSave = () => {
if (content === originalContent) {
setMode("view");
return;
}
setShowCommitDialog(true);
};
const handleCommit = async (message: string) => {
if (!filePath || !user) return;
setSaving(true);
try {
await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
path: filePath,
branch,
content,
commit_message: message,
author_name: user.name || "User",
author_email: user.email || "user@example.com",
}
);
setOriginalContent(content);
setMode("view");
setShowCommitDialog(false);
} catch {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
if (mode === "view" && !isBinary) {
handleEdit();
} else if (mode === "edit") {
handleCancel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (mode === "edit") {
handleSave();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, isBinary, content, originalContent]);
if (!filePath) {
return (
<div className="file-viewer-empty">
<p className="muted">Select a file to view its contents</p>
</div>
);
}
if (loading) return <p className="muted">Loading file...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-editor">
<div className="file-editor-toolbar">
<div className="file-breadcrumbs">
{filePath.split("/").map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
<span className="breadcrumb-sep">/</span>
)}
</span>
))}
</div>
<div className="file-actions">
{mode === "view" && !isBinary && (
<button
className="btn-primary"
onClick={handleEdit}
type="button"
>
Edit
</button>
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? "Saving..." : "Save"}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
Cancel
</button>
</>
)}
</div>
</div>
<div className="file-editor-content">
{mode === "view" && (
<SyntaxHighlighter
code={content}
language={language}
showLineNumbers={!isBinary}
/>
)}
{mode === "edit" && (
<CodeEditor
value={content}
onChange={setContent}
language={language}
/>
)}
</div>
<CommitDialog
isOpen={showCommitDialog}
filePath={filePath}
originalContent={originalContent}
newContent={content}
onCommit={handleCommit}
onCancel={() => setShowCommitDialog(false)}
/>
</div>
);
};
@@ -0,0 +1,65 @@
import React, { useEffect, useState } from "react";
import { highlightCode, loadLanguage } from "../utils/language";
interface SyntaxHighlighterProps {
code: string;
language: string;
showLineNumbers?: boolean;
}
export const SyntaxHighlighter: React.FC<SyntaxHighlighterProps> = ({
code,
language,
showLineNumbers = true,
}) => {
const [highlighted, setHighlighted] = useState(">");
const [copied, setCopied] = useState(false);
useEffect(() => {
const highlight = async () => {
await loadLanguage(language);
setHighlighted(highlightCode(code, language));
};
void highlight();
}, [code, language]);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const lines = code.split("\n");
return (
<div className="syntax-highlighter">
<div className="highlighter-toolbar">
<span className="language-badge">{language}</span>
<button
className="copy-button"
onClick={handleCopy}
type="button"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<div className="code-container">
{showLineNumbers && (
<div className="line-numbers">
{lines.map((_, i) => (
<div key={i} className="line-number">
{i + 1}
</div>
))}
</div>
)}
<pre className="code-block">
<code
className={`language-${language}`}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
</pre>
</div>
</div>
);
};
+2 -90
View File
@@ -10,6 +10,7 @@ import {
type GitStatus,
} from "../api/git_repositories";
import { CommitPanel } from "../components/commit-panel";
import { FileEditor } from "../components/file-editor";
import { GitToolbar } from "../components/git-toolbar";
import { WorkspaceHeader } from "../components/workspace-header";
@@ -237,7 +238,7 @@ export const RepoWorkspace = () => {
<main className="workspace-main">
{selectedRepoId && (
<FileViewer projectId={projectId!} repoId={selectedRepoId} />
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
</main>
</div>
@@ -365,92 +366,3 @@ const FileBrowser = ({
);
};
// File Viewer Component
const FileViewer = ({
projectId,
repoId,
}: {
projectId: string;
repoId: string;
}) => {
const [searchParams] = useSearchParams();
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent(null);
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
} else {
setIsBinary(false);
setContent(data.content);
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
if (!filePath) {
return (
<div className="file-viewer-empty">
<p className="muted">Select a file to view its contents</p>
</div>
);
}
if (loading) return <p className="muted">Loading file...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-viewer">
<div className="file-viewer-header">
<div className="file-breadcrumbs">
{filePath.split("/").map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && <span className="breadcrumb-sep">/</span>}
</span>
))}
</div>
</div>
<div className="file-content">
{isBinary ? (
<p className="muted">{content}</p>
) : (
<pre>
<code>{content}</code>
</pre>
)}
</div>
</div>
);
};
+365
View File
@@ -1377,3 +1377,368 @@ a {
background: rgba(107, 114, 128, 0.1);
color: #4b5563;
}
/* File Editor */
.file-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.file-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.file-editor-content {
flex: 1;
overflow: auto;
background: var(--bg);
}
/* Syntax Highlighter */
.syntax-highlighter {
display: flex;
flex-direction: column;
height: 100%;
}
.highlighter-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.language-badge {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
background: var(--bg);
border-radius: 4px;
color: var(--muted);
}
.copy-button {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--ink);
}
.copy-button:hover {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.code-container {
display: flex;
flex: 1;
overflow: auto;
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
.line-numbers {
display: flex;
flex-direction: column;
padding: 1rem 0.5rem;
background: var(--panel);
border-right: 1px solid var(--border);
color: var(--muted);
text-align: right;
user-select: none;
min-width: 3rem;
}
.line-number {
padding: 0 0.5rem;
}
.code-block {
flex: 1;
margin: 0;
padding: 1rem;
overflow: auto;
background: transparent;
}
.code-block code {
display: block;
background: transparent;
}
/* Code Editor */
.code-editor {
height: 100%;
overflow: auto;
}
.editor-textarea {
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
min-height: 100%;
}
.editor-textarea-input {
background: transparent;
color: var(--ink);
caret-color: var(--ink);
}
.editor-line {
display: flex;
}
.editor-line-number {
display: inline-block;
width: 3rem;
padding: 0 0.5rem;
text-align: right;
color: var(--muted);
user-select: none;
background: var(--panel);
border-right: 1px solid var(--border);
}
.editor-line-content {
flex: 1;
padding: 0 0.5rem;
white-space: pre;
}
/* Commit Dialog */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.commit-dialog {
background: var(--panel);
border-radius: 14px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.dialog-header h3 {
margin: 0;
font-size: 1.1rem;
}
.dialog-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--muted);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.dialog-close:hover {
background: var(--bg);
color: var(--ink);
}
.dialog-body {
padding: 1.5rem;
}
.file-info {
margin: 0 0 1rem;
color: var(--muted);
}
.diff-preview {
margin-bottom: 1.5rem;
}
.diff-preview h4 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--muted);
}
.diff-content {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 300px;
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
font-size: 13px;
}
.diff-line {
display: flex;
padding: 0.15rem 0.5rem;
gap: 0.5rem;
}
.diff-line-number {
color: var(--muted);
min-width: 2rem;
text-align: right;
user-select: none;
}
.diff-marker {
width: 1rem;
text-align: center;
font-weight: bold;
}
.diff-added {
background: rgba(16, 185, 129, 0.1);
}
.diff-added .diff-marker {
color: #059669;
}
.diff-removed {
background: rgba(239, 68, 68, 0.1);
}
.diff-removed .diff-marker {
color: #dc2626;
}
.diff-same {
background: transparent;
}
.warning-message {
padding: 0.75rem;
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border-radius: 8px;
margin-bottom: 1rem;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
/* Prism.js Theme Integration */
code[class*="language-"],
pre[class*="language-"] {
color: var(--ink);
text-shadow: none;
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
font-size: 14px;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 2;
hyphens: none;
}
/* Syntax Highlighting Colors */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--muted);
}
.token.punctuation {
color: var(--ink);
}
.token.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f59e0b;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #10b981;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #f43f5e;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #3b82f6;
}
.token.function,
.token.class-name {
color: #8b5cf6;
}
.token.regex,
.token.important,
.token.variable {
color: #ec4899;
}
+90
View File
@@ -0,0 +1,90 @@
import Prism from "prismjs";
const EXTENSION_MAP: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
md: "markdown",
markdown: "markdown",
json: "json",
yaml: "yaml",
yml: "yaml",
html: "html",
htm: "html",
xml: "xml",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
sh: "bash",
bash: "bash",
zsh: "bash",
dockerfile: "dockerfile",
sql: "sql",
rs: "rust",
go: "go",
rb: "ruby",
php: "php",
java: "java",
kt: "kotlin",
scala: "scala",
c: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
swift: "swift",
dart: "dart",
lua: "lua",
r: "r",
matlab: "matlab",
perl: "perl",
clj: "clojure",
cljs: "clojure",
edn: "clojure",
groovy: "groovy",
tf: "hcl",
hcl: "hcl",
vue: "vue",
svelte: "svelte",
graphql: "graphql",
gql: "graphql",
toml: "toml",
ini: "ini",
cfg: "ini",
conf: "ini",
env: "bash",
gitignore: "gitignore",
gitattributes: "gitattributes",
diff: "diff",
patch: "diff",
log: "log",
txt: "plaintext",
};
export const detectLanguage = (filename: string): string => {
const ext = filename.split(".").pop()?.toLowerCase() || "";
return EXTENSION_MAP[ext] || "plaintext";
};
export const highlightCode = (code: string, language: string): string => {
const grammar = Prism.languages[language] || Prism.languages.plaintext;
return Prism.highlight(code, grammar, language);
};
export const loadLanguage = async (language: string): Promise<void> => {
if (language === "plaintext" || Prism.languages[language]) {
return;
}
// Dynamic import for language support
try {
await import(`prismjs/components/prism-${language}`);
} catch {
// Language not available, use plaintext
}
};