From b63d827ccc5e798df0d9ba12548ca825c144e8c2 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Fri, 20 Feb 2026 08:55:28 +0300 Subject: [PATCH] refator(code-editor): make CodeEditor feature based component - replaced interfaces with types from main-content types --- src/components/CodeEditor.jsx | 875 ------------------ src/components/CodeEditor.tsx | 1 + .../code-editor/constants/settings.ts | 17 + .../hooks/useCodeEditorDocument.ts | 122 +++ .../hooks/useCodeEditorSettings.ts | 84 ++ .../hooks/useEditorKeyboardShortcuts.ts | 34 + .../hooks/useEditorSidebar.ts | 17 +- src/components/code-editor/types/types.ts | 21 + .../code-editor/utils/editorExtensions.ts | 141 +++ .../code-editor/utils/editorStyles.ts | 79 ++ .../code-editor/utils/editorToolbarPanel.ts | 189 ++++ .../code-editor/view/CodeEditor.tsx | 227 +++++ .../view}/EditorSidebar.tsx | 31 +- .../view/subcomponents/CodeEditorFooter.tsx | 28 + .../view/subcomponents/CodeEditorHeader.tsx | 143 +++ .../subcomponents/CodeEditorLoadingState.tsx | 36 + .../view/subcomponents/CodeEditorSurface.tsx | 62 ++ .../markdown/MarkdownCodeBlock.tsx | 73 ++ .../markdown/MarkdownPreview.tsx | 53 ++ src/components/main-content/types/types.ts | 62 +- .../main-content/view/MainContent.tsx | 24 +- 21 files changed, 1369 insertions(+), 950 deletions(-) delete mode 100644 src/components/CodeEditor.jsx create mode 100644 src/components/CodeEditor.tsx create mode 100644 src/components/code-editor/constants/settings.ts create mode 100644 src/components/code-editor/hooks/useCodeEditorDocument.ts create mode 100644 src/components/code-editor/hooks/useCodeEditorSettings.ts create mode 100644 src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts rename src/components/{main-content => code-editor}/hooks/useEditorSidebar.ts (86%) create mode 100644 src/components/code-editor/types/types.ts create mode 100644 src/components/code-editor/utils/editorExtensions.ts create mode 100644 src/components/code-editor/utils/editorStyles.ts create mode 100644 src/components/code-editor/utils/editorToolbarPanel.ts create mode 100644 src/components/code-editor/view/CodeEditor.tsx rename src/components/{main-content/view/subcomponents => code-editor/view}/EditorSidebar.tsx (63%) create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorFooter.tsx create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx create mode 100644 src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx create mode 100644 src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx create mode 100644 src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx diff --git a/src/components/CodeEditor.jsx b/src/components/CodeEditor.jsx deleted file mode 100644 index 20eff13e..00000000 --- a/src/components/CodeEditor.jsx +++ /dev/null @@ -1,875 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import CodeMirror from '@uiw/react-codemirror'; -import { javascript } from '@codemirror/lang-javascript'; -import { python } from '@codemirror/lang-python'; -import { html } from '@codemirror/lang-html'; -import { css } from '@codemirror/lang-css'; -import { json } from '@codemirror/lang-json'; -import { markdown } from '@codemirror/lang-markdown'; -import { oneDark } from '@codemirror/theme-one-dark'; -import { StreamLanguage } from '@codemirror/language'; -import { EditorView, showPanel, ViewPlugin } from '@codemirror/view'; -import { unifiedMergeView, getChunks } from '@codemirror/merge'; -import { showMinimap } from '@replit/codemirror-minimap'; -import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import rehypeRaw from 'rehype-raw'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { api } from '../utils/api'; -import { useTranslation } from 'react-i18next'; -import { Eye, Code2 } from 'lucide-react'; - -// Custom .env file syntax highlighting -const envLanguage = StreamLanguage.define({ - token(stream) { - // Comments - if (stream.match(/^#.*/)) return 'comment'; - // Key (before =) - if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition'; - // Equals sign - if (stream.match(/^=/)) return 'operator'; - // Double-quoted string - if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string'; - // Single-quoted string - if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string'; - // Variable interpolation ${...} - if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special'; - // Variable reference $VAR - if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special'; - // Numbers - if (stream.match(/^\d+/)) return 'number'; - // Skip other characters - stream.next(); - return null; - }, -}); - -function MarkdownCodeBlock({ inline, className, children, ...props }) { - const [copied, setCopied] = useState(false); - const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); - const looksMultiline = /[\r\n]/.test(raw); - const shouldInline = inline || !looksMultiline; - - if (shouldInline) { - return ( - - {children} - - ); - } - - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : 'text'; - - return ( -
- {language && language !== 'text' && ( -
{language}
- )} - - - {raw} - -
- ); -} - -const markdownPreviewComponents = { - code: MarkdownCodeBlock, - blockquote: ({ children }) => ( -
- {children} -
- ), - a: ({ href, children }) => ( - - {children} - - ), - table: ({ children }) => ( -
- {children}
-
- ), - thead: ({ children }) => {children}, - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - {children} - ), -}; - -function MarkdownPreview({ content }) { - const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); - const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []); - - return ( - - {content} - - ); -} - -function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) { - const { t } = useTranslation('codeEditor'); - const [content, setContent] = useState(''); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const [isDarkMode, setIsDarkMode] = useState(() => { - const savedTheme = localStorage.getItem('codeEditorTheme'); - return savedTheme ? savedTheme === 'dark' : true; - }); - const [saveSuccess, setSaveSuccess] = useState(false); - const [showDiff, setShowDiff] = useState(!!file.diffInfo); - const [wordWrap, setWordWrap] = useState(() => { - return localStorage.getItem('codeEditorWordWrap') === 'true'; - }); - const [minimapEnabled, setMinimapEnabled] = useState(() => { - return localStorage.getItem('codeEditorShowMinimap') !== 'false'; - }); - const [showLineNumbers, setShowLineNumbers] = useState(() => { - return localStorage.getItem('codeEditorLineNumbers') !== 'false'; - }); - const [fontSize, setFontSize] = useState(() => { - return localStorage.getItem('codeEditorFontSize') || '12'; - }); - const [markdownPreview, setMarkdownPreview] = useState(false); - const editorRef = useRef(null); - - // Check if file is markdown - const isMarkdownFile = useMemo(() => { - const ext = file.name.split('.').pop()?.toLowerCase(); - return ext === 'md' || ext === 'markdown'; - }, [file.name]); - - // Create minimap extension with chunk-based gutters - const minimapExtension = useMemo(() => { - if (!file.diffInfo || !showDiff || !minimapEnabled) return []; - - const gutters = {}; - - return [ - showMinimap.compute(['doc'], (state) => { - // Get actual chunks from merge view - const chunksData = getChunks(state); - const chunks = chunksData?.chunks || []; - - // Clear previous gutters - Object.keys(gutters).forEach(key => delete gutters[key]); - - // Mark lines that are part of chunks - chunks.forEach(chunk => { - // Mark the lines in the B side (current document) - const fromLine = state.doc.lineAt(chunk.fromB).number; - const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number; - - for (let lineNum = fromLine; lineNum <= toLine; lineNum++) { - gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)'; - } - }); - - return { - create: () => ({ dom: document.createElement('div') }), - displayText: 'blocks', - showOverlay: 'always', - gutters: [gutters] - }; - }) - ]; - }, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]); - - // Create extension to scroll to first chunk on mount - const scrollToFirstChunkExtension = useMemo(() => { - if (!file.diffInfo || !showDiff) return []; - - return [ - ViewPlugin.fromClass(class { - constructor(view) { - // Delay to ensure merge view is fully initialized - setTimeout(() => { - const chunksData = getChunks(view.state); - const chunks = chunksData?.chunks || []; - - if (chunks.length > 0) { - const firstChunk = chunks[0]; - - // Scroll to the first chunk - view.dispatch({ - effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }) - }); - } - }, 100); - } - - update() {} - destroy() {} - }) - ]; - }, [file.diffInfo, showDiff]); - - // Whether toolbar has any buttons worth showing - const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand)); - - // Create editor toolbar panel - only when there are buttons to show - const editorToolbarPanel = useMemo(() => { - if (!hasToolbarButtons) return []; - - const createPanel = (view) => { - const dom = document.createElement('div'); - dom.className = 'cm-editor-toolbar-panel'; - - let currentIndex = 0; - - const updatePanel = () => { - // Check if we have diff info and it's enabled - const hasDiff = file.diffInfo && showDiff; - const chunksData = hasDiff ? getChunks(view.state) : null; - const chunks = chunksData?.chunks || []; - const chunkCount = chunks.length; - - // Build the toolbar HTML - let toolbarHTML = '
'; - - // Left side - diff navigation (if applicable) - toolbarHTML += '
'; - if (hasDiff) { - toolbarHTML += ` - ${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')} - - - `; - } - toolbarHTML += '
'; - - // Right side - action buttons - toolbarHTML += '
'; - - // Show/hide diff button (only if there's diff info) - if (file.diffInfo) { - toolbarHTML += ` - - `; - } - - // Pop out button (only in sidebar mode with onPopOut) - if (isSidebar && onPopOut) { - toolbarHTML += ` - - `; - } - - // Expand button (only in sidebar mode) - if (isSidebar && onToggleExpand) { - toolbarHTML += ` - - `; - } - - toolbarHTML += '
'; - toolbarHTML += '
'; - - dom.innerHTML = toolbarHTML; - - if (hasDiff) { - const prevBtn = dom.querySelector('.cm-diff-nav-prev'); - const nextBtn = dom.querySelector('.cm-diff-nav-next'); - - prevBtn?.addEventListener('click', () => { - if (chunks.length === 0) return; - currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1; - - const chunk = chunks[currentIndex]; - if (chunk) { - view.dispatch({ - effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }) - }); - } - updatePanel(); - }); - - nextBtn?.addEventListener('click', () => { - if (chunks.length === 0) return; - currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0; - - const chunk = chunks[currentIndex]; - if (chunk) { - view.dispatch({ - effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }) - }); - } - updatePanel(); - }); - } - - if (file.diffInfo) { - const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn'); - toggleDiffBtn?.addEventListener('click', () => { - setShowDiff(!showDiff); - }); - } - - if (isSidebar && onPopOut) { - const popoutBtn = dom.querySelector('.cm-popout-btn'); - popoutBtn?.addEventListener('click', () => { - onPopOut(); - }); - } - - if (isSidebar && onToggleExpand) { - const expandBtn = dom.querySelector('.cm-expand-btn'); - expandBtn?.addEventListener('click', () => { - onToggleExpand(); - }); - } - }; - - updatePanel(); - - return { - top: true, - dom, - update: updatePanel - }; - }; - - return [showPanel.of(createPanel)]; - }, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]); - - // Get language extension based on file extension - const getLanguageExtension = (filename) => { - const lowerName = filename.toLowerCase(); - // Handle dotfiles like .env, .env.local, .env.production, etc. - if (lowerName === '.env' || lowerName.startsWith('.env.')) { - return [envLanguage]; - } - const ext = filename.split('.').pop()?.toLowerCase(); - switch (ext) { - case 'js': - case 'jsx': - case 'ts': - case 'tsx': - return [javascript({ jsx: true, typescript: ext.includes('ts') })]; - case 'py': - return [python()]; - case 'html': - case 'htm': - return [html()]; - case 'css': - case 'scss': - case 'less': - return [css()]; - case 'json': - return [json()]; - case 'md': - case 'markdown': - return [markdown()]; - case 'env': - return [envLanguage]; - default: - return []; - } - }; - - // Load file content - useEffect(() => { - const loadFileContent = async () => { - try { - setLoading(true); - - // If we have diffInfo with both old and new content, we can show the diff directly - // This handles both GitPanel (full content) and ChatInterface (full content from API) - if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) { - // Use the new_string as the content to display - // The unifiedMergeView will compare it against old_string - setContent(file.diffInfo.new_string); - setLoading(false); - return; - } - - // Otherwise, load from disk - const response = await api.readFile(file.projectName, file.path); - - if (!response.ok) { - throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - setContent(data.content); - } catch (error) { - console.error('Error loading file:', error); - setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`); - } finally { - setLoading(false); - } - }; - - loadFileContent(); - }, [file, projectPath]); - - const handleSave = async () => { - setSaving(true); - try { - console.log('Saving file:', { - projectName: file.projectName, - path: file.path, - contentLength: content?.length - }); - - const response = await api.saveFile(file.projectName, file.path, content); - - console.log('Save response:', { - status: response.status, - ok: response.ok, - contentType: response.headers.get('content-type') - }); - - if (!response.ok) { - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const errorData = await response.json(); - throw new Error(errorData.error || `Save failed: ${response.status}`); - } else { - const textError = await response.text(); - console.error('Non-JSON error response:', textError); - throw new Error(`Save failed: ${response.status} ${response.statusText}`); - } - } - - const result = await response.json(); - console.log('Save successful:', result); - - setSaveSuccess(true); - setTimeout(() => setSaveSuccess(false), 2000); - - } catch (error) { - console.error('Error saving file:', error); - alert(`Error saving file: ${error.message}`); - } finally { - setSaving(false); - } - }; - - const handleDownload = () => { - const blob = new Blob([content], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = file.name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const toggleFullscreen = () => { - setIsFullscreen(!isFullscreen); - }; - - // Save theme preference to localStorage - useEffect(() => { - localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light'); - }, [isDarkMode]); - - // Save word wrap preference to localStorage - useEffect(() => { - localStorage.setItem('codeEditorWordWrap', wordWrap.toString()); - }, [wordWrap]); - - // Listen for settings changes from the Settings modal - useEffect(() => { - const handleStorageChange = () => { - const newTheme = localStorage.getItem('codeEditorTheme'); - if (newTheme) { - setIsDarkMode(newTheme === 'dark'); - } - - const newWordWrap = localStorage.getItem('codeEditorWordWrap'); - if (newWordWrap !== null) { - setWordWrap(newWordWrap === 'true'); - } - - const newShowMinimap = localStorage.getItem('codeEditorShowMinimap'); - if (newShowMinimap !== null) { - setMinimapEnabled(newShowMinimap !== 'false'); - } - - const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers'); - if (newShowLineNumbers !== null) { - setShowLineNumbers(newShowLineNumbers !== 'false'); - } - - const newFontSize = localStorage.getItem('codeEditorFontSize'); - if (newFontSize) { - setFontSize(newFontSize); - } - }; - - // Listen for storage events (changes from other tabs/windows) - window.addEventListener('storage', handleStorageChange); - - // Custom event for same-window updates - window.addEventListener('codeEditorSettingsChanged', handleStorageChange); - - return () => { - window.removeEventListener('storage', handleStorageChange); - window.removeEventListener('codeEditorSettingsChanged', handleStorageChange); - }; - }, []); - - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e) => { - if (e.ctrlKey || e.metaKey) { - if (e.key === 's') { - e.preventDefault(); - handleSave(); - } else if (e.key === 'Escape') { - e.preventDefault(); - onClose(); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [content]); - - if (loading) { - return ( - <> - - {isSidebar ? ( -
-
-
- {t('loading', { fileName: file.name })} -
-
- ) : ( -
-
-
-
- {t('loading', { fileName: file.name })} -
-
-
- )} - - ); - } - - return ( - <> - -
-
- {/* Header */} -
-
-
-
-

{file.name}

- {file.diffInfo && ( - - {t('header.showingChanges')} - - )} -
-

{file.path}

-
-
- -
- {isMarkdownFile && ( - - )} - - - - - - - - {!isSidebar && ( - - )} - - -
-
- - {/* Editor / Markdown Preview */} -
- {markdownPreview && isMarkdownFile ? ( -
-
- -
-
- ) : ( - - )} -
- - {/* Footer */} -
-
- {t('footer.lines')} {content.split('\n').length} - {t('footer.characters')} {content.length} -
- -
- {t('footer.shortcuts')} -
-
-
-
- - ); -} - -export default CodeEditor; diff --git a/src/components/CodeEditor.tsx b/src/components/CodeEditor.tsx new file mode 100644 index 00000000..ba716c17 --- /dev/null +++ b/src/components/CodeEditor.tsx @@ -0,0 +1 @@ +export { default } from './code-editor/view/CodeEditor'; diff --git a/src/components/code-editor/constants/settings.ts b/src/components/code-editor/constants/settings.ts new file mode 100644 index 00000000..fe3d5d24 --- /dev/null +++ b/src/components/code-editor/constants/settings.ts @@ -0,0 +1,17 @@ +export const CODE_EDITOR_STORAGE_KEYS = { + theme: 'codeEditorTheme', + wordWrap: 'codeEditorWordWrap', + showMinimap: 'codeEditorShowMinimap', + lineNumbers: 'codeEditorLineNumbers', + fontSize: 'codeEditorFontSize', +} as const; + +export const CODE_EDITOR_DEFAULTS = { + isDarkMode: true, + wordWrap: false, + minimapEnabled: true, + showLineNumbers: true, + fontSize: '12', +} as const; + +export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged'; diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts new file mode 100644 index 00000000..f707f437 --- /dev/null +++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useState } from 'react'; +import { api } from '../../../utils/api'; +import type { CodeEditorFile } from '../types/types'; + +type UseCodeEditorDocumentParams = { + file: CodeEditorFile; + projectPath?: string; +}; + +const getErrorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message; + } + + return String(error); +}; + +export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => { + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + useEffect(() => { + const loadFileContent = async () => { + try { + setLoading(true); + + // Diff payload may already include full old/new snapshots, so avoid disk read. + if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) { + setContent(file.diffInfo.new_string); + setLoading(false); + return; + } + + const response = await api.readFile(file.projectName, file.path); + if (!response.ok) { + throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + setContent(data.content); + } catch (error) { + const message = getErrorMessage(error); + console.error('Error loading file:', error); + setContent(`// Error loading file: ${message}\n// File: ${file.name}\n// Path: ${file.path}`); + } finally { + setLoading(false); + } + }; + + loadFileContent(); + }, [file, projectPath]); + + const handleSave = useCallback(async () => { + setSaving(true); + + try { + console.log('Saving file:', { + projectName: file.projectName, + path: file.path, + contentLength: content?.length, + }); + + const response = await api.saveFile(file.projectName, file.path, content); + console.log('Save response:', { + status: response.status, + ok: response.ok, + contentType: response.headers.get('content-type'), + }); + + if (!response.ok) { + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + const errorData = await response.json(); + throw new Error(errorData.error || `Save failed: ${response.status}`); + } + + const textError = await response.text(); + console.error('Non-JSON error response:', textError); + throw new Error(`Save failed: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + console.log('Save successful:', result); + + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } catch (error) { + const message = getErrorMessage(error); + console.error('Error saving file:', error); + alert(`Error saving file: ${message}`); + } finally { + setSaving(false); + } + }, [content, file.path, file.projectName]); + + const handleDownload = useCallback(() => { + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = file.name; + + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + URL.revokeObjectURL(url); + }, [content, file.name]); + + return { + content, + setContent, + loading, + saving, + saveSuccess, + handleSave, + handleDownload, + }; +}; diff --git a/src/components/code-editor/hooks/useCodeEditorSettings.ts b/src/components/code-editor/hooks/useCodeEditorSettings.ts new file mode 100644 index 00000000..5c9e151f --- /dev/null +++ b/src/components/code-editor/hooks/useCodeEditorSettings.ts @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; +import { + CODE_EDITOR_DEFAULTS, + CODE_EDITOR_SETTINGS_CHANGED_EVENT, + CODE_EDITOR_STORAGE_KEYS, +} from '../constants/settings'; + +const readTheme = () => { + const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme); + if (!savedTheme) { + return CODE_EDITOR_DEFAULTS.isDarkMode; + } + + return savedTheme === 'dark'; +}; + +const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => { + const value = localStorage.getItem(storageKey); + if (value === null) { + return defaultValue; + } + + return value !== falseValue; +}; + +const readWordWrap = () => { + return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true'; +}; + +const readFontSize = () => { + return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize) ?? CODE_EDITOR_DEFAULTS.fontSize; +}; + +export const useCodeEditorSettings = () => { + const [isDarkMode, setIsDarkMode] = useState(readTheme); + const [wordWrap, setWordWrap] = useState(readWordWrap); + const [minimapEnabled, setMinimapEnabled] = useState(() => ( + readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled) + )); + const [showLineNumbers, setShowLineNumbers] = useState(() => ( + readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers) + )); + const [fontSize, setFontSize] = useState(readFontSize); + + // Keep legacy behavior where the editor writes theme and wrap settings directly. + useEffect(() => { + localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light'); + }, [isDarkMode]); + + useEffect(() => { + localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap)); + }, [wordWrap]); + + useEffect(() => { + const refreshFromStorage = () => { + setIsDarkMode(readTheme()); + setWordWrap(readWordWrap()); + setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)); + setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)); + setFontSize(readFontSize()); + }; + + window.addEventListener('storage', refreshFromStorage); + window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage); + + return () => { + window.removeEventListener('storage', refreshFromStorage); + window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage); + }; + }, []); + + return { + isDarkMode, + setIsDarkMode, + wordWrap, + setWordWrap, + minimapEnabled, + setMinimapEnabled, + showLineNumbers, + setShowLineNumbers, + fontSize, + setFontSize, + }; +}; diff --git a/src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts b/src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts new file mode 100644 index 00000000..1cc99f21 --- /dev/null +++ b/src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; + +type UseEditorKeyboardShortcutsParams = { + onSave: () => void; + onClose: () => void; + dependency: string; +}; + +export const useEditorKeyboardShortcuts = ({ + onSave, + onClose, + dependency, +}: UseEditorKeyboardShortcutsParams) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey)) { + return; + } + + if (event.key === 's') { + event.preventDefault(); + onSave(); + } else if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [dependency, onClose, onSave]); +}; diff --git a/src/components/main-content/hooks/useEditorSidebar.ts b/src/components/code-editor/hooks/useEditorSidebar.ts similarity index 86% rename from src/components/main-content/hooks/useEditorSidebar.ts rename to src/components/code-editor/hooks/useEditorSidebar.ts index 71688676..bd70edf0 100644 --- a/src/components/main-content/hooks/useEditorSidebar.ts +++ b/src/components/code-editor/hooks/useEditorSidebar.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { MouseEvent as ReactMouseEvent } from 'react'; import type { Project } from '../../../types/app'; -import type { DiffInfo, EditingFile } from '../types/types'; +import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types'; type UseEditorSidebarOptions = { selectedProject: Project | null; @@ -9,12 +9,12 @@ type UseEditorSidebarOptions = { initialWidth?: number; }; -export function useEditorSidebar({ +export const useEditorSidebar = ({ selectedProject, isMobile, initialWidth = 600, -}: UseEditorSidebarOptions) { - const [editingFile, setEditingFile] = useState(null); +}: UseEditorSidebarOptions) => { + const [editingFile, setEditingFile] = useState(null); const [editorWidth, setEditorWidth] = useState(initialWidth); const [editorExpanded, setEditorExpanded] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -22,7 +22,7 @@ export function useEditorSidebar({ const resizeHandleRef = useRef(null); const handleFileOpen = useCallback( - (filePath: string, diffInfo: DiffInfo | null = null) => { + (filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => { const normalizedPath = filePath.replace(/\\/g, '/'); const fileName = normalizedPath.split('/').pop() || filePath; @@ -42,7 +42,7 @@ export function useEditorSidebar({ }, []); const handleToggleEditorExpand = useCallback(() => { - setEditorExpanded((prev) => !prev); + setEditorExpanded((previous) => !previous); }, []); const handleResizeStart = useCallback( @@ -51,8 +51,7 @@ export function useEditorSidebar({ return; } - // Once the user starts dragging, width should be controlled by drag state - // instead of "fill available space" layout mode. + // After first drag interaction, the editor width is user-controlled. setHasManualWidth(true); setIsResizing(true); event.preventDefault(); @@ -112,4 +111,4 @@ export function useEditorSidebar({ handleToggleEditorExpand, handleResizeStart, }; -} +}; diff --git a/src/components/code-editor/types/types.ts b/src/components/code-editor/types/types.ts new file mode 100644 index 00000000..8427a5e0 --- /dev/null +++ b/src/components/code-editor/types/types.ts @@ -0,0 +1,21 @@ +export type CodeEditorDiffInfo = { + old_string?: string; + new_string?: string; + [key: string]: unknown; +}; + +export type CodeEditorFile = { + name: string; + path: string; + projectName?: string; + diffInfo?: CodeEditorDiffInfo | null; + [key: string]: unknown; +}; + +export type CodeEditorSettingsState = { + isDarkMode: boolean; + wordWrap: boolean; + minimapEnabled: boolean; + showLineNumbers: boolean; + fontSize: string; +}; diff --git a/src/components/code-editor/utils/editorExtensions.ts b/src/components/code-editor/utils/editorExtensions.ts new file mode 100644 index 00000000..b98fd739 --- /dev/null +++ b/src/components/code-editor/utils/editorExtensions.ts @@ -0,0 +1,141 @@ +import { css } from '@codemirror/lang-css'; +import { html } from '@codemirror/lang-html'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { StreamLanguage } from '@codemirror/language'; +import { markdown } from '@codemirror/lang-markdown'; +import { python } from '@codemirror/lang-python'; +import { getChunks } from '@codemirror/merge'; +import { EditorView, ViewPlugin } from '@codemirror/view'; +import { showMinimap } from '@replit/codemirror-minimap'; +import type { CodeEditorFile } from '../types/types'; + +// Lightweight lexer for `.env` files (including `.env.*` variants). +const envLanguage = StreamLanguage.define({ + token(stream) { + if (stream.match(/^#.*/)) return 'comment'; + if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition'; + if (stream.match(/^=/)) return 'operator'; + if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string'; + if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string'; + if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special'; + if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special'; + if (stream.match(/^\d+/)) return 'number'; + + stream.next(); + return null; + }, +}); + +export const getLanguageExtensions = (filename: string) => { + const lowerName = filename.toLowerCase(); + if (lowerName === '.env' || lowerName.startsWith('.env.')) { + return [envLanguage]; + } + + const ext = filename.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'js': + case 'jsx': + case 'ts': + case 'tsx': + return [javascript({ jsx: true, typescript: ext.includes('ts') })]; + case 'py': + return [python()]; + case 'html': + case 'htm': + return [html()]; + case 'css': + case 'scss': + case 'less': + return [css()]; + case 'json': + return [json()]; + case 'md': + case 'markdown': + return [markdown()]; + case 'env': + return [envLanguage]; + default: + return []; + } +}; + +export const createMinimapExtension = ({ + file, + showDiff, + minimapEnabled, + isDarkMode, +}: { + file: CodeEditorFile; + showDiff: boolean; + minimapEnabled: boolean; + isDarkMode: boolean; +}) => { + if (!file.diffInfo || !showDiff || !minimapEnabled) { + return []; + } + + const gutters: Record = {}; + + return [ + showMinimap.compute(['doc'], (state) => { + const chunksData = getChunks(state); + const chunks = chunksData?.chunks || []; + + Object.keys(gutters).forEach((key) => { + delete gutters[Number(key)]; + }); + + chunks.forEach((chunk) => { + const fromLine = state.doc.lineAt(chunk.fromB).number; + const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number; + + for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) { + gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)'; + } + }); + + return { + create: () => ({ dom: document.createElement('div') }), + displayText: 'blocks', + showOverlay: 'always', + gutters: [gutters], + }; + }), + ]; +}; + +export const createScrollToFirstChunkExtension = ({ + file, + showDiff, +}: { + file: CodeEditorFile; + showDiff: boolean; +}) => { + if (!file.diffInfo || !showDiff) { + return []; + } + + return [ + ViewPlugin.fromClass(class { + constructor(view: EditorView) { + // Wait for merge decorations so the first chunk location is stable. + setTimeout(() => { + const chunksData = getChunks(view.state); + const firstChunk = chunksData?.chunks?.[0]; + + if (firstChunk) { + view.dispatch({ + effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }), + }); + } + }, 100); + } + + update() {} + + destroy() {} + }), + ]; +}; diff --git a/src/components/code-editor/utils/editorStyles.ts b/src/components/code-editor/utils/editorStyles.ts new file mode 100644 index 00000000..b3a6c619 --- /dev/null +++ b/src/components/code-editor/utils/editorStyles.ts @@ -0,0 +1,79 @@ +export const getEditorLoadingStyles = (isDarkMode: boolean) => { + return ` + .code-editor-loading { + background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important; + } + + .code-editor-loading:hover { + background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important; + } + `; +}; + +export const getEditorStyles = (isDarkMode: boolean) => { + return ` + .cm-deletedChunk { + background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important; + border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important; + padding-left: 4px !important; + } + + .cm-insertedChunk { + background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important; + border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important; + padding-left: 4px !important; + } + + .cm-editor.cm-merge-b .cm-changedText { + background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important; + padding-top: 2px !important; + padding-bottom: 2px !important; + margin-top: -2px !important; + margin-bottom: -2px !important; + } + + .cm-editor .cm-deletedChunk .cm-changedText { + background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important; + padding-top: 2px !important; + padding-bottom: 2px !important; + margin-top: -2px !important; + margin-bottom: -2px !important; + } + + .cm-gutter.cm-gutter-minimap { + background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'}; + } + + .cm-editor-toolbar-panel { + padding: 4px 10px; + background-color: ${isDarkMode ? '#1f2937' : '#ffffff'}; + border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'}; + color: ${isDarkMode ? '#d1d5db' : '#374151'}; + font-size: 12px; + } + + .cm-diff-nav-btn, + .cm-toolbar-btn { + padding: 3px; + background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + transition: background-color 0.2s; + } + + .cm-diff-nav-btn:hover, + .cm-toolbar-btn:hover { + background-color: ${isDarkMode ? '#374151' : '#f3f4f6'}; + } + + .cm-diff-nav-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `; +}; diff --git a/src/components/code-editor/utils/editorToolbarPanel.ts b/src/components/code-editor/utils/editorToolbarPanel.ts new file mode 100644 index 00000000..b584592f --- /dev/null +++ b/src/components/code-editor/utils/editorToolbarPanel.ts @@ -0,0 +1,189 @@ +import { getChunks } from '@codemirror/merge'; +import { EditorView, showPanel } from '@codemirror/view'; +import type { CodeEditorFile } from '../types/types'; + +type EditorToolbarLabels = { + changes: string; + previousChange: string; + nextChange: string; + hideDiff: string; + showDiff: string; + collapse: string; + expand: string; +}; + +type CreateEditorToolbarPanelParams = { + file: CodeEditorFile; + showDiff: boolean; + isSidebar: boolean; + isExpanded: boolean; + onToggleDiff: () => void; + onPopOut: (() => void) | null; + onToggleExpand: (() => void) | null; + labels: EditorToolbarLabels; +}; + +const getDiffVisibilityIcon = (showDiff: boolean) => { + if (showDiff) { + return ''; + } + + return ''; +}; + +const getExpandIcon = (isExpanded: boolean) => { + if (isExpanded) { + return ''; + } + + return ''; +}; + +export const createEditorToolbarPanelExtension = ({ + file, + showDiff, + isSidebar, + isExpanded, + onToggleDiff, + onPopOut, + onToggleExpand, + labels, +}: CreateEditorToolbarPanelParams) => { + const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand)); + if (!hasToolbarButtons) { + return []; + } + + const createPanel = (view: EditorView) => { + const dom = document.createElement('div'); + dom.className = 'cm-editor-toolbar-panel'; + + let currentIndex = 0; + + const updatePanel = () => { + const hasDiff = Boolean(file.diffInfo && showDiff); + const chunksData = hasDiff ? getChunks(view.state) : null; + const chunks = chunksData?.chunks || []; + const chunkCount = chunks.length; + + let toolbarHtml = '
'; + toolbarHtml += '
'; + + if (hasDiff) { + toolbarHtml += ` + ${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${labels.changes} + + + `; + } + + toolbarHtml += '
'; + toolbarHtml += '
'; + + if (file.diffInfo) { + toolbarHtml += ` + + `; + } + + if (isSidebar && onPopOut) { + toolbarHtml += ` + + `; + } + + if (isSidebar && onToggleExpand) { + toolbarHtml += ` + + `; + } + + toolbarHtml += '
'; + toolbarHtml += '
'; + + dom.innerHTML = toolbarHtml; + + if (hasDiff) { + const previousButton = dom.querySelector('.cm-diff-nav-prev'); + const nextButton = dom.querySelector('.cm-diff-nav-next'); + + previousButton?.addEventListener('click', () => { + if (chunks.length === 0) { + return; + } + + currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1; + const chunk = chunks[currentIndex]; + + if (chunk) { + view.dispatch({ + effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }), + }); + } + + updatePanel(); + }); + + nextButton?.addEventListener('click', () => { + if (chunks.length === 0) { + return; + } + + currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0; + const chunk = chunks[currentIndex]; + + if (chunk) { + view.dispatch({ + effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }), + }); + } + + updatePanel(); + }); + } + + const toggleDiffButton = dom.querySelector('.cm-toggle-diff-btn'); + toggleDiffButton?.addEventListener('click', onToggleDiff); + + const popOutButton = dom.querySelector('.cm-popout-btn'); + popOutButton?.addEventListener('click', () => { + onPopOut?.(); + }); + + const expandButton = dom.querySelector('.cm-expand-btn'); + expandButton?.addEventListener('click', () => { + onToggleExpand?.(); + }); + }; + + updatePanel(); + + return { + top: true, + dom, + update: updatePanel, + }; + }; + + return [showPanel.of(createPanel)]; +}; diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx new file mode 100644 index 00000000..28243f0f --- /dev/null +++ b/src/components/code-editor/view/CodeEditor.tsx @@ -0,0 +1,227 @@ +import { EditorView } from '@codemirror/view'; +import { unifiedMergeView } from '@codemirror/merge'; +import type { Extension } from '@codemirror/state'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument'; +import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings'; +import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts'; +import type { CodeEditorFile } from '../types/types'; +import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions'; +import { getEditorStyles } from '../utils/editorStyles'; +import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel'; +import CodeEditorFooter from './subcomponents/CodeEditorFooter'; +import CodeEditorHeader from './subcomponents/CodeEditorHeader'; +import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState'; +import CodeEditorSurface from './subcomponents/CodeEditorSurface'; + +type CodeEditorProps = { + file: CodeEditorFile; + onClose: () => void; + projectPath?: string; + isSidebar?: boolean; + isExpanded?: boolean; + onToggleExpand?: (() => void) | null; + onPopOut?: (() => void) | null; +}; + +export default function CodeEditor({ + file, + onClose, + projectPath, + isSidebar = false, + isExpanded = false, + onToggleExpand = null, + onPopOut = null, +}: CodeEditorProps) { + const { t } = useTranslation('codeEditor'); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo)); + const [markdownPreview, setMarkdownPreview] = useState(false); + + const { + isDarkMode, + wordWrap, + minimapEnabled, + showLineNumbers, + fontSize, + } = useCodeEditorSettings(); + + const { + content, + setContent, + loading, + saving, + saveSuccess, + handleSave, + handleDownload, + } = useCodeEditorDocument({ + file, + projectPath, + }); + + const isMarkdownFile = useMemo(() => { + const extension = file.name.split('.').pop()?.toLowerCase(); + return extension === 'md' || extension === 'markdown'; + }, [file.name]); + + const minimapExtension = useMemo( + () => ( + createMinimapExtension({ + file, + showDiff, + minimapEnabled, + isDarkMode, + }) + ), + [file, isDarkMode, minimapEnabled, showDiff], + ); + + const scrollToFirstChunkExtension = useMemo( + () => createScrollToFirstChunkExtension({ file, showDiff }), + [file, showDiff], + ); + + const toolbarPanelExtension = useMemo( + () => ( + createEditorToolbarPanelExtension({ + file, + showDiff, + isSidebar, + isExpanded, + onToggleDiff: () => setShowDiff((previous) => !previous), + onPopOut, + onToggleExpand, + labels: { + changes: t('toolbar.changes'), + previousChange: t('toolbar.previousChange'), + nextChange: t('toolbar.nextChange'), + hideDiff: t('toolbar.hideDiff'), + showDiff: t('toolbar.showDiff'), + collapse: t('toolbar.collapse'), + expand: t('toolbar.expand'), + }, + }) + ), + [file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t], + ); + + const extensions = useMemo(() => { + const allExtensions: Extension[] = [ + ...getLanguageExtensions(file.name), + ...toolbarPanelExtension, + ]; + + if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) { + allExtensions.push( + unifiedMergeView({ + original: file.diffInfo.old_string, + mergeControls: false, + highlightChanges: true, + syntaxHighlightDeletions: false, + gutter: true, + }), + ); + allExtensions.push(...minimapExtension); + allExtensions.push(...scrollToFirstChunkExtension); + } + + if (wordWrap) { + allExtensions.push(EditorView.lineWrapping); + } + + return allExtensions; + }, [ + file.diffInfo, + file.name, + minimapExtension, + scrollToFirstChunkExtension, + showDiff, + toolbarPanelExtension, + wordWrap, + ]); + + useEditorKeyboardShortcuts({ + onSave: handleSave, + onClose, + dependency: content, + }); + + if (loading) { + return ( + + ); + } + + const outerContainerClassName = isSidebar + ? 'w-full h-full flex flex-col' + : `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`; + + const innerContainerClassName = isSidebar + ? 'bg-background flex flex-col w-full h-full' + : `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${ + isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]' + }`; + + return ( + <> + +
+
+ setMarkdownPreview((previous) => !previous)} + onOpenSettings={() => window.openSettings?.('appearance')} + onDownload={handleDownload} + onSave={handleSave} + onToggleFullscreen={() => setIsFullscreen((previous) => !previous)} + onClose={onClose} + labels={{ + showingChanges: t('header.showingChanges'), + editMarkdown: t('actions.editMarkdown'), + previewMarkdown: t('actions.previewMarkdown'), + settings: t('toolbar.settings'), + download: t('actions.download'), + save: t('actions.save'), + saving: t('actions.saving'), + saved: t('actions.saved'), + fullscreen: t('actions.fullscreen'), + exitFullscreen: t('actions.exitFullscreen'), + close: t('actions.close'), + }} + /> + +
+ +
+ + +
+
+ + ); +} diff --git a/src/components/main-content/view/subcomponents/EditorSidebar.tsx b/src/components/code-editor/view/EditorSidebar.tsx similarity index 63% rename from src/components/main-content/view/subcomponents/EditorSidebar.tsx rename to src/components/code-editor/view/EditorSidebar.tsx index 814a30fe..d08e2b08 100644 --- a/src/components/main-content/view/subcomponents/EditorSidebar.tsx +++ b/src/components/code-editor/view/EditorSidebar.tsx @@ -1,8 +1,21 @@ import { useState } from 'react'; -import CodeEditor from '../../../CodeEditor'; -import type { EditorSidebarProps } from '../../types/types'; +import type { MouseEvent, MutableRefObject } from 'react'; +import type { CodeEditorFile } from '../types/types'; +import CodeEditor from './CodeEditor'; -const AnyCodeEditor = CodeEditor as any; +type EditorSidebarProps = { + editingFile: CodeEditorFile | null; + isMobile: boolean; + editorExpanded: boolean; + editorWidth: number; + hasManualWidth: boolean; + resizeHandleRef: MutableRefObject; + onResizeStart: (event: MouseEvent) => void; + onCloseEditor: () => void; + onToggleEditorExpand: () => void; + projectPath?: string; + fillSpace?: boolean; +}; export default function EditorSidebar({ editingFile, @@ -25,7 +38,7 @@ export default function EditorSidebar({ if (isMobile || poppedOut) { return ( - { setPoppedOut(false); @@ -37,8 +50,8 @@ export default function EditorSidebar({ ); } - // Keep "fill space" as default in files tab, but allow user drag to take control. - const useFlex = editorExpanded || (fillSpace && !hasManualWidth); + // In files tab, fill the remaining width unless user has dragged manually. + const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth); return ( <> @@ -54,10 +67,10 @@ export default function EditorSidebar({ )}
- +
+ + {linesLabel} {content.split('\n').length} + + + {charactersLabel} {content.length} + +
+ +
{shortcutsLabel}
+
+ ); +} diff --git a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx new file mode 100644 index 00000000..41c7d211 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx @@ -0,0 +1,143 @@ +import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react'; +import type { CodeEditorFile } from '../../types/types'; + +type CodeEditorHeaderProps = { + file: CodeEditorFile; + isSidebar: boolean; + isFullscreen: boolean; + isMarkdownFile: boolean; + markdownPreview: boolean; + saving: boolean; + saveSuccess: boolean; + onToggleMarkdownPreview: () => void; + onOpenSettings: () => void; + onDownload: () => void; + onSave: () => void; + onToggleFullscreen: () => void; + onClose: () => void; + labels: { + showingChanges: string; + editMarkdown: string; + previewMarkdown: string; + settings: string; + download: string; + save: string; + saving: string; + saved: string; + fullscreen: string; + exitFullscreen: string; + close: string; + }; +}; + +export default function CodeEditorHeader({ + file, + isSidebar, + isFullscreen, + isMarkdownFile, + markdownPreview, + saving, + saveSuccess, + onToggleMarkdownPreview, + onOpenSettings, + onDownload, + onSave, + onToggleFullscreen, + onClose, + labels, +}: CodeEditorHeaderProps) { + const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save; + + return ( +
+
+
+
+

{file.name}

+ {file.diffInfo && ( + + {labels.showingChanges} + + )} +
+

{file.path}

+
+
+ +
+ {isMarkdownFile && ( + + )} + + + + + + + + {!isSidebar && ( + + )} + + +
+
+ ); +} diff --git a/src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx b/src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx new file mode 100644 index 00000000..8f2718bd --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx @@ -0,0 +1,36 @@ +import { getEditorLoadingStyles } from '../../utils/editorStyles'; + +type CodeEditorLoadingStateProps = { + isDarkMode: boolean; + isSidebar: boolean; + loadingText: string; +}; + +export default function CodeEditorLoadingState({ + isDarkMode, + isSidebar, + loadingText, +}: CodeEditorLoadingStateProps) { + return ( + <> + + {isSidebar ? ( +
+
+
+ {loadingText} +
+
+ ) : ( +
+
+
+
+ {loadingText} +
+
+
+ )} + + ); +} diff --git a/src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx b/src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx new file mode 100644 index 00000000..6a171cd3 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx @@ -0,0 +1,62 @@ +import CodeMirror from '@uiw/react-codemirror'; +import { oneDark } from '@codemirror/theme-one-dark'; +import type { Extension } from '@codemirror/state'; +import MarkdownPreview from './markdown/MarkdownPreview'; + +type CodeEditorSurfaceProps = { + content: string; + onChange: (value: string) => void; + markdownPreview: boolean; + isMarkdownFile: boolean; + isDarkMode: boolean; + fontSize: string; + showLineNumbers: boolean; + extensions: Extension[]; +}; + +export default function CodeEditorSurface({ + content, + onChange, + markdownPreview, + isMarkdownFile, + isDarkMode, + fontSize, + showLineNumbers, + extensions, +}: CodeEditorSurfaceProps) { + if (markdownPreview && isMarkdownFile) { + return ( +
+
+ +
+
+ ); + } + + return ( + + ); +} diff --git a/src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx b/src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx new file mode 100644 index 00000000..47ca8357 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react'; +import type { ComponentProps } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +type MarkdownCodeBlockProps = { + inline?: boolean; + node?: unknown; +} & ComponentProps<'code'>; + +export default function MarkdownCodeBlock({ + inline, + className, + children, + node: _node, + ...props +}: MarkdownCodeBlockProps) { + const [copied, setCopied] = useState(false); + const rawContent = Array.isArray(children) ? children.join('') : String(children ?? ''); + const looksMultiline = /[\r\n]/.test(rawContent); + const shouldRenderInline = inline || !looksMultiline; + + if (shouldRenderInline) { + return ( + + {children} + + ); + } + + const languageMatch = /language-(\w+)/.exec(className || ''); + const language = languageMatch ? languageMatch[1] : 'text'; + + const handleCopy = () => { + const copyPromise = navigator.clipboard?.writeText(rawContent); + void copyPromise?.then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( +
+ {language !== 'text' && ( +
{language}
+ )} + + + + + {rawContent} + +
+ ); +} diff --git a/src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx b/src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx new file mode 100644 index 00000000..b56052d6 --- /dev/null +++ b/src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import type { Components } from 'react-markdown'; +import ReactMarkdown from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import MarkdownCodeBlock from './MarkdownCodeBlock'; + +type MarkdownPreviewProps = { + content: string; +}; + +const markdownPreviewComponents: Components = { + code: MarkdownCodeBlock, + blockquote: ({ children }) => ( +
+ {children} +
+ ), + a: ({ href, children }) => ( + + {children} + + ), + table: ({ children }) => ( +
+ {children}
+
+ ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + {children} + ), +}; + +export default function MarkdownPreview({ content }: MarkdownPreviewProps) { + const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); + const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []); + + return ( + + {content} + + ); +} diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 47cd6d2c..d4e708df 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -1,23 +1,9 @@ -import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import type { AppTab, Project, ProjectSession } from '../../../types/app'; export type SessionLifecycleHandler = (sessionId?: string | null) => void; -export interface DiffInfo { - old_string?: string; - new_string?: string; - [key: string]: unknown; -} - -export interface EditingFile { - name: string; - path: string; - projectName?: string; - diffInfo?: DiffInfo | null; - [key: string]: unknown; -} - -export interface TaskMasterTask { +export type TaskMasterTask = { id: string | number; title?: string; description?: string; @@ -29,24 +15,24 @@ export interface TaskMasterTask { dependencies?: Array; subtasks?: TaskMasterTask[]; [key: string]: unknown; -} +}; -export interface TaskReference { +export type TaskReference = { id: string | number; title?: string; [key: string]: unknown; -} +}; export type TaskSelection = TaskMasterTask | TaskReference; -export interface PrdFile { +export type PrdFile = { name: string; content?: string; isExisting?: boolean; [key: string]: unknown; -} +}; -export interface MainContentProps { +export type MainContentProps = { selectedProject: Project | null; selectedSession: ProjectSession | null; activeTab: AppTab; @@ -67,9 +53,9 @@ export interface MainContentProps { onNavigateToSession: (targetSessionId: string) => void; onShowSettings: () => void; externalMessageUpdate: number; -} +}; -export interface MainContentHeaderProps { +export type MainContentHeaderProps = { activeTab: AppTab; setActiveTab: Dispatch>; selectedProject: Project; @@ -77,33 +63,19 @@ export interface MainContentHeaderProps { shouldShowTasksTab: boolean; isMobile: boolean; onMenuClick: () => void; -} +}; -export interface MainContentStateViewProps { +export type MainContentStateViewProps = { mode: 'loading' | 'empty'; isMobile: boolean; onMenuClick: () => void; -} +}; -export interface MobileMenuButtonProps { +export type MobileMenuButtonProps = { onMenuClick: () => void; compact?: boolean; -} +}; -export interface EditorSidebarProps { - editingFile: EditingFile | null; - isMobile: boolean; - editorExpanded: boolean; - editorWidth: number; - hasManualWidth: boolean; - resizeHandleRef: RefObject; - onResizeStart: (event: MouseEvent) => void; - onCloseEditor: () => void; - onToggleEditorExpand: () => void; - projectPath?: string; - fillSpace?: boolean; -} - -export interface TaskMasterPanelProps { +export type TaskMasterPanelProps = { isVisible: boolean; -} +}; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 998d309a..1db0136a 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -8,14 +8,14 @@ import ErrorBoundary from '../../ErrorBoundary'; import MainContentHeader from './subcomponents/MainContentHeader'; import MainContentStateView from './subcomponents/MainContentStateView'; -import EditorSidebar from './subcomponents/EditorSidebar'; import TaskMasterPanel from './subcomponents/TaskMasterPanel'; import type { MainContentProps } from '../types/types'; import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useUiPreferences } from '../../../hooks/useUiPreferences'; -import { useEditorSidebar } from '../hooks/useEditorSidebar'; +import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; +import EditorSidebar from '../../code-editor/view/EditorSidebar'; import type { Project } from '../../../types/app'; const AnyStandaloneShell = StandaloneShell as any; @@ -162,16 +162,16 @@ function MainContent({
-