From 7261c75bb2c5554123453ee9e9343349cc85584a Mon Sep 17 00:00:00 2001 From: Fusion Date: Tue, 19 May 2026 16:04:48 +0200 Subject: [PATCH] feat: implement file editor with syntax highlighting and editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ✓ --- apps/web/package-lock.json | 28 ++ apps/web/package.json | 3 + apps/web/src/components/code-editor.tsx | 56 +++ apps/web/src/components/commit-dialog.tsx | 146 +++++++ apps/web/src/components/file-editor.tsx | 228 +++++++++++ .../web/src/components/syntax-highlighter.tsx | 65 ++++ apps/web/src/pages/repo-workspace.tsx | 92 +---- apps/web/src/styles.css | 365 ++++++++++++++++++ apps/web/src/utils/language.ts | 90 +++++ openspec/changes/file-editor/.openspec.yaml | 2 + openspec/changes/file-editor/design.md | 191 +++++++++ openspec/changes/file-editor/proposal.md | 68 ++++ openspec/changes/file-editor/specs/spec.md | 187 +++++++++ openspec/changes/file-editor/tasks.md | 102 +++++ 14 files changed, 1533 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/components/code-editor.tsx create mode 100644 apps/web/src/components/commit-dialog.tsx create mode 100644 apps/web/src/components/file-editor.tsx create mode 100644 apps/web/src/components/syntax-highlighter.tsx create mode 100644 apps/web/src/utils/language.ts create mode 100644 openspec/changes/file-editor/.openspec.yaml create mode 100644 openspec/changes/file-editor/design.md create mode 100644 openspec/changes/file-editor/proposal.md create mode 100644 openspec/changes/file-editor/specs/spec.md create mode 100644 openspec/changes/file-editor/tasks.md diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 22088b5..989ed60 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 7e1414c..2158dbb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/components/code-editor.tsx b/apps/web/src/components/code-editor.tsx new file mode 100644 index 0000000..5f56d03 --- /dev/null +++ b/apps/web/src/components/code-editor.tsx @@ -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 = ({ + 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) => + `
${ + i + 1 + }${highlightCode( + line || " ", + language + )}
` + ) + .join(""); + + return ( +
+ +
+ ); +}; diff --git a/apps/web/src/components/commit-dialog.tsx b/apps/web/src/components/commit-dialog.tsx new file mode 100644 index 0000000..0f3aefd --- /dev/null +++ b/apps/web/src/components/commit-dialog.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; + +interface CommitDialogProps { + isOpen: boolean; + filePath: string; + originalContent: string; + newContent: string; + onCommit: (message: string) => Promise; + onCancel: () => void; +} + +export const CommitDialog: React.FC = ({ + 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 ( +
+
+
+

Commit Changes

+ +
+ +
+

+ Editing: {filePath} +

+ + {!hasChanges && ( +
No changes to commit
+ )} + + {hasChanges && ( +
+

Changes

+
+ {diff.map((line, i) => ( +
+ {line.lineNum} + + {line.type === "added" && "+"} + {line.type === "removed" && "-"} + {line.type === "same" && " "} + + {line.line} +
+ ))} +
+
+ )} + +
+ +