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
}
};
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-19
+191
View File
@@ -0,0 +1,191 @@
# File Editor - Design
## Architecture
```
RepoWorkspace
└── FileEditor (replaces FileViewer)
├── View Mode
│ ├── Toolbar (Edit button, file info)
│ ├── SyntaxHighlighter (Prism.js)
│ └── Line numbers
└── Edit Mode
├── Toolbar (Save, Cancel, file info)
├── Editor (react-simple-code-editor)
└── Line numbers
↓ [Save clicked]
CommitDialog
├── Diff preview
├── Commit message input
└── Author info
```
## Component Design
### FileEditor
**Props:**
```typescript
interface FileEditorProps {
projectId: string;
repoId: string;
filePath: string;
branch: string;
}
```
**State:**
```typescript
interface FileEditorState {
mode: 'view' | 'edit';
content: string;
originalContent: string;
language: string;
loading: boolean;
error: string | null;
showCommitDialog: boolean;
}
```
**Flow:**
1. Load file content on mount/file change
2. Detect language from file extension
3. Display in view mode by default
4. Click "Edit" → switch to edit mode
5. Make changes → click "Save"
6. Show commit dialog with diff
7. Enter commit message → commit
8. Return to view mode with updated content
### SyntaxHighlighter (View Mode)
**Implementation:**
- Use Prism.js for tokenization
- Render highlighted tokens as HTML
- Add line numbers via CSS counter
- Copy-to-clipboard button
**Language Detection:**
```typescript
const detectLanguage = (filename: string): string => {
const ext = filename.split('.').pop()?.toLowerCase();
const langMap: Record<string, string> = {
'js': 'javascript',
'ts': 'typescript',
'tsx': 'tsx',
'jsx': 'jsx',
'py': 'python',
'md': 'markdown',
// ... more mappings
};
return langMap[ext || ''] || 'plaintext';
};
```
### CodeEditor (Edit Mode)
**Implementation:**
- react-simple-code-editor component
- Prism.js highlighting via textarea overlay
- Line numbers synchronized with content
- Tab key support (inserts spaces)
**Features:**
- Syntax highlighting while typing
- Auto-indentation
- Line numbers
- Selection highlighting
### CommitDialog
**Props:**
```typescript
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
originalContent: string;
newContent: string;
onCommit: (message: string) => void;
onCancel: () => void;
}
```
**Features:**
- Diff preview (simple line-by-line comparison)
- Commit message input (required)
- Author info (from user profile)
- Cancel button (returns to edit mode)
## API Integration
### Load File (existing)
```
GET /projects/{id}/repositories/{id}/files/content
Query: branch, path
```
### Save File (existing)
```
POST /projects/{id}/repositories/{id}/files/content
Body: {
path: string,
branch: string,
content: string,
commit_message: string,
author_name: string,
author_email: string
}
```
## Styling
### Editor Layout
```
┌─────────────────────────────────────────┐
│ [file.txt] [Edit] [Raw] │ ← Toolbar
├─────────────────────────────────────────┤
│ 1 │ function hello() { │ ← Line numbers + content
│ 2 │ return "world"; │
│ 3 │ } │
└─────────────────────────────────────────┘
```
### Colors
- Match existing app theme (CSS variables)
- Syntax colors:
- Keywords: var(--brand)
- Strings: #10b981
- Comments: var(--muted)
- Numbers: #f59e0b
- Functions: #3b82f6
### Responsive
- Editor takes full width on mobile
- Toolbar buttons shrink to icons
- Line numbers hidden on very small screens
## Implementation Order
1. **Install dependencies** - react-simple-code-editor, prismjs
2. **Create SyntaxHighlighter component** - Prism.js wrapper
3. **Create CodeEditor component** - Edit mode with highlighting
4. **Create CommitDialog component** - Commit flow
5. **Create FileEditor component** - Main component orchestrating modes
6. **Replace FileViewer in RepoWorkspace**
7. **Add CSS styles** - Editor styling, syntax colors
8. **Test** - Various file types, commit flow
## Error Handling
- **Binary files**: Show "Cannot edit binary files" message
- **Load errors**: Show retry button
- **Save errors**: Show error in commit dialog
- **Large files**: Show warning, offer to download instead
- **Network errors**: Show offline indicator
## Performance
- **Lazy load Prism** - Load language grammars on demand
- **Debounce edits** - Don't re-highlight on every keystroke
- **Virtual scrolling** - For files >1000 lines
- **Memoization** - Cache highlighted output
+68
View File
@@ -0,0 +1,68 @@
# File Editor - Syntax Highlighting and Editing
## Problem
The current file viewer in the repository workspace only displays raw text without syntax highlighting or editing capabilities. Users cannot view code with proper formatting or make quick edits to files.
## Solution
Create a comprehensive file editor that provides:
1. **Syntax highlighting** for all text-based file types
2. **View/Edit mode toggle** - switch between read-only and edit mode
3. **Rich text editing** with syntax highlighting while editing
4. **Commit dialog** - save changes with custom commit message
5. **Line numbers** in both view and edit modes
6. **File type detection** from extension
## Key Features
### Syntax Highlighting
- Support for all common programming languages
- Automatic language detection from file extension
- Consistent color scheme matching the app theme
### Edit Mode
- Toggle between view (read-only) and edit mode
- Syntax highlighted editing using textarea overlay
- Line numbers visible during editing
- Keyboard shortcuts (Ctrl+S to save)
### Commit Flow
- Click "Edit" → make changes → click "Save"
- Commit dialog appears with message input
- Shows diff preview of changes
- Commit with author info (from user profile)
- Returns to view mode after successful commit
### File Support
- All text-based files (source code, config, markdown, etc.)
- Binary files show "cannot edit" message
- Large files (>1MB) show warning
## Benefits
- **Better code reading** - syntax highlighting makes code easier to understand
- **Quick fixes** - edit files without leaving the browser
- **Git integration** - changes are committed directly
- **Familiar interface** - similar to GitHub/GitLab file editor
## Scope
### New Components
- FileEditor (enhanced file viewer with edit capability)
- CommitDialog (commit message + diff preview)
- SyntaxHighlighter (Prism.js wrapper)
### Modified Components
- RepoWorkspace (integrate new editor)
- FileViewer (replaced by FileEditor)
### Backend Changes
- None (existing endpoints already support file update)
## Technology
- **react-simple-code-editor** - lightweight code editing with syntax highlighting
- **Prism.js** - syntax highlighting for 289+ languages
- **Existing API** - PUT /files/content endpoint already exists
+187
View File
@@ -0,0 +1,187 @@
# File Editor Specification
## Requirements
### Functional Requirements
1. **File Display**: Show file contents with syntax highlighting for all text files
2. **Language Detection**: Automatically detect language from file extension
3. **View Mode**: Read-only display with line numbers and copy button
4. **Edit Mode**: Rich text editing with syntax highlighting
5. **Commit Flow**: Save changes via commit dialog with custom message
6. **Diff Preview**: Show changes before committing
7. **File Support**: All text-based files (source code, config, markdown, etc.)
### Non-Functional Requirements
1. **Performance**: Load files < 500ms, highlighting < 100ms
2. **Responsiveness**: UI remains responsive during editing
3. **Accessibility**: Keyboard navigation, screen reader support
4. **Browser Support**: Modern browsers (Chrome, Firefox, Safari, Edge)
## API Specification
### GET /projects/{project_id}/repositories/{repo_id}/files/content
Get file content (existing endpoint).
**Query Parameters:**
- `branch` (required): Branch name
- `path` (required): File path
**Response 200:**
```json
{
"path": "src/main.py",
"branch": "main",
"content": "function hello() {\n return 'world';\n}",
"size": 42,
"encoding": "utf-8",
"language": "python",
"is_binary": false
}
```
### POST /projects/{project_id}/repositories/{repo_id}/files/content
Update file content (existing endpoint).
**Request Body:**
```json
{
"path": "src/main.py",
"branch": "main",
"content": "new content",
"commit_message": "Update greeting",
"author_name": "User Name",
"author_email": "user@example.com"
}
```
## Frontend Specification
### Components
#### FileEditor
Main component managing view/edit modes.
**Props:**
```typescript
interface FileEditorProps {
projectId: string;
repoId: string;
}
```
**State:**
```typescript
interface FileEditorState {
mode: 'view' | 'edit';
content: string;
originalContent: string;
language: string;
filePath: string | null;
branch: string;
loading: boolean;
error: string | null;
showCommitDialog: boolean;
isBinary: boolean;
}
```
#### SyntaxHighlighter
Read-only syntax highlighted display.
**Props:**
```typescript
interface SyntaxHighlighterProps {
code: string;
language: string;
showLineNumbers?: boolean;
}
```
#### CodeEditor
Editable code with syntax highlighting.
**Props:**
```typescript
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language: string;
readOnly?: boolean;
}
```
#### CommitDialog
Commit flow dialog.
**Props:**
```typescript
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
originalContent: string;
newContent: string;
onCommit: (message: string) => Promise<void>;
onCancel: () => void;
}
```
### Language Support
Supported languages (from Prism.js):
- JavaScript/TypeScript (js, ts, jsx, tsx)
- Python (py)
- HTML/XML (html, xml)
- CSS/SCSS (css, scss)
- JSON (json)
- Markdown (md)
- YAML (yaml, yml)
- Shell/Bash (sh, bash)
- Docker (dockerfile)
- SQL (sql)
- And 280+ more via Prism.js
### Keyboard Shortcuts
- `Ctrl/Cmd + E`: Toggle edit mode
- `Ctrl/Cmd + S`: Save (shows commit dialog)
- `Escape`: Cancel edit mode
- `Tab`: Insert 2 spaces
### URL State
Editor state synced to URL:
```
/projects/:projectId?repo=:repoId&branch=:branch&file=:path&edit=true
```
## Error Codes
| Error Code | Description | User Message |
|------------|-------------|--------------|
| BINARY_FILE | File is binary | "Binary files cannot be edited" |
| FILE_TOO_LARGE | File > 1MB | "File too large to edit" |
| LOAD_ERROR | Failed to load | "Failed to load file" |
| SAVE_ERROR | Failed to save | "Failed to save changes" |
| EMPTY_COMMIT | No changes | "No changes to commit" |
## Testing Strategy
### Unit Tests
- Language detection
- Syntax highlighting output
- Diff generation
- Commit validation
### Integration Tests
- File loading
- Mode switching
- Commit flow
- Error handling
### Manual Tests
- Various file types
- Large files
- Binary files
- Network failures
+102
View File
@@ -0,0 +1,102 @@
# File Editor - Tasks
## Phase 1: Dependencies and Setup
- [ ] **Task 1.1**: Install dependencies
- `react-simple-code-editor`
- `prismjs`
- `@types/prismjs`
- Add Prism CSS theme
- [ ] **Task 1.2**: Create language detection utility
- Map file extensions to Prism.js language names
- Handle common extensions (.js, .ts, .py, .md, etc.)
- Default to plaintext for unknown extensions
## Phase 2: Syntax Highlighting
- [ ] **Task 2.1**: Create SyntaxHighlighter component
- Use Prism.js to tokenize code
- Render highlighted HTML
- Add line numbers
- Copy-to-clipboard button
- [ ] **Task 2.2**: Add Prism.js themes
- Light theme (matches app)
- Dark theme support
- CSS custom properties integration
- [ ] **Task 2.3**: Lazy load language support
- Only load language grammar when needed
- Dynamic imports for language files
## Phase 3: Edit Mode
- [ ] **Task 3.1**: Create CodeEditor component
- Use react-simple-code-editor
- Prism.js highlighting overlay
- Line numbers
- Tab key support
- [ ] **Task 3.2**: Add keyboard shortcuts
- Ctrl/Cmd + E: Toggle edit
- Ctrl/Cmd + S: Save
- Escape: Cancel
- Tab: Insert spaces
## Phase 4: Commit Dialog
- [ ] **Task 4.1**: Create CommitDialog component
- Diff preview (simple comparison)
- Commit message input (required)
- Author info display
- Cancel and Commit buttons
- [ ] **Task 4.2**: Add diff generation
- Simple line-by-line diff
- Show added/removed lines
- Highlight changes
## Phase 5: FileEditor Integration
- [ ] **Task 5.1**: Create FileEditor component
- Manage view/edit state
- Load file content
- Toggle modes
- Handle save flow
- [ ] **Task 5.2**: Replace FileViewer in RepoWorkspace
- Update imports
- Pass required props
- Handle file selection
## Phase 6: Styling
- [ ] **Task 6.1**: Add editor CSS
- Toolbar styling
- Editor container
- Line numbers
- Syntax colors
- Commit dialog styles
- [ ] **Task 6.2**: Add responsive styles
- Mobile layout
- Touch-friendly buttons
- Collapsible toolbar
## Phase 7: Quality Gates
- [ ] **Task 7.1**: Run backend checks
- ruff
- mypy
- [ ] **Task 7.2**: Run frontend checks
- TypeScript typecheck
- ESLint
- Build
- [ ] **Task 7.3**: Manual testing
- Test various file types
- Test commit flow
- Test error handling
- Test responsive design