mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
/**
|
|
* Code Editor Component
|
|
* Lightweight JSON/TOML editor with syntax highlighting, line numbers, and validation
|
|
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
|
*/
|
|
|
|
import { useState, useCallback, useMemo, useRef, type UIEvent } from 'react';
|
|
import Editor from 'react-simple-code-editor';
|
|
import { Highlight, themes } from 'prism-react-renderer';
|
|
import Prism from 'prismjs';
|
|
import 'prismjs/components/prism-json';
|
|
import 'prismjs/components/prism-yaml';
|
|
import 'prismjs/components/prism-toml';
|
|
import { parse as parseToml } from 'smol-toml';
|
|
import { useTheme } from '@/hooks/use-theme';
|
|
import { cn } from '@/lib/utils';
|
|
import { isSensitiveKey } from '@/lib/sensitive-keys';
|
|
import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
interface CodeEditorProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
language?: 'json' | 'yaml' | 'toml';
|
|
readonly?: boolean;
|
|
exactText?: boolean;
|
|
className?: string;
|
|
minHeight?: string;
|
|
heightMode?: 'content' | 'fill-parent';
|
|
}
|
|
|
|
interface ValidationResult {
|
|
valid: boolean;
|
|
error?: string;
|
|
line?: number;
|
|
}
|
|
|
|
/**
|
|
* Validate JSON and extract error location
|
|
*/
|
|
function validateJson(code: string): ValidationResult {
|
|
if (!code.trim()) {
|
|
return { valid: true };
|
|
}
|
|
|
|
try {
|
|
JSON.parse(code);
|
|
return { valid: true };
|
|
} catch (e) {
|
|
const error = e as SyntaxError;
|
|
const message = error.message;
|
|
|
|
// Try to extract line number from error message
|
|
// Format: "... at position X" or "... at line Y column Z"
|
|
const posMatch = message.match(/position (\d+)/);
|
|
if (posMatch) {
|
|
const pos = parseInt(posMatch[1], 10);
|
|
const lines = code.substring(0, pos).split('\n');
|
|
return {
|
|
valid: false,
|
|
error: message,
|
|
line: lines.length,
|
|
};
|
|
}
|
|
|
|
return {
|
|
valid: false,
|
|
error: message,
|
|
};
|
|
}
|
|
}
|
|
|
|
function validateToml(code: string): ValidationResult {
|
|
if (!code.trim()) {
|
|
return { valid: true };
|
|
}
|
|
|
|
try {
|
|
parseToml(code);
|
|
return { valid: true };
|
|
} catch (error) {
|
|
const message = (error as Error).message;
|
|
const lineMatch = message.match(/line\s+(\d+)/i);
|
|
|
|
return {
|
|
valid: false,
|
|
error: message,
|
|
line: lineMatch ? Number.parseInt(lineMatch[1], 10) : undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function CodeEditor({
|
|
value,
|
|
onChange,
|
|
language = 'json',
|
|
readonly = false,
|
|
exactText = false,
|
|
className,
|
|
minHeight = '300px',
|
|
heightMode = 'content',
|
|
}: CodeEditorProps) {
|
|
const { isDark } = useTheme();
|
|
const { t } = useTranslation();
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const [isMasked, setIsMasked] = useState(true);
|
|
const isFillParent = heightMode === 'fill-parent';
|
|
|
|
// Validate on every change for JSON
|
|
const validation = useMemo(() => {
|
|
if (language === 'json') {
|
|
return validateJson(value);
|
|
}
|
|
if (language === 'toml') {
|
|
return validateToml(value);
|
|
}
|
|
return { valid: true };
|
|
}, [value, language]);
|
|
|
|
// Highlight function using prism-react-renderer
|
|
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
|
|
const highlightCode = useCallback(
|
|
(code: string) => (
|
|
<Highlight
|
|
prism={Prism}
|
|
theme={isDark ? themes.nightOwl : themes.github}
|
|
code={code}
|
|
language={language}
|
|
>
|
|
{({ tokens, getLineProps, getTokenProps }) => {
|
|
let nextValueIsSensitive = false;
|
|
|
|
return (
|
|
<>
|
|
{tokens.map((line, i) => (
|
|
<div
|
|
key={i}
|
|
{...getLineProps({ line })}
|
|
className={cn(validation.line === i + 1 && 'bg-destructive/20')}
|
|
>
|
|
{line.map((token, key) => {
|
|
let isSensitive = false;
|
|
|
|
// Check for sensitive keys
|
|
if (token.types.includes('property')) {
|
|
const content = token.content.replace(/['"]/g, '');
|
|
// Use shared sensitive key detection utility
|
|
if (isSensitiveKey(content)) {
|
|
nextValueIsSensitive = true;
|
|
} else {
|
|
nextValueIsSensitive = false;
|
|
}
|
|
}
|
|
// Apply masking to values following sensitive keys
|
|
else if (
|
|
(token.types.includes('string') ||
|
|
token.types.includes('number') ||
|
|
token.types.includes('boolean')) &&
|
|
nextValueIsSensitive
|
|
) {
|
|
isSensitive = true;
|
|
// Consumes the flag for this value
|
|
nextValueIsSensitive = false;
|
|
}
|
|
// Reset flag on commas or new keys (handled by property check),
|
|
// but persist through colons and whitespace
|
|
else if (token.types.includes('punctuation')) {
|
|
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
|
|
nextValueIsSensitive = false;
|
|
}
|
|
}
|
|
|
|
const tokenProps = getTokenProps({ token });
|
|
|
|
if (isSensitive && isMasked) {
|
|
tokenProps.className = cn(
|
|
tokenProps.className,
|
|
'blur-[3px] select-none opacity-70 transition-all duration-200'
|
|
);
|
|
}
|
|
|
|
return <span key={key} {...tokenProps} />;
|
|
})}
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}}
|
|
</Highlight>
|
|
),
|
|
[isDark, language, validation.line, isMasked]
|
|
);
|
|
const highlightLayerRef = useRef<HTMLDivElement>(null);
|
|
const syncHighlightScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
|
|
const layer = highlightLayerRef.current;
|
|
if (!layer) return;
|
|
|
|
const { scrollTop } = event.currentTarget;
|
|
layer.style.transform = `translateY(${-scrollTop}px)`;
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className={cn('flex min-h-0 flex-col', isFillParent && 'h-full', className)}
|
|
style={isFillParent ? { height: minHeight === 'auto' ? undefined : minHeight } : undefined}
|
|
>
|
|
{/* Editor container */}
|
|
<div
|
|
className={cn(
|
|
'relative rounded-md border overflow-hidden',
|
|
'bg-muted/30',
|
|
isFillParent && 'flex min-h-0 flex-1 flex-col',
|
|
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
|
|
readonly && 'opacity-70 cursor-not-allowed',
|
|
!validation.valid && 'border-destructive'
|
|
)}
|
|
data-slot="code-editor-surface"
|
|
>
|
|
<div
|
|
className={cn(
|
|
isFillParent && 'scrollbar-editor min-h-0 flex-1',
|
|
isFillParent && (exactText ? 'overflow-hidden' : 'overflow-auto')
|
|
)}
|
|
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
|
|
>
|
|
{exactText ? (
|
|
// Exact mode keeps the native textarea as the editable/copyable source of truth while
|
|
// rendering Prism colors behind it with the same soft-wrap layout contract.
|
|
<div
|
|
className={cn(
|
|
'relative w-full overflow-hidden',
|
|
isFillParent ? 'h-full min-h-full' : 'min-h-[300px]'
|
|
)}
|
|
style={{
|
|
minHeight,
|
|
}}
|
|
data-slot="code-editor-exact-highlight-wrapper"
|
|
>
|
|
<div
|
|
ref={highlightLayerRef}
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'pointer-events-none absolute inset-x-0 top-0 w-full p-3 font-mono text-sm leading-relaxed',
|
|
'whitespace-pre-wrap overflow-visible break-words'
|
|
)}
|
|
style={{
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
fontSize: '0.875rem',
|
|
minHeight,
|
|
tabSize: 2,
|
|
overflowWrap: 'anywhere',
|
|
wordBreak: 'break-word',
|
|
}}
|
|
data-slot="code-editor-highlight-layer"
|
|
>
|
|
{highlightCode(value)}
|
|
</div>
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => {
|
|
if (!readonly) onChange(event.target.value);
|
|
}}
|
|
readOnly={readonly}
|
|
wrap="soft"
|
|
spellCheck={false}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
onScroll={syncHighlightScroll}
|
|
className={cn(
|
|
'absolute inset-0 z-10 block h-full w-full resize-none border-0 bg-transparent p-3 font-mono text-sm leading-relaxed',
|
|
'whitespace-pre-wrap overflow-y-auto overflow-x-hidden break-words text-transparent caret-foreground selection:bg-transparent focus:outline-none',
|
|
readonly && 'cursor-not-allowed'
|
|
)}
|
|
style={{
|
|
minHeight,
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
fontSize: '0.875rem',
|
|
tabSize: 2,
|
|
overflowWrap: 'anywhere',
|
|
wordBreak: 'break-word',
|
|
WebkitTextFillColor: 'transparent',
|
|
}}
|
|
data-slot="code-editor-plain-textarea"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Editor
|
|
value={value}
|
|
onValueChange={readonly ? () => {} : onChange}
|
|
highlight={highlightCode}
|
|
key={isDark ? 'dark-editor' : 'light-editor'}
|
|
padding={12}
|
|
disabled={readonly}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
textareaClassName={cn(
|
|
'focus:outline-none font-mono text-sm',
|
|
readonly && 'cursor-not-allowed'
|
|
)}
|
|
preClassName="font-mono text-sm"
|
|
style={{
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
fontSize: '0.875rem',
|
|
minHeight,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="absolute top-2 right-2 z-20 opacity-50 hover:opacity-100 transition-opacity">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 bg-background/50 hover:bg-background border shadow-sm rounded-full"
|
|
onClick={() => setIsMasked(!isMasked)}
|
|
title={isMasked ? t('codeEditor.revealSensitive') : t('codeEditor.maskSensitive')}
|
|
>
|
|
{isMasked ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Validation status */}
|
|
<div className="flex items-center gap-2 mt-2 text-xs">
|
|
{validation.valid ? (
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
|
{t('codeEditor.valid', { language: language.toUpperCase() })}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-destructive">
|
|
<AlertCircle className="w-3 h-3" />
|
|
{validation.error}
|
|
{validation.line && ` (line ${validation.line})`}
|
|
</span>
|
|
)}
|
|
{readonly && (
|
|
<span className="ml-auto text-muted-foreground">{t('codeEditor.readOnly')}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|