mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-15 19:42:12 +08:00
Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)
This commit is contained in:
@@ -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