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 }) => (
-
- ),
- 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 ? (
+
+ ) : (
+
+ )}
+ >
+ );
+}
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 }) => (
+
+ ),
+ 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({
-