mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 00:24:08 +00:00
Merge pull request #800 from kaitranntt/kai/fix/json-viewer-scroll
fix(ui): restore scrolling in bounded code editors
This commit is contained in:
@@ -234,8 +234,8 @@ export function ProviderEditor({
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
<div className="flex flex-col overflow-hidden bg-muted/5">
|
||||
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
|
||||
<Tabs defaultValue="config" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
@@ -303,7 +303,7 @@ export function ProviderEditor({
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -32,7 +32,7 @@ export function RawEditorSection({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -56,13 +56,14 @@ export function RawEditorSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={onRawJsonChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,15 +85,21 @@ export function RawJsonSettingsEditorPanel({
|
||||
Loading settings.json...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{parseWarning && (
|
||||
<div className="mx-4 mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
Parse warning: {parseWarning}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 p-4 pt-3">
|
||||
<div className="min-h-0 flex-1 p-4 pt-3">
|
||||
<div className="h-full rounded-md border overflow-hidden bg-background">
|
||||
<CodeEditor value={value} onChange={onChange} language="json" minHeight="100%" />
|
||||
<CodeEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function RawEditorSection({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -63,13 +63,14 @@ export function RawEditorSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={onChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,8 +226,8 @@ export function ProfileEditor({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
<div className="flex flex-col overflow-hidden bg-muted/5 min-w-0">
|
||||
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
|
||||
<FriendlyUISection
|
||||
profileName={profileName}
|
||||
target={resolvedTarget}
|
||||
@@ -242,7 +242,7 @@ export function ProfileEditor({
|
||||
onAddEnvVar={addNewEnvVar}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -41,7 +41,7 @@ export function RawEditorSection({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -64,13 +64,14 @@ export function RawEditorSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={onChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<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'
|
||||
)}
|
||||
style={{ minHeight }}
|
||||
data-slot="code-editor-surface"
|
||||
>
|
||||
<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
|
||||
className={cn(isFillParent && 'scrollbar-editor min-h-0 flex-1 overflow-auto')}
|
||||
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Secrets Toggle Overlay */}
|
||||
<div className="absolute top-2 right-2 z-10 opacity-50 hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -279,7 +279,7 @@ function SettingsDialogContent({
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw" className="flex-1 overflow-hidden p-4 pt-4 m-0">
|
||||
<TabsContent value="raw" className="m-0 min-h-0 flex-1 overflow-hidden p-4 pt-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -293,6 +293,7 @@ function SettingsDialogContent({
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="calc(60vh - 120px)"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ export function ApiPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{selectedProfileData ? (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b bg-background flex items-center justify-end gap-2">
|
||||
|
||||
@@ -1248,6 +1248,7 @@ function EntryInspector({
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1271,6 +1272,7 @@ function EntryInspector({
|
||||
language="json"
|
||||
readonly
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
<CodeEditor
|
||||
value={'{\n "provider": "openrouter"\n}'}
|
||||
onChange={vi.fn()}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<CodeEditor
|
||||
value={'{\n "provider": "openrouter"\n}'}
|
||||
onChange={vi.fn()}
|
||||
language="json"
|
||||
readonly
|
||||
minHeight="calc(60vh - 120px)"
|
||||
heightMode="fill-parent"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<CodeEditor
|
||||
value={'{\n "provider": "openrouter"\n}'}
|
||||
onChange={vi.fn()}
|
||||
language="json"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user