feat: implement ignore patterns and extension filters in CodexLens

- Added tests to ensure loading of ignore patterns and extension filters from settings.
- Implemented functionality to respect ignore patterns and extension filters during file indexing.
- Created integration tests for CodexLens ignore-pattern configuration routes.
- Added a new AdvancedTab component with tests for managing ignore patterns and extension filters.
- Established a comprehensive branding naming system for the Maestro project, including guidelines for package names, CLI commands, and directory structure.
This commit is contained in:
catlog22
2026-03-09 14:43:21 +08:00
parent 3341a2e772
commit b2fc2f60f1
33 changed files with 1489 additions and 69 deletions

View File

@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import { render, screen, waitFor } from '@/test/i18n';
import { AdvancedTab } from './AdvancedTab';
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensEnv: vi.fn(),
useUpdateCodexLensEnv: vi.fn(),
useCodexLensIgnorePatterns: vi.fn(),
useUpdateIgnorePatterns: vi.fn(),
useNotifications: vi.fn(),
};
});
import {
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensIgnorePatterns,
useUpdateIgnorePatterns,
useNotifications,
} from '@/hooks';
const mockRefetchEnv = vi.fn().mockResolvedValue(undefined);
const mockRefetchPatterns = vi.fn().mockResolvedValue(undefined);
const mockUpdateEnv = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
const mockUpdatePatterns = vi.fn().mockResolvedValue({
success: true,
patterns: ['dist', 'frontend/dist'],
extensionFilters: ['*.min.js', 'frontend/skip.ts'],
defaults: {
patterns: ['dist', 'build'],
extensionFilters: ['*.min.js'],
},
});
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
function setupDefaultMocks() {
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: {}, settings: {}, raw: '', path: '~/.codexlens/.env' },
raw: '',
env: {},
settings: {},
isLoading: false,
error: null,
refetch: mockRefetchEnv,
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: mockUpdateEnv,
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensIgnorePatterns).mockReturnValue({
data: {
success: true,
patterns: ['dist', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
defaults: {
patterns: ['dist', 'build', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
},
},
patterns: ['dist', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
defaults: {
patterns: ['dist', 'build', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
},
isLoading: false,
error: null,
refetch: mockRefetchPatterns,
});
vi.mocked(useUpdateIgnorePatterns).mockReturnValue({
updatePatterns: mockUpdatePatterns,
isUpdating: false,
error: null,
});
vi.mocked(useNotifications).mockReturnValue({
success: mockToastSuccess,
error: mockToastError,
} as ReturnType<typeof useNotifications>);
}
describe('AdvancedTab', () => {
beforeEach(() => {
vi.clearAllMocks();
setupDefaultMocks();
});
it('renders existing filter configuration', () => {
render(<AdvancedTab enabled={true} />);
expect(screen.getByLabelText(/Ignored directories \/ paths/i)).toHaveValue('dist\ncoverage');
expect(screen.getByLabelText(/Skipped files \/ globs/i)).toHaveValue('*.min.js\n*.map');
expect(screen.getByText(/Directory filters: 2/i)).toBeInTheDocument();
expect(screen.getByText(/File filters: 2/i)).toBeInTheDocument();
});
it('saves parsed filter configuration', async () => {
render(<AdvancedTab enabled={true} />);
const ignorePatternsInput = screen.getByLabelText(/Ignored directories \/ paths/i);
const extensionFiltersInput = screen.getByLabelText(/Skipped files \/ globs/i);
fireEvent.change(ignorePatternsInput, { target: { value: 'dist,\nfrontend/dist' } });
fireEvent.change(extensionFiltersInput, { target: { value: '*.min.js,\nfrontend/skip.ts' } });
fireEvent.click(screen.getByRole('button', { name: /Save filters/i }));
await waitFor(() => {
expect(mockUpdatePatterns).toHaveBeenCalledWith({
patterns: ['dist', 'frontend/dist'],
extensionFilters: ['*.min.js', 'frontend/skip.ts'],
});
});
expect(mockRefetchPatterns).toHaveBeenCalled();
});
it('restores default filter values before saving', async () => {
render(<AdvancedTab enabled={true} />);
fireEvent.click(screen.getByRole('button', { name: /Restore defaults/i }));
expect(screen.getByLabelText(/Ignored directories \/ paths/i)).toHaveValue('dist\nbuild\ncoverage');
expect(screen.getByLabelText(/Skipped files \/ globs/i)).toHaveValue('*.min.js\n*.map');
fireEvent.click(screen.getByRole('button', { name: /Save filters/i }));
await waitFor(() => {
expect(mockUpdatePatterns).toHaveBeenCalledWith({
patterns: ['dist', 'build', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
});
});
});
it('blocks invalid filter entries before saving', async () => {
render(<AdvancedTab enabled={true} />);
fireEvent.change(screen.getByLabelText(/Ignored directories \/ paths/i), {
target: { value: 'bad pattern!' },
});
fireEvent.click(screen.getByRole('button', { name: /Save filters/i }));
expect(mockUpdatePatterns).not.toHaveBeenCalled();
expect(screen.getByText(/Invalid ignore patterns/i)).toBeInTheDocument();
});
});

View File

@@ -5,13 +5,18 @@
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw, AlertTriangle, FileCode, AlertCircle } from 'lucide-react';
import { Save, RefreshCw, AlertTriangle, FileCode, AlertCircle, Filter } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Badge } from '@/components/ui/Badge';
import { useCodexLensEnv, useUpdateCodexLensEnv } from '@/hooks';
import {
useCodexLensEnv,
useCodexLensIgnorePatterns,
useUpdateCodexLensEnv,
useUpdateIgnorePatterns,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { CcwToolsCard } from './CcwToolsCard';
@@ -22,6 +27,28 @@ interface AdvancedTabProps {
interface FormErrors {
env?: string;
ignorePatterns?: string;
extensionFilters?: string;
}
const FILTER_ENTRY_PATTERN = /^[-\w.*\\/]+$/;
function parseListEntries(text: string): string[] {
return text
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function normalizeListEntries(entries: string[]): string {
return entries
.map((entry) => entry.trim())
.filter(Boolean)
.join('\n');
}
function normalizeListText(text: string): string {
return normalizeListEntries(parseListEntries(text));
}
export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
@@ -37,14 +64,32 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
refetch,
} = useCodexLensEnv({ enabled });
const {
patterns,
extensionFilters,
defaults,
isLoading: isLoadingPatterns,
error: patternsError,
refetch: refetchPatterns,
} = useCodexLensIgnorePatterns({ enabled });
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
const { updatePatterns, isUpdating: isUpdatingPatterns } = useUpdateIgnorePatterns();
// Form state
const [envInput, setEnvInput] = useState('');
const [ignorePatternsInput, setIgnorePatternsInput] = useState('');
const [extensionFiltersInput, setExtensionFiltersInput] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [hasChanges, setHasChanges] = useState(false);
const [hasFilterChanges, setHasFilterChanges] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const currentIgnorePatterns = patterns ?? [];
const currentExtensionFilters = extensionFilters ?? [];
const defaultIgnorePatterns = defaults?.patterns ?? [];
const defaultExtensionFilters = defaults?.extensionFilters ?? [];
// Initialize form from env - handles both undefined (loading) and empty string (empty file)
// The hook returns raw directly, so we check if it's been set (not undefined means data loaded)
useEffect(() => {
@@ -58,6 +103,31 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
}
}, [raw, isLoadingEnv]);
useEffect(() => {
if (!isLoadingPatterns) {
const nextIgnorePatterns = patterns ?? [];
const nextExtensionFilters = extensionFilters ?? [];
setIgnorePatternsInput(nextIgnorePatterns.join('\n'));
setExtensionFiltersInput(nextExtensionFilters.join('\n'));
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
setHasFilterChanges(false);
}
}, [extensionFilters, isLoadingPatterns, patterns]);
const updateFilterChangeState = (nextIgnorePatternsInput: string, nextExtensionFiltersInput: string) => {
const normalizedCurrentIgnorePatterns = normalizeListEntries(currentIgnorePatterns);
const normalizedCurrentExtensionFilters = normalizeListEntries(currentExtensionFilters);
setHasFilterChanges(
normalizeListText(nextIgnorePatternsInput) !== normalizedCurrentIgnorePatterns
|| normalizeListText(nextExtensionFiltersInput) !== normalizedCurrentExtensionFilters
);
};
const handleEnvChange = (value: string) => {
setEnvInput(value);
// Check if there are changes - compare with raw value (handle undefined as empty)
@@ -69,6 +139,22 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
}
};
const handleIgnorePatternsChange = (value: string) => {
setIgnorePatternsInput(value);
updateFilterChangeState(value, extensionFiltersInput);
if (errors.ignorePatterns) {
setErrors((prev) => ({ ...prev, ignorePatterns: undefined }));
}
};
const handleExtensionFiltersChange = (value: string) => {
setExtensionFiltersInput(value);
updateFilterChangeState(ignorePatternsInput, value);
if (errors.extensionFilters) {
setErrors((prev) => ({ ...prev, extensionFilters: undefined }));
}
};
const parseEnvVariables = (text: string): Record<string, string> => {
const envObj: Record<string, string> = {};
const lines = text.split('\n');
@@ -101,10 +187,50 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
);
}
setErrors(newErrors);
setErrors((prev) => ({ ...prev, env: newErrors.env }));
return Object.keys(newErrors).length === 0;
};
const validateFilterForm = (): boolean => {
const nextErrors: Pick<FormErrors, 'ignorePatterns' | 'extensionFilters'> = {};
const parsedIgnorePatterns = parseListEntries(ignorePatternsInput);
const parsedExtensionFilters = parseListEntries(extensionFiltersInput);
const invalidIgnorePatterns = parsedIgnorePatterns.filter(
(entry) => !FILTER_ENTRY_PATTERN.test(entry)
);
if (invalidIgnorePatterns.length > 0) {
nextErrors.ignorePatterns = formatMessage(
{
id: 'codexlens.advanced.validation.invalidIgnorePatterns',
defaultMessage: 'Invalid ignore patterns: {values}',
},
{ values: invalidIgnorePatterns.join(', ') }
);
}
const invalidExtensionFilters = parsedExtensionFilters.filter(
(entry) => !FILTER_ENTRY_PATTERN.test(entry)
);
if (invalidExtensionFilters.length > 0) {
nextErrors.extensionFilters = formatMessage(
{
id: 'codexlens.advanced.validation.invalidExtensionFilters',
defaultMessage: 'Invalid file filters: {values}',
},
{ values: invalidExtensionFilters.join(', ') }
);
}
setErrors((prev) => ({
...prev,
ignorePatterns: nextErrors.ignorePatterns,
extensionFilters: nextErrors.extensionFilters,
}));
return Object.values(nextErrors).every((value) => !value);
};
const handleSave = async () => {
if (!validateForm()) {
return;
@@ -138,12 +264,68 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
const handleReset = () => {
// Reset to current raw value (handle undefined as empty)
setEnvInput(raw ?? '');
setErrors({});
setErrors((prev) => ({ ...prev, env: undefined }));
setHasChanges(false);
setShowWarning(false);
};
const handleSaveFilters = async () => {
if (!validateFilterForm()) {
return;
}
const parsedIgnorePatterns = parseListEntries(ignorePatternsInput);
const parsedExtensionFilters = parseListEntries(extensionFiltersInput);
try {
const result = await updatePatterns({
patterns: parsedIgnorePatterns,
extensionFilters: parsedExtensionFilters,
});
if (result.success) {
setIgnorePatternsInput((result.patterns ?? parsedIgnorePatterns).join('\n'));
setExtensionFiltersInput((result.extensionFilters ?? parsedExtensionFilters).join('\n'));
setHasFilterChanges(false);
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
await refetchPatterns();
}
} catch {
// Hook-level mutation already reports the error.
}
};
const handleResetFilters = () => {
setIgnorePatternsInput(currentIgnorePatterns.join('\n'));
setExtensionFiltersInput(currentExtensionFilters.join('\n'));
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
setHasFilterChanges(false);
};
const handleRestoreDefaultFilters = () => {
const defaultIgnoreText = defaultIgnorePatterns.join('\n');
const defaultExtensionText = defaultExtensionFilters.join('\n');
setIgnorePatternsInput(defaultIgnoreText);
setExtensionFiltersInput(defaultExtensionText);
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
updateFilterChangeState(defaultIgnoreText, defaultExtensionText);
};
const isLoading = isLoadingEnv;
const isLoadingFilters = isLoadingPatterns;
// Get current env variables as array for display
const currentEnvVars = env
@@ -242,6 +424,204 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
{/* CCW Tools Card */}
<CcwToolsCard />
{/* Index Filters */}
<Card className="p-6">
<div className="flex flex-col gap-3 mb-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({
id: 'codexlens.advanced.indexFilters',
defaultMessage: 'Index Filters',
})}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs">
{formatMessage(
{
id: 'codexlens.advanced.ignorePatternCount',
defaultMessage: 'Directory filters: {count}',
},
{ count: currentIgnorePatterns.length }
)}
</Badge>
<Badge variant="outline" className="text-xs">
{formatMessage(
{
id: 'codexlens.advanced.extensionFilterCount',
defaultMessage: 'File filters: {count}',
},
{ count: currentExtensionFilters.length }
)}
</Badge>
</div>
</div>
<div className="space-y-4">
{patternsError && (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">
{formatMessage({
id: 'codexlens.advanced.filtersLoadError',
defaultMessage: 'Unable to load current filter settings',
})}
</p>
<p className="text-xs text-destructive/80 mt-1">{patternsError.message}</p>
</div>
</div>
</div>
)}
<div className="grid gap-4 xl:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ignore-patterns-input">
{formatMessage({
id: 'codexlens.advanced.ignorePatterns',
defaultMessage: 'Ignored directories / paths',
})}
</Label>
<Textarea
id="ignore-patterns-input"
value={ignorePatternsInput}
onChange={(event) => handleIgnorePatternsChange(event.target.value)}
placeholder={formatMessage({
id: 'codexlens.advanced.ignorePatternsPlaceholder',
defaultMessage: 'dist\nfrontend/dist\ncoverage',
})}
className={cn(
'min-h-[220px] font-mono text-sm',
errors.ignorePatterns && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoadingFilters || isUpdatingPatterns}
/>
{errors.ignorePatterns && (
<p className="text-sm text-destructive">{errors.ignorePatterns}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({
id: 'codexlens.advanced.ignorePatternsHint',
defaultMessage: 'One entry per line. Supports exact names, relative paths, and glob patterns.',
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="extension-filters-input">
{formatMessage({
id: 'codexlens.advanced.extensionFilters',
defaultMessage: 'Skipped files / globs',
})}
</Label>
<Textarea
id="extension-filters-input"
value={extensionFiltersInput}
onChange={(event) => handleExtensionFiltersChange(event.target.value)}
placeholder={formatMessage({
id: 'codexlens.advanced.extensionFiltersPlaceholder',
defaultMessage: '*.min.js\n*.map\npackage-lock.json',
})}
className={cn(
'min-h-[220px] font-mono text-sm',
errors.extensionFilters && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoadingFilters || isUpdatingPatterns}
/>
{errors.extensionFilters && (
<p className="text-sm text-destructive">{errors.extensionFilters}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({
id: 'codexlens.advanced.extensionFiltersHint',
defaultMessage: 'Use this for generated or low-value files that should stay out of the index.',
})}
</p>
</div>
</div>
<div className="grid gap-3 rounded-md border border-border/60 bg-muted/30 p-3 md:grid-cols-2">
<div>
<p className="text-xs font-medium text-foreground">
{formatMessage({
id: 'codexlens.advanced.defaultIgnorePatterns',
defaultMessage: 'Default directory filters',
})}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{defaultIgnorePatterns.slice(0, 6).map((pattern) => (
<Badge key={pattern} variant="secondary" className="font-mono text-xs">
{pattern}
</Badge>
))}
{defaultIgnorePatterns.length > 6 && (
<Badge variant="outline" className="text-xs">
+{defaultIgnorePatterns.length - 6}
</Badge>
)}
</div>
</div>
<div>
<p className="text-xs font-medium text-foreground">
{formatMessage({
id: 'codexlens.advanced.defaultExtensionFilters',
defaultMessage: 'Default file filters',
})}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{defaultExtensionFilters.slice(0, 6).map((pattern) => (
<Badge key={pattern} variant="secondary" className="font-mono text-xs">
{pattern}
</Badge>
))}
{defaultExtensionFilters.length > 6 && (
<Badge variant="outline" className="text-xs">
+{defaultExtensionFilters.length - 6}
</Badge>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleSaveFilters}
disabled={isLoadingFilters || isUpdatingPatterns || !hasFilterChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdatingPatterns && 'animate-spin')} />
{isUpdatingPatterns
? formatMessage({ id: 'codexlens.advanced.saving', defaultMessage: 'Saving...' })
: formatMessage({
id: 'codexlens.advanced.saveFilters',
defaultMessage: 'Save filters',
})
}
</Button>
<Button
variant="outline"
onClick={handleResetFilters}
disabled={isLoadingFilters || !hasFilterChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.advanced.reset', defaultMessage: 'Reset' })}
</Button>
<Button
variant="outline"
onClick={handleRestoreDefaultFilters}
disabled={isLoadingFilters || isUpdatingPatterns}
>
{formatMessage({
id: 'codexlens.advanced.restoreDefaults',
defaultMessage: 'Restore defaults',
})}
</Button>
</div>
</div>
</Card>
{/* Environment Variables Editor */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">

View File

@@ -212,7 +212,7 @@ export function McpServerDialog({
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
const { error: showError } = useNotifications();
const { error: showError, success: showSuccess } = useNotifications();
// Fetch templates from backend
const { templates, isLoading: templatesLoading } = useMcpTemplates();
@@ -241,6 +241,10 @@ export function McpServerDialog({
const [saveAsTemplate, setSaveAsTemplate] = useState(false);
const projectConfigType: McpProjectConfigType = configType === 'claude-json' ? 'claude' : 'mcp';
// JSON import mode state
const [inputMode, setInputMode] = useState<'form' | 'json'>('form');
const [jsonInput, setJsonInput] = useState('');
// Helper to detect transport type from server data
const detectTransportType = useCallback((serverData: McpServer | undefined): McpTransportType => {
if (!serverData) return 'stdio';
@@ -249,6 +253,73 @@ export function McpServerDialog({
return 'stdio';
}, []);
// Parse JSON config and populate form
const parseJsonConfig = useCallback(() => {
try {
const config = JSON.parse(jsonInput);
// Detect transport type based on config structure
if (config.url) {
// HTTP transport
setTransportType('http');
// Parse headers
const headers: HttpHeader[] = [];
if (config.headers && typeof config.headers === 'object') {
Object.entries(config.headers).forEach(([name, value], idx) => {
headers.push({
id: `header-${Date.now()}-${idx}`,
name,
value: String(value),
isEnvVar: false,
});
});
}
setFormData(prev => ({
...prev,
url: config.url || '',
headers,
bearerTokenEnvVar: config.bearer_token_env_var || config.bearerTokenEnvVar || '',
}));
} else {
// STDIO transport
setTransportType('stdio');
const args = Array.isArray(config.args) ? config.args : [];
const env = config.env && typeof config.env === 'object' ? config.env : {};
setFormData(prev => ({
...prev,
command: config.command || '',
args,
env,
}));
setArgsInput(args.join(', '));
setEnvInput(
Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
);
}
// Switch to form mode to show parsed data
setInputMode('form');
setErrors({});
showSuccess(
formatMessage({ id: 'mcp.dialog.json.parseSuccess' }),
formatMessage({ id: 'mcp.dialog.json.parseSuccessDesc' })
);
} catch (error) {
setErrors({
name: formatMessage({ id: 'mcp.dialog.json.parseError' }, {
error: error instanceof Error ? error.message : 'Invalid JSON'
})
});
}
}, [jsonInput, formatMessage, showSuccess]);
// Initialize form from server prop (edit mode)
useEffect(() => {
if (server && mode === 'edit') {
@@ -578,9 +649,96 @@ export function McpServerDialog({
</DialogTitle>
</DialogHeader>
{/* Input Mode Switcher - Only in add mode */}
{mode === 'add' && (
<div className="flex gap-2 border-b pb-3">
<Button
type="button"
variant={inputMode === 'form' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMode('form')}
className="flex-1"
>
{formatMessage({ id: 'mcp.dialog.mode.form' })}
</Button>
<Button
type="button"
variant={inputMode === 'json' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMode('json')}
className="flex-1"
>
{formatMessage({ id: 'mcp.dialog.mode.json' })}
</Button>
</div>
)}
<div className="space-y-4">
{/* Template Selector - Only for STDIO */}
{transportType === 'stdio' && (
{/* JSON Input Mode */}
{inputMode === 'json' ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.json.label' })}
</label>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.dialog.json.placeholder' })}
className={cn(
'flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono',
errors.name && 'border-destructive focus-visible:ring-destructive'
)}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.dialog.json.hint' })}
</p>
</div>
{/* Example JSON */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.json.example' })}
</label>
<div className="bg-muted p-3 rounded-md">
<p className="text-xs font-medium mb-2">STDIO:</p>
<pre className="text-xs overflow-x-auto">
{`{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"],
"env": {
"API_KEY": "your-key"
}
}`}
</pre>
<p className="text-xs font-medium mt-3 mb-2">HTTP:</p>
<pre className="text-xs overflow-x-auto">
{`{
"url": "http://localhost:3000",
"headers": {
"Authorization": "Bearer token"
}
}`}
</pre>
</div>
</div>
<Button
type="button"
onClick={parseJsonConfig}
disabled={!jsonInput.trim()}
className="w-full"
>
{formatMessage({ id: 'mcp.dialog.json.parse' })}
</Button>
</div>
) : (
<>
{/* Template Selector - Only for STDIO */}
{transportType === 'stdio' && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.template' })}
@@ -901,6 +1059,8 @@ export function McpServerDialog({
</label>
</div>
)}
</>
)}
</div>
<DialogFooter>

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchMcpServers,
toggleMcpServer,
@@ -26,7 +26,29 @@ function getLastFetchCall(fetchMock: any) {
return calls[calls.length - 1] as [RequestInfo | URL, RequestInit | undefined];
}
const TEST_CSRF_TOKEN = 'test-csrf-token';
function mockFetchWithCsrf(
handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>
) {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (input === '/api/csrf-token') {
return jsonResponse({ csrfToken: TEST_CSRF_TOKEN });
}
return handler(input, init);
});
}
describe('MCP API (frontend ↔ backend contract)', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fetchMcpServers derives lists from /api/mcp-config and computes enabled from disabledMcpServers', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
jsonResponse({
@@ -58,7 +80,8 @@ describe('MCP API (frontend ↔ backend contract)', () => {
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/mcp-config');
expect(result.global.map((s) => s.name).sort()).toEqual(['global1', 'globalDup']);
expect(result.project.map((s) => s.name)).toEqual(['projOnly']);
expect(result.project.map((s) => s.name)).toEqual(['projOnly', 'globalDup', 'entDup']);
expect(result.conflicts.map((c) => c.name)).toEqual(['globalDup']);
const global1 = result.global.find((s) => s.name === 'global1');
expect(global1?.enabled).toBe(false);
@@ -76,9 +99,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
});
it('toggleMcpServer uses /api/mcp-toggle with { projectPath, serverName, enable }', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, _init) => {
const fetchMock = mockFetchWithCsrf(async (input, _init) => {
if (input === '/api/mcp-toggle') {
return jsonResponse({ success: true, serverName: 'global1', enabled: false });
}
@@ -111,7 +132,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
});
it('deleteMcpServer calls the correct backend endpoint for project/global scopes', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const fetchMock = mockFetchWithCsrf(async (input) => {
if (input === '/api/mcp-remove-global-server') {
return jsonResponse({ success: true });
}
@@ -129,9 +150,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
});
it('createMcpServer (project) uses /api/mcp-copy-server and includes serverName + serverConfig', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input) => {
const fetchMock = mockFetchWithCsrf(async (input) => {
if (input === '/api/mcp-copy-server') {
return jsonResponse({ success: true });
}
@@ -181,7 +200,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
});
it('updateMcpServer (global) upserts via /api/mcp-add-global-server', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const fetchMock = mockFetchWithCsrf(async (input) => {
if (input === '/api/mcp-add-global-server') {
return jsonResponse({ success: true });
}
@@ -232,7 +251,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
});
it('crossCliCopy codex->claude copies via /api/mcp-copy-server per server', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const fetchMock = mockFetchWithCsrf(async (input) => {
if (input === '/api/codex-mcp-config') {
return jsonResponse({ servers: { s1: { command: 'node' } }, configPath: 'x', exists: true });
}

View File

@@ -115,6 +115,20 @@
"addHeader": "Add Header"
}
},
"mode": {
"form": "Form Mode",
"json": "JSON Mode"
},
"json": {
"label": "JSON Configuration",
"placeholder": "Paste MCP server JSON configuration...",
"hint": "Paste complete MCP server configuration JSON, then click Parse button",
"example": "Example Format",
"parse": "Parse JSON",
"parseSuccess": "JSON Parsed Successfully",
"parseSuccessDesc": "Configuration has been populated to the form, please review and save",
"parseError": "JSON Parse Error: {error}"
},
"templates": {
"npx-stdio": "NPX STDIO",
"python-stdio": "Python STDIO",

View File

@@ -104,6 +104,20 @@
"addHeader": "添加请求头"
}
},
"mode": {
"form": "表单模式",
"json": "JSON 模式"
},
"json": {
"label": "JSON 配置",
"placeholder": "粘贴 MCP 服务器 JSON 配置...",
"hint": "粘贴完整的 MCP 服务器配置 JSON然后点击解析按钮",
"example": "示例格式",
"parse": "解析 JSON",
"parseSuccess": "JSON 解析成功",
"parseSuccessDesc": "配置已填充到表单中,请检查并保存",
"parseError": "JSON 解析失败:{error}"
},
"templates": {
"npx-stdio": "NPX STDIO",
"python-stdio": "Python STDIO",

View File

@@ -0,0 +1,144 @@
/**
* Integration tests for CodexLens ignore-pattern configuration routes.
*
* Notes:
* - Targets runtime implementation shipped in `ccw/dist`.
* - Calls route handler directly (no HTTP server required).
* - Uses temporary CODEXLENS_DATA_DIR to isolate ~/.codexlens writes.
*/
import { after, before, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CODEXLENS_HOME = mkdtempSync(join(tmpdir(), 'codexlens-ignore-home-'));
const PROJECT_ROOT = mkdtempSync(join(tmpdir(), 'codexlens-ignore-project-'));
const configRoutesUrl = new URL('../../dist/core/routes/codexlens/config-handlers.js', import.meta.url);
configRoutesUrl.searchParams.set('t', String(Date.now()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod: any;
const originalEnv = {
CODEXLENS_DATA_DIR: process.env.CODEXLENS_DATA_DIR,
};
async function callConfigRoute(
initialPath: string,
method: string,
path: string,
body?: unknown,
): Promise<{ handled: boolean; status: number; json: any }> {
const url = new URL(path, 'http://localhost');
let status = 0;
let text = '';
let postPromise: Promise<void> | null = null;
const res = {
writeHead(code: number) {
status = code;
},
end(chunk?: unknown) {
text = chunk === undefined ? '' : String(chunk);
},
};
const handlePostRequest = (_req: unknown, _res: unknown, handler: (parsed: any) => Promise<any>) => {
postPromise = (async () => {
const result = await handler(body ?? {});
const errorValue = result && typeof result === 'object' ? result.error : undefined;
const statusValue = result && typeof result === 'object' ? result.status : undefined;
if (typeof errorValue === 'string' && errorValue.length > 0) {
res.writeHead(typeof statusValue === 'number' ? statusValue : 500);
res.end(JSON.stringify({ error: errorValue }));
return;
}
res.writeHead(200);
res.end(JSON.stringify(result));
})();
};
const handled = await mod.handleCodexLensConfigRoutes({
pathname: url.pathname,
url,
req: { method },
res,
initialPath,
handlePostRequest,
broadcastToClients() {},
});
if (postPromise) {
await postPromise;
}
return { handled, status, json: text ? JSON.parse(text) : null };
}
describe('codexlens ignore-pattern routes integration', async () => {
before(async () => {
process.env.CODEXLENS_DATA_DIR = CODEXLENS_HOME;
mock.method(console, 'log', () => {});
mock.method(console, 'warn', () => {});
mock.method(console, 'error', () => {});
mod = await import(configRoutesUrl.href);
});
after(() => {
mock.restoreAll();
process.env.CODEXLENS_DATA_DIR = originalEnv.CODEXLENS_DATA_DIR;
rmSync(CODEXLENS_HOME, { recursive: true, force: true });
rmSync(PROJECT_ROOT, { recursive: true, force: true });
});
it('GET /api/codexlens/ignore-patterns returns defaults before config exists', async () => {
const res = await callConfigRoute(PROJECT_ROOT, 'GET', '/api/codexlens/ignore-patterns');
assert.equal(res.handled, true);
assert.equal(res.status, 200);
assert.equal(res.json.success, true);
assert.equal(Array.isArray(res.json.patterns), true);
assert.equal(Array.isArray(res.json.extensionFilters), true);
assert.ok(res.json.patterns.includes('dist'));
assert.ok(res.json.extensionFilters.includes('*.min.js'));
});
it('POST /api/codexlens/ignore-patterns persists custom patterns and filters', async () => {
const saveRes = await callConfigRoute(PROJECT_ROOT, 'POST', '/api/codexlens/ignore-patterns', {
patterns: ['dist', 'frontend/dist'],
extensionFilters: ['*.min.js', 'frontend/skip.ts'],
});
assert.equal(saveRes.handled, true);
assert.equal(saveRes.status, 200);
assert.equal(saveRes.json.success, true);
assert.deepEqual(saveRes.json.patterns, ['dist', 'frontend/dist']);
assert.deepEqual(saveRes.json.extensionFilters, ['*.min.js', 'frontend/skip.ts']);
const settingsPath = join(CODEXLENS_HOME, 'settings.json');
assert.equal(existsSync(settingsPath), true);
const savedSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
assert.deepEqual(savedSettings.ignore_patterns, ['dist', 'frontend/dist']);
assert.deepEqual(savedSettings.extension_filters, ['*.min.js', 'frontend/skip.ts']);
const getRes = await callConfigRoute(PROJECT_ROOT, 'GET', '/api/codexlens/ignore-patterns');
assert.equal(getRes.status, 200);
assert.deepEqual(getRes.json.patterns, ['dist', 'frontend/dist']);
assert.deepEqual(getRes.json.extensionFilters, ['*.min.js', 'frontend/skip.ts']);
});
it('POST /api/codexlens/ignore-patterns rejects invalid entries', async () => {
const res = await callConfigRoute(PROJECT_ROOT, 'POST', '/api/codexlens/ignore-patterns', {
patterns: ['bad pattern!'],
});
assert.equal(res.handled, true);
assert.equal(res.status, 400);
assert.match(String(res.json.error), /Invalid patterns:/);
});
});

View File

@@ -0,0 +1,10 @@
{
"ignore_patterns": [
"dist",
"frontend/dist"
],
"extension_filters": [
"*.min.js",
"frontend/skip.ts"
]
}