Files
ccs/ui/src/components/shared/code-editor.tsx
T

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>
);
}