mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-11 00:47:52 +00:00
Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)
This commit is contained in:
17
src/components/code-editor/constants/settings.ts
Normal file
17
src/components/code-editor/constants/settings.ts
Normal file
@@ -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';
|
||||
126
src/components/code-editor/hooks/useCodeEditorDocument.ts
Normal file
126
src/components/code-editor/hooks/useCodeEditorDocument.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const fileProjectName = file.projectName ?? projectPath;
|
||||
const filePath = file.path;
|
||||
const fileName = file.name;
|
||||
const fileDiffNewString = file.diffInfo?.new_string;
|
||||
const fileDiffOldString = file.diffInfo?.old_string;
|
||||
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Diff payload may already include full old/new snapshots, so avoid disk read.
|
||||
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
|
||||
setContent(fileDiffNewString);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileProjectName) {
|
||||
throw new Error('Missing project identifier');
|
||||
}
|
||||
|
||||
const response = await api.readFile(fileProjectName, filePath);
|
||||
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: ${fileName}\n// Path: ${filePath}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
if (!fileProjectName) {
|
||||
throw new Error('Missing project identifier');
|
||||
}
|
||||
|
||||
const response = await api.saveFile(fileProjectName, filePath, content);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
await response.json();
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error('Error saving file:', error);
|
||||
setSaveError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, fileProjectName]);
|
||||
|
||||
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,
|
||||
saveError,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
};
|
||||
};
|
||||
85
src/components/code-editor/hooks/useCodeEditorSettings.ts
Normal file
85
src/components/code-editor/hooks/useCodeEditorSettings.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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 = () => {
|
||||
const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);
|
||||
return Number(stored ?? 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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
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.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [dependency, onClose, onSave]);
|
||||
};
|
||||
114
src/components/code-editor/hooks/useEditorSidebar.ts
Normal file
114
src/components/code-editor/hooks/useEditorSidebar.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';
|
||||
|
||||
type UseEditorSidebarOptions = {
|
||||
selectedProject: Project | null;
|
||||
isMobile: boolean;
|
||||
initialWidth?: number;
|
||||
};
|
||||
|
||||
export const useEditorSidebar = ({
|
||||
selectedProject,
|
||||
isMobile,
|
||||
initialWidth = 600,
|
||||
}: UseEditorSidebarOptions) => {
|
||||
const [editingFile, setEditingFile] = useState<CodeEditorFile | null>(null);
|
||||
const [editorWidth, setEditorWidth] = useState(initialWidth);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [hasManualWidth, setHasManualWidth] = useState(false);
|
||||
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleFileOpen = useCallback(
|
||||
(filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const fileName = normalizedPath.split('/').pop() || filePath;
|
||||
|
||||
setEditingFile({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
projectName: selectedProject?.name,
|
||||
diffInfo,
|
||||
});
|
||||
},
|
||||
[selectedProject?.name],
|
||||
);
|
||||
|
||||
const handleCloseEditor = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setEditorExpanded(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleEditorExpand = useCallback(() => {
|
||||
setEditorExpanded((previous) => !previous);
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// After first drag interaction, the editor width is user-controlled.
|
||||
setHasManualWidth(true);
|
||||
setIsResizing(true);
|
||||
event.preventDefault();
|
||||
},
|
||||
[isMobile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: globalThis.MouseEvent) => {
|
||||
if (!isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = resizeHandleRef.current?.parentElement;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - event.clientX;
|
||||
|
||||
const minWidth = 300;
|
||||
const maxWidth = containerRect.width * 0.8;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setEditorWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
return {
|
||||
editingFile,
|
||||
editorWidth,
|
||||
editorExpanded,
|
||||
hasManualWidth,
|
||||
resizeHandleRef,
|
||||
handleFileOpen,
|
||||
handleCloseEditor,
|
||||
handleToggleEditorExpand,
|
||||
handleResizeStart,
|
||||
};
|
||||
};
|
||||
21
src/components/code-editor/types/types.ts
Normal file
21
src/components/code-editor/types/types.ts
Normal file
@@ -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;
|
||||
};
|
||||
141
src/components/code-editor/utils/editorExtensions.ts
Normal file
141
src/components/code-editor/utils/editorExtensions.ts
Normal file
@@ -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<number, string> = {};
|
||||
|
||||
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() {}
|
||||
}),
|
||||
];
|
||||
};
|
||||
79
src/components/code-editor/utils/editorStyles.ts
Normal file
79
src/components/code-editor/utils/editorStyles.ts
Normal file
@@ -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;
|
||||
}
|
||||
`;
|
||||
};
|
||||
212
src/components/code-editor/utils/editorToolbarPanel.ts
Normal file
212
src/components/code-editor/utils/editorToolbarPanel.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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 '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
|
||||
}
|
||||
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
|
||||
};
|
||||
|
||||
const getExpandIcon = (isExpanded: boolean) => {
|
||||
if (isExpanded) {
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />';
|
||||
}
|
||||
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />';
|
||||
};
|
||||
|
||||
const escapeHtml = (value: string): string => (
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
);
|
||||
|
||||
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;
|
||||
const maxChunkIndex = Math.max(0, chunkCount - 1);
|
||||
currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));
|
||||
const escapedLabels = {
|
||||
changes: escapeHtml(labels.changes),
|
||||
previousChange: escapeHtml(labels.previousChange),
|
||||
nextChange: escapeHtml(labels.nextChange),
|
||||
hideDiff: escapeHtml(labels.hideDiff),
|
||||
showDiff: escapeHtml(labels.showDiff),
|
||||
collapse: escapeHtml(labels.collapse),
|
||||
expand: escapeHtml(labels.expand),
|
||||
};
|
||||
// Icons are static SVG path fragments controlled by this module.
|
||||
const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);
|
||||
const expandIcon = getExpandIcon(isExpanded);
|
||||
|
||||
let toolbarHtml = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
|
||||
toolbarHtml += '<div style="display: flex; align-items: center; gap: 8px;">';
|
||||
|
||||
if (hasDiff) {
|
||||
toolbarHtml += `
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${escapedLabels.previousChange}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${escapedLabels.nextChange}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
toolbarHtml += '</div>';
|
||||
toolbarHtml += '<div style="display: flex; align-items: center; gap: 4px;">';
|
||||
|
||||
if (file.diffInfo) {
|
||||
toolbarHtml += `
|
||||
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? escapedLabels.hideDiff : escapedLabels.showDiff}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${diffVisibilityIcon}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isSidebar && onPopOut) {
|
||||
toolbarHtml += `
|
||||
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isSidebar && onToggleExpand) {
|
||||
toolbarHtml += `
|
||||
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? escapedLabels.collapse : escapedLabels.expand}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${expandIcon}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
toolbarHtml += '</div>';
|
||||
toolbarHtml += '</div>';
|
||||
|
||||
dom.innerHTML = toolbarHtml;
|
||||
|
||||
if (hasDiff) {
|
||||
const previousButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-prev');
|
||||
const nextButton = dom.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.cm-toggle-diff-btn');
|
||||
toggleDiffButton?.addEventListener('click', onToggleDiff);
|
||||
|
||||
const popOutButton = dom.querySelector<HTMLButtonElement>('.cm-popout-btn');
|
||||
popOutButton?.addEventListener('click', () => {
|
||||
onPopOut?.();
|
||||
});
|
||||
|
||||
const expandButton = dom.querySelector<HTMLButtonElement>('.cm-expand-btn');
|
||||
expandButton?.addEventListener('click', () => {
|
||||
onToggleExpand?.();
|
||||
});
|
||||
};
|
||||
|
||||
updatePanel();
|
||||
|
||||
return {
|
||||
top: true,
|
||||
dom,
|
||||
update: updatePanel,
|
||||
};
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
};
|
||||
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
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,
|
||||
saveError,
|
||||
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 (
|
||||
<CodeEditorLoadingState
|
||||
isDarkMode={isDarkMode}
|
||||
isSidebar={isSidebar}
|
||||
loadingText={t('loading', { fileName: file.name })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<style>{getEditorStyles(isDarkMode)}</style>
|
||||
<div className={outerContainerClassName}>
|
||||
<div className={innerContainerClassName}>
|
||||
<CodeEditorHeader
|
||||
file={file}
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
markdownPreview={markdownPreview}
|
||||
saving={saving}
|
||||
saveSuccess={saveSuccess}
|
||||
onToggleMarkdownPreview={() => 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'),
|
||||
}}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeEditorSurface
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
markdownPreview={markdownPreview}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
isDarkMode={isDarkMode}
|
||||
fontSize={fontSize}
|
||||
showLineNumbers={showLineNumbers}
|
||||
extensions={extensions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeEditorFooter
|
||||
content={content}
|
||||
linesLabel={t('footer.lines')}
|
||||
charactersLabel={t('footer.characters')}
|
||||
shortcutsLabel={t('footer.shortcuts')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/components/code-editor/view/EditorSidebar.tsx
Normal file
85
src/components/code-editor/view/EditorSidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import type { MouseEvent, MutableRefObject } from 'react';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import CodeEditor from './CodeEditor';
|
||||
|
||||
type EditorSidebarProps = {
|
||||
editingFile: CodeEditorFile | null;
|
||||
isMobile: boolean;
|
||||
editorExpanded: boolean;
|
||||
editorWidth: number;
|
||||
hasManualWidth: boolean;
|
||||
resizeHandleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onCloseEditor: () => void;
|
||||
onToggleEditorExpand: () => void;
|
||||
projectPath?: string;
|
||||
fillSpace?: boolean;
|
||||
};
|
||||
|
||||
export default function EditorSidebar({
|
||||
editingFile,
|
||||
isMobile,
|
||||
editorExpanded,
|
||||
editorWidth,
|
||||
hasManualWidth,
|
||||
resizeHandleRef,
|
||||
onResizeStart,
|
||||
onCloseEditor,
|
||||
onToggleEditorExpand,
|
||||
projectPath,
|
||||
fillSpace,
|
||||
}: EditorSidebarProps) {
|
||||
const [poppedOut, setPoppedOut] = useState(false);
|
||||
|
||||
if (!editingFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMobile || poppedOut) {
|
||||
return (
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={() => {
|
||||
setPoppedOut(false);
|
||||
onCloseEditor();
|
||||
}}
|
||||
projectPath={projectPath}
|
||||
isSidebar={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// In files tab, fill the remaining width unless user has dragged manually.
|
||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
onMouseDown={onResizeStart}
|
||||
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
|
||||
style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={onCloseEditor}
|
||||
projectPath={projectPath}
|
||||
isSidebar
|
||||
isExpanded={editorExpanded}
|
||||
onToggleExpand={onToggleEditorExpand}
|
||||
onPopOut={() => setPoppedOut(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
type CodeEditorFooterProps = {
|
||||
content: string;
|
||||
linesLabel: string;
|
||||
charactersLabel: string;
|
||||
shortcutsLabel: string;
|
||||
};
|
||||
|
||||
export default function CodeEditorFooter({
|
||||
content,
|
||||
linesLabel,
|
||||
charactersLabel,
|
||||
shortcutsLabel,
|
||||
}: CodeEditorFooterProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{linesLabel} {content.split('\n').length}
|
||||
</span>
|
||||
<span>
|
||||
{charactersLabel} {content.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{shortcutsLabel}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{labels.showingChanges}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMarkdownPreview}
|
||||
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
|
||||
markdownPreview
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
|
||||
>
|
||||
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.settings}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.download}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={saveTitle}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.close}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<style>{getEditorLoadingStyles(isDarkMode)}</style>
|
||||
{isSidebar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
|
||||
<span className="text-gray-900 dark:text-white">{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
|
||||
<span className="text-gray-900 dark:text-white">{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: number;
|
||||
showLineNumbers: boolean;
|
||||
extensions: Extension[];
|
||||
};
|
||||
|
||||
export default function CodeEditorSurface({
|
||||
content,
|
||||
onChange,
|
||||
markdownPreview,
|
||||
isMarkdownFile,
|
||||
isDarkMode,
|
||||
fontSize,
|
||||
showLineNumbers,
|
||||
extensions,
|
||||
}: CodeEditorSurfaceProps) {
|
||||
if (markdownPreview && isMarkdownFile) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
|
||||
<MarkdownPreview content={content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
value={content}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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';
|
||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||
|
||||
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 (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const languageMatch = /language-(\w+)/.exec(className || '');
|
||||
const language = languageMatch ? languageMatch[1] : 'text';
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyTextToClipboard(rawContent).then((success) => {
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
})}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={prismOneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
{rawContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Components } from 'react-markdown';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
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 }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownPreviewComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user