diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 502b8583..55d858f0 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -234,8 +234,8 @@ export function ProviderEditor({ Loading settings... ) : ( -
-
+
+
@@ -303,7 +303,7 @@ export function ProviderEditor({
-
+
diff --git a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx index bae26325..904a4dcc 100644 --- a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx @@ -32,7 +32,7 @@ export function RawEditorSection({
} > -
+
{!isRawJsonValid && rawJsonEdits !== null && (
@@ -56,13 +56,14 @@ export function RawEditorSection({
)} -
+
diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx index 6eec468b..0a84103a 100644 --- a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -85,15 +85,21 @@ export function RawJsonSettingsEditorPanel({ Loading settings.json...
) : ( -
+
{parseWarning && (
Parse warning: {parseWarning}
)} -
+
- +
diff --git a/ui/src/components/copilot/config-form/raw-editor-section.tsx b/ui/src/components/copilot/config-form/raw-editor-section.tsx index 6a13c9e1..b1369951 100644 --- a/ui/src/components/copilot/config-form/raw-editor-section.tsx +++ b/ui/src/components/copilot/config-form/raw-editor-section.tsx @@ -40,7 +40,7 @@ export function RawEditorSection({
} > -
+
{!isRawJsonValid && rawJsonEdits !== null && (
@@ -63,13 +63,14 @@ export function RawEditorSection({
)} -
+
diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index d13c4353..f9709845 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -226,8 +226,8 @@ export function ProfileEditor({
) : ( -
-
+
+
-
+
diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index d4bbe4ac..686fb2e7 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -41,7 +41,7 @@ export function RawEditorSection({
} > -
+
{!isRawJsonValid && rawJsonEdits !== null && (
@@ -64,13 +64,14 @@ export function RawEditorSection({
)} -
+
diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 47f95edc..5dbc3fd4 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -20,6 +20,7 @@ interface CodeEditorProps { readonly?: boolean; className?: string; minHeight?: string; + heightMode?: 'content' | 'fill-parent'; } interface ValidationResult { @@ -70,10 +71,12 @@ export function CodeEditor({ readonly = false, className, minHeight = '300px', + heightMode = 'content', }: CodeEditorProps) { const { isDark } = useTheme(); const [isFocused, setIsFocused] = useState(false); const [isMasked, setIsMasked] = useState(true); + const isFillParent = heightMode === 'fill-parent'; // Validate on every change for JSON const validation = useMemo(() => { @@ -153,38 +156,47 @@ export function CodeEditor({ ); return ( -
+
{/* Editor container */}
- {} : 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, - }} - /> +
+ {} : 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, + }} + /> +
{/* Secrets Toggle Overlay */}
diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx index faeffb54..dac1321d 100644 --- a/ui/src/components/shared/settings-dialog.tsx +++ b/ui/src/components/shared/settings-dialog.tsx @@ -279,7 +279,7 @@ function SettingsDialogContent({ - + @@ -293,6 +293,7 @@ function SettingsDialogContent({ onChange={handleRawJsonChange} language="json" minHeight="calc(60vh - 120px)" + heightMode="fill-parent" /> diff --git a/ui/src/index.css b/ui/src/index.css index 1e0a0d23..b15fdb8e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -342,3 +342,35 @@ .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +.scrollbar-editor { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + scrollbar-gutter: stable; +} + +.scrollbar-editor::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.scrollbar-editor::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-editor::-webkit-scrollbar-thumb { + background: var(--border); + border: 2px solid transparent; + border-radius: 9999px; + background-clip: content-box; + min-height: 32px; +} + +.scrollbar-editor::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + background-clip: content-box; +} + +.scrollbar-editor::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 2267a659..2a653f87 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -354,7 +354,7 @@ export function ApiPage() { />
-
+
{selectedProfileData ? ( <>
diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index f7a492f2..b7a8c79e 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -1248,6 +1248,7 @@ function EntryInspector({ onChange={handleRawJsonChange} language="json" minHeight="100%" + heightMode="fill-parent" />
@@ -1271,6 +1272,7 @@ function EntryInspector({ language="json" readonly minHeight="100%" + heightMode="fill-parent" />
diff --git a/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts b/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts new file mode 100644 index 00000000..57c638c6 --- /dev/null +++ b/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const boundedConsumers = [ + { + file: 'src/pages/cliproxy-ai-providers.tsx', + expectedCount: 2, + }, + { + file: 'src/components/cliproxy/provider-editor/raw-editor-section.tsx', + expectedCount: 1, + }, + { + file: 'src/components/profiles/editor/raw-editor-section.tsx', + expectedCount: 1, + }, + { + file: 'src/components/copilot/config-form/raw-editor-section.tsx', + expectedCount: 1, + }, + { + file: 'src/components/compatible-cli/raw-json-settings-editor-panel.tsx', + expectedCount: 1, + }, + { + file: 'src/components/shared/settings-dialog.tsx', + expectedCount: 1, + }, +] as const; + +const boundedLayoutContracts = [ + { + file: 'src/components/profiles/editor/index.tsx', + snippets: [ + 'min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden', + 'flex min-h-0 min-w-0 flex-col overflow-hidden', + ], + }, + { + file: 'src/components/cliproxy/provider-editor/index.tsx', + snippets: [ + 'min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden', + 'flex min-h-0 min-w-0 flex-col overflow-hidden', + ], + }, + { + file: 'src/pages/api.tsx', + snippets: ['flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden'], + }, +] as const; + +describe('bounded CodeEditor consumers', () => { + it.each(boundedConsumers)('$file opts into fill-parent mode for every bounded editor', ({ + file, + expectedCount, + }) => { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + const matches = source.match(/heightMode="fill-parent"/g) ?? []; + + expect(matches).toHaveLength(expectedCount); + }); + + it.each(boundedLayoutContracts)( + '$file keeps bounded editor ancestors shrinkable', + ({ file, snippets }) => { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + + for (const snippet of snippets) { + expect(source).toContain(snippet); + } + } + ); +}); diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx new file mode 100644 index 00000000..67e8b4c8 --- /dev/null +++ b/ui/tests/unit/components/ui/code-editor.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@tests/setup/test-utils'; + +import { CodeEditor } from '@/components/shared/code-editor'; + +vi.mock('@/hooks/use-theme', () => ({ + useTheme: () => ({ isDark: false }), +})); + +describe('CodeEditor', () => { + it('creates an internal scroll viewport in fill-parent mode and keeps status outside it', () => { + const { container } = render( + + ); + + const viewport = container.querySelector('[data-slot="code-editor-viewport"]'); + const root = container.firstElementChild; + + expect(viewport).toBeInTheDocument(); + expect(root).toHaveStyle({ height: '100%' }); + expect(viewport).not.toContainElement(screen.getByText('Valid JSON')); + }); + + it('keeps readonly status outside the scroll viewport for bounded editors', () => { + const { container } = render( + + ); + + const viewport = container.querySelector('[data-slot="code-editor-viewport"]'); + const textarea = container.querySelector('textarea'); + const root = container.firstElementChild; + + expect(root).toHaveStyle({ height: 'calc(60vh - 120px)' }); + expect(textarea).toBeDisabled(); + expect(viewport).not.toContainElement(screen.getByText('(Read-only)')); + }); + + it('preserves content mode as the default layout contract', () => { + const { container } = render( + + ); + + expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument(); + }); +});