fix(ui): restore codex config highlighting (#1216)

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-11 21:33:33 -04:00
committed by GitHub
parent 46b8ac2560
commit e1777247b3
4 changed files with 95 additions and 43 deletions
@@ -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"
/> />
+76 -35
View File
@@ -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 */}
+1 -1
View File
@@ -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();
}); });
}); });