mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
fix(ui): restore codex config highlighting (#1216)
This commit is contained in:
@@ -23,7 +23,7 @@ interface RawConfigEditorPanelProps {
|
|||||||
onRefresh: () => Promise<void> | void;
|
onRefresh: () => Promise<void> | void;
|
||||||
onDiscard?: () => void;
|
onDiscard?: () => void;
|
||||||
language?: 'json' | 'yaml' | 'toml';
|
language?: 'json' | 'yaml' | 'toml';
|
||||||
plainText?: boolean;
|
exactText?: boolean;
|
||||||
loadingLabel?: string;
|
loadingLabel?: string;
|
||||||
parseWarningLabel?: string;
|
parseWarningLabel?: string;
|
||||||
ownershipNotice?: ReactNode;
|
ownershipNotice?: ReactNode;
|
||||||
@@ -45,7 +45,7 @@ export function RawConfigEditorPanel({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
language = 'json',
|
language = 'json',
|
||||||
plainText = false,
|
exactText = false,
|
||||||
loadingLabel = 'Loading settings.json...',
|
loadingLabel = 'Loading settings.json...',
|
||||||
parseWarningLabel = 'Parse warning',
|
parseWarningLabel = 'Parse warning',
|
||||||
ownershipNotice,
|
ownershipNotice,
|
||||||
@@ -130,7 +130,7 @@ export function RawConfigEditorPanel({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
language={language}
|
language={language}
|
||||||
readonly={readOnly}
|
readonly={readOnly}
|
||||||
plainText={plainText}
|
exactText={exactText}
|
||||||
minHeight="100%"
|
minHeight="100%"
|
||||||
heightMode="fill-parent"
|
heightMode="fill-parent"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useRef, type UIEvent } from 'react';
|
||||||
import Editor from 'react-simple-code-editor';
|
import Editor from 'react-simple-code-editor';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
import { Highlight, themes } from 'prism-react-renderer';
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
@@ -24,7 +24,7 @@ interface CodeEditorProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
language?: 'json' | 'yaml' | 'toml';
|
language?: 'json' | 'yaml' | 'toml';
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
plainText?: boolean;
|
exactText?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
heightMode?: 'content' | 'fill-parent';
|
heightMode?: 'content' | 'fill-parent';
|
||||||
@@ -96,7 +96,7 @@ export function CodeEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
language = 'json',
|
language = 'json',
|
||||||
readonly = false,
|
readonly = false,
|
||||||
plainText = false,
|
exactText = false,
|
||||||
className,
|
className,
|
||||||
minHeight = '300px',
|
minHeight = '300px',
|
||||||
heightMode = 'content',
|
heightMode = 'content',
|
||||||
@@ -191,6 +191,14 @@ export function CodeEditor({
|
|||||||
),
|
),
|
||||||
[isDark, language, validation.line, isMasked]
|
[isDark, language, validation.line, isMasked]
|
||||||
);
|
);
|
||||||
|
const highlightLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const syncHighlightScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
const layer = highlightLayerRef.current;
|
||||||
|
if (!layer) return;
|
||||||
|
|
||||||
|
const { scrollLeft, scrollTop } = event.currentTarget;
|
||||||
|
layer.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -213,30 +221,65 @@ export function CodeEditor({
|
|||||||
className={cn(isFillParent && 'scrollbar-editor min-h-0 flex-1 overflow-auto')}
|
className={cn(isFillParent && 'scrollbar-editor min-h-0 flex-1 overflow-auto')}
|
||||||
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
|
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
|
||||||
>
|
>
|
||||||
{plainText ? (
|
{exactText ? (
|
||||||
<textarea
|
// Exact mode keeps the native textarea as the editable/copyable source of truth while
|
||||||
value={value}
|
// rendering Prism colors behind it with the same non-wrapping layout contract.
|
||||||
onChange={(event) => {
|
<div
|
||||||
if (!readonly) onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
readOnly={readonly}
|
|
||||||
wrap="off"
|
|
||||||
spellCheck={false}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'block w-full resize-none border-0 bg-transparent p-3 font-mono text-sm leading-relaxed',
|
'relative w-full overflow-hidden',
|
||||||
'whitespace-pre overflow-auto focus:outline-none',
|
isFillParent ? 'h-full min-h-full' : 'min-h-[300px]'
|
||||||
isFillParent ? 'h-full min-h-full' : 'min-h-[300px]',
|
|
||||||
readonly && 'cursor-not-allowed'
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
minHeight,
|
minHeight,
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
}}
|
||||||
data-slot="code-editor-plain-textarea"
|
data-slot="code-editor-exact-highlight-wrapper"
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
ref={highlightLayerRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute left-0 top-0 min-w-full p-3 font-mono text-sm leading-relaxed',
|
||||||
|
'whitespace-pre overflow-visible'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
minHeight,
|
||||||
|
tabSize: 2,
|
||||||
|
overflowWrap: 'normal',
|
||||||
|
wordBreak: 'normal',
|
||||||
|
}}
|
||||||
|
data-slot="code-editor-highlight-layer"
|
||||||
|
>
|
||||||
|
{highlightCode(value)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!readonly) onChange(event.target.value);
|
||||||
|
}}
|
||||||
|
readOnly={readonly}
|
||||||
|
wrap="off"
|
||||||
|
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 overflow-auto text-transparent caret-foreground selection:bg-primary/25 focus:outline-none',
|
||||||
|
readonly && 'cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
minHeight,
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
tabSize: 2,
|
||||||
|
overflowWrap: 'normal',
|
||||||
|
wordBreak: 'normal',
|
||||||
|
}}
|
||||||
|
data-slot="code-editor-plain-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Editor
|
<Editor
|
||||||
value={value}
|
value={value}
|
||||||
@@ -261,19 +304,17 @@ export function CodeEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!plainText && (
|
<div className="absolute top-2 right-2 z-20 opacity-50 hover:opacity-100 transition-opacity">
|
||||||
<div className="absolute top-2 right-2 z-10 opacity-50 hover:opacity-100 transition-opacity">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className="h-6 w-6 bg-background/50 hover:bg-background border shadow-sm rounded-full"
|
||||||
className="h-6 w-6 bg-background/50 hover:bg-background border shadow-sm rounded-full"
|
onClick={() => setIsMasked(!isMasked)}
|
||||||
onClick={() => setIsMasked(!isMasked)}
|
title={isMasked ? t('codeEditor.revealSensitive') : t('codeEditor.maskSensitive')}
|
||||||
title={isMasked ? t('codeEditor.revealSensitive') : t('codeEditor.maskSensitive')}
|
>
|
||||||
>
|
{isMasked ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||||
{isMasked ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation status */}
|
{/* Validation status */}
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export function CodexPage() {
|
|||||||
onRefresh={refreshAll}
|
onRefresh={refreshAll}
|
||||||
onDiscard={() => setRawDraftText(null)}
|
onDiscard={() => setRawDraftText(null)}
|
||||||
language="toml"
|
language="toml"
|
||||||
plainText
|
exactText
|
||||||
/* TODO i18n: missing key for "Loading config.toml..." */
|
/* TODO i18n: missing key for "Loading config.toml..." */
|
||||||
loadingLabel="Loading config.toml..."
|
loadingLabel="Loading config.toml..."
|
||||||
/* TODO i18n: missing key for "TOML warning" */
|
/* TODO i18n: missing key for "TOML warning" */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@tests/setup/test-utils';
|
import { fireEvent, render, screen } from '@tests/setup/test-utils';
|
||||||
|
|
||||||
import { CodeEditor } from '@/components/shared/code-editor';
|
import { CodeEditor } from '@/components/shared/code-editor';
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ describe('CodeEditor', () => {
|
|||||||
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
|
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can render raw TOML as plain text so visible lines match copied text', () => {
|
it('renders raw TOML with syntax color while visible lines match copied text', () => {
|
||||||
const rawToml =
|
const rawToml =
|
||||||
'model = "gpt-5.4"\n' +
|
'model = "gpt-5.4"\n' +
|
||||||
'[mcp_servers.playwright]\n' +
|
'[mcp_servers.playwright]\n' +
|
||||||
@@ -82,17 +82,28 @@ describe('CodeEditor', () => {
|
|||||||
language="toml"
|
language="toml"
|
||||||
minHeight="100%"
|
minHeight="100%"
|
||||||
heightMode="fill-parent"
|
heightMode="fill-parent"
|
||||||
plainText
|
exactText
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const textarea = container.querySelector('[data-slot="code-editor-plain-textarea"]');
|
const textarea = container.querySelector('[data-slot="code-editor-plain-textarea"]');
|
||||||
|
const highlightLayer = container.querySelector('[data-slot="code-editor-highlight-layer"]');
|
||||||
|
const highlightedToken = highlightLayer?.querySelector('span');
|
||||||
|
|
||||||
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
|
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
|
||||||
expect(textarea).toHaveValue(rawToml);
|
expect(textarea).toHaveValue(rawToml);
|
||||||
expect(textarea).toHaveAttribute('wrap', 'off');
|
expect(textarea).toHaveAttribute('wrap', 'off');
|
||||||
|
expect(textarea).toHaveClass('text-transparent');
|
||||||
|
expect(highlightLayer).toBeInTheDocument();
|
||||||
|
expect(highlightLayer).toHaveClass('whitespace-pre');
|
||||||
|
expect(highlightedToken).toBeInTheDocument();
|
||||||
expect(container.querySelector('pre')).not.toBeInTheDocument();
|
expect(container.querySelector('pre')).not.toBeInTheDocument();
|
||||||
expect(container.querySelector('button')).not.toBeInTheDocument();
|
if (textarea instanceof HTMLTextAreaElement && highlightLayer instanceof HTMLElement) {
|
||||||
|
textarea.scrollLeft = 96;
|
||||||
|
textarea.scrollTop = 24;
|
||||||
|
fireEvent.scroll(textarea);
|
||||||
|
expect(highlightLayer.style.transform).toBe('translate(-96px, -24px)');
|
||||||
|
}
|
||||||
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
|
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user