feat: add MCP server for semantic code search with FastMCP integration

This commit is contained in:
catlog22
2026-03-17 23:03:20 +08:00
parent ef2c5a58e1
commit ad9d3f94e0
80 changed files with 3427 additions and 21329 deletions

View File

@@ -1,154 +0,0 @@
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 unknown 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

@@ -1,704 +0,0 @@
// ========================================
// CodexLens Advanced Tab
// ========================================
// Advanced settings including .env editor and ignore patterns
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
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,
useCodexLensIgnorePatterns,
useUpdateCodexLensEnv,
useUpdateIgnorePatterns,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { CcwToolsCard } from './CcwToolsCard';
interface AdvancedTabProps {
enabled?: boolean;
}
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) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
raw,
env,
settings,
isLoading: isLoadingEnv,
error: envError,
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(() => {
// Initialize when data is loaded (raw may be empty string but not undefined during loading)
// Note: During initial load, raw is undefined. After load completes, raw is set (even if empty string)
if (!isLoadingEnv) {
setEnvInput(raw ?? ''); // Use empty string if raw is undefined/null
setErrors({});
setHasChanges(false);
setShowWarning(false);
}
}, [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)
const currentRaw = raw ?? '';
setHasChanges(value !== currentRaw);
setShowWarning(value !== currentRaw);
if (errors.env) {
setErrors((prev) => ({ ...prev, env: undefined }));
}
};
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');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
const [key, ...valParts] = trimmed.split('=');
const val = valParts.join('=');
if (key) {
envObj[key.trim()] = val.trim();
}
}
}
return envObj;
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const parsed = parseEnvVariables(envInput);
// Check for invalid variable names
const invalidKeys = Object.keys(parsed).filter(
(key) => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)
);
if (invalidKeys.length > 0) {
newErrors.env = formatMessage(
{ id: 'codexlens.advanced.validation.invalidKeys' },
{ keys: invalidKeys.join(', ') }
);
}
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;
}
try {
const parsed = parseEnvVariables(envInput);
const result = await updateEnv({ env: parsed });
if (result.success) {
success(
formatMessage({ id: 'codexlens.advanced.saveSuccess' }),
result.message || formatMessage({ id: 'codexlens.advanced.envUpdated' })
);
refetch();
setShowWarning(false);
} else {
showError(
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
result.message || formatMessage({ id: 'codexlens.advanced.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.advanced.unknownError' })
);
}
};
const handleReset = () => {
// Reset to current raw value (handle undefined as empty)
setEnvInput(raw ?? '');
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
? Object.entries(env).map(([key, value]) => ({ key, value }))
: [];
// Get settings variables
const settingsVars = settings
? Object.entries(settings).map(([key, value]) => ({ key, value }))
: [];
return (
<div className="space-y-6">
{/* Error Card */}
{envError && (
<Card className="p-4 bg-destructive/10 border-destructive/20">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-destructive-foreground">
{formatMessage({ id: 'codexlens.advanced.loadError' })}
</h4>
<p className="text-xs text-destructive-foreground/80 mt-1">
{envError.message || formatMessage({ id: 'codexlens.advanced.loadErrorDesc' })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="mt-2"
>
<RefreshCw className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
</div>
</Card>
)}
{/* Sensitivity Warning Card */}
{showWarning && (
<Card className="p-4 bg-warning/10 border-warning/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-warning-foreground">
{formatMessage({ id: 'codexlens.advanced.warningTitle' })}
</h4>
<p className="text-xs text-warning-foreground/80 mt-1">
{formatMessage({ id: 'codexlens.advanced.warningMessage' })}
</p>
</div>
</div>
</Card>
)}
{/* Current Variables Summary */}
{(currentEnvVars.length > 0 || settingsVars.length > 0) && (
<Card className="p-4 bg-muted/30">
<h4 className="text-sm font-medium text-foreground mb-3">
{formatMessage({ id: 'codexlens.advanced.currentVars' })}
</h4>
<div className="space-y-3">
{settingsVars.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.settingsVars' })}
</p>
<div className="flex flex-wrap gap-2">
{settingsVars.map(({ key }) => (
<Badge key={key} variant="outline" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
</div>
)}
{currentEnvVars.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.customVars' })}
</p>
<div className="flex flex-wrap gap-2">
{currentEnvVars.map(({ key }) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* 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">
<div className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'codexlens.advanced.envEditor' })}
</h3>
</div>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.advanced.envFile' })}: .env
</Badge>
</div>
<div className="space-y-4">
{/* Env Textarea */}
<div className="space-y-2">
<Label htmlFor="env-input">
{formatMessage({ id: 'codexlens.advanced.envContent' })}
</Label>
<Textarea
id="env-input"
value={envInput}
onChange={(e) => handleEnvChange(e.target.value)}
placeholder={formatMessage({ id: 'codexlens.advanced.envPlaceholder' })}
className={cn(
'min-h-[300px] font-mono text-sm',
errors.env && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoading}
/>
{errors.env && (
<p className="text-sm text-destructive">{errors.env}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.advanced.envHint' })}
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
{isUpdating
? formatMessage({ id: 'codexlens.advanced.saving' })
: formatMessage({ id: 'codexlens.advanced.save' })
}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.advanced.reset' })}
</Button>
</div>
</div>
</Card>
{/* Help Card */}
<Card className="p-4 bg-info/10 border-info/20">
<h4 className="text-sm font-medium text-info-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.helpTitle' })}
</h4>
<ul className="text-xs text-info-foreground/80 space-y-1">
<li> {formatMessage({ id: 'codexlens.advanced.helpComment' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpFormat' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpQuotes' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpRestart' })}</li>
</ul>
</Card>
</div>
);
}
export default AdvancedTab;

View File

@@ -1,153 +0,0 @@
// ========================================
// CCW Tools Card Component
// ========================================
// Displays all registered CCW tools, highlighting codex-lens related tools
import { useIntl } from 'react-intl';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useCcwToolsList } from '@/hooks';
import type { CcwToolInfo } from '@/lib/api';
const CODEX_LENS_PREFIX = 'codex_lens';
function isCodexLensTool(tool: CcwToolInfo): boolean {
return tool.name.startsWith(CODEX_LENS_PREFIX);
}
export function CcwToolsCard() {
const { formatMessage } = useIntl();
const { tools, isLoading, error } = useCcwToolsList();
const codexLensTools = tools.filter(isCodexLensTool);
const otherTools = tools.filter((t) => !isCodexLensTool(t));
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{formatMessage({ id: 'codexlens.mcp.loading' })}</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="border-destructive/20">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">
{formatMessage({ id: 'codexlens.mcp.error' })}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'codexlens.mcp.errorDesc' })}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
if (tools.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.mcp.emptyDesc' })}
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</div>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.mcp.totalCount' }, { count: tools.length })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* CodexLens Tools Section */}
{codexLensTools.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
{formatMessage({ id: 'codexlens.mcp.codexLensSection' })}
</p>
<div className="space-y-1.5">
{codexLensTools.map((tool) => (
<ToolRow key={tool.name} tool={tool} variant="default" />
))}
</div>
</div>
)}
{/* Other Tools Section */}
{otherTools.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
{formatMessage({ id: 'codexlens.mcp.otherSection' })}
</p>
<div className="space-y-1.5">
{otherTools.map((tool) => (
<ToolRow key={tool.name} tool={tool} variant="secondary" />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface ToolRowProps {
tool: CcwToolInfo;
variant: 'default' | 'secondary';
}
function ToolRow({ tool, variant }: ToolRowProps) {
return (
<div className="flex items-center gap-2 py-1 px-2 rounded-md hover:bg-muted/50 transition-colors">
<Badge variant={variant} className="text-xs font-mono shrink-0">
{tool.name}
</Badge>
<span className="text-xs text-muted-foreground truncate" title={tool.description}>
{tool.description}
</span>
</div>
);
}
export default CcwToolsCard;

View File

@@ -1,135 +0,0 @@
// ========================================
// CodexLens File Watcher Card
// ========================================
// Displays file watcher status, stats, and toggle control
import { useIntl } from 'react-intl';
import {
Eye,
EyeOff,
Activity,
Clock,
FolderOpen,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useCodexLensWatcher, useCodexLensWatcherMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
interface FileWatcherCardProps {
disabled?: boolean;
}
export function FileWatcherCard({ disabled = false }: FileWatcherCardProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { running, rootPath, eventsProcessed, uptimeSeconds, isLoading } = useCodexLensWatcher();
const { startWatcher, stopWatcher, isStarting, isStopping } = useCodexLensWatcherMutations();
const isMutating = isStarting || isStopping;
const handleToggle = async () => {
if (running) {
await stopWatcher();
} else {
await startWatcher(projectPath);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.watcher.title' })}</span>
</div>
<Badge variant={running ? 'success' : 'secondary'}>
{running
? formatMessage({ id: 'codexlens.watcher.status.running' })
: formatMessage({ id: 'codexlens.watcher.status.stopped' })
}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-sm">
<Activity className={cn('w-4 h-4', running ? 'text-success' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.watcher.eventsProcessed' })}
</p>
<p className="font-semibold text-foreground">{eventsProcessed}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className={cn('w-4 h-4', running ? 'text-info' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.watcher.uptime' })}
</p>
<p className="font-semibold text-foreground">
{running ? formatUptime(uptimeSeconds) : '--'}
</p>
</div>
</div>
</div>
{/* Watched Path */}
{running && rootPath && (
<div className="flex items-center gap-2 text-sm">
<FolderOpen className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground truncate" title={rootPath}>
{rootPath}
</span>
</div>
)}
{/* Toggle Button */}
<Button
variant={running ? 'outline' : 'default'}
size="sm"
className="w-full"
onClick={handleToggle}
disabled={disabled || isMutating || isLoading}
>
{running ? (
<>
<EyeOff className="w-4 h-4 mr-2" />
{isStopping
? formatMessage({ id: 'codexlens.watcher.stopping' })
: formatMessage({ id: 'codexlens.watcher.stop' })
}
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
{isStarting
? formatMessage({ id: 'codexlens.watcher.starting' })
: formatMessage({ id: 'codexlens.watcher.start' })
}
</>
)}
</Button>
</CardContent>
</Card>
);
}
export default FileWatcherCard;

View File

@@ -1,293 +0,0 @@
// ========================================
// CodexLens GPU Selector
// ========================================
// GPU detection, listing, and selection component
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Cpu, Search, Check, X, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useCodexLensGpu, useSelectGpu } from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import type { CodexLensGpuDevice } from '@/lib/api';
interface GpuSelectorProps {
enabled?: boolean;
compact?: boolean;
}
export function GpuSelector({ enabled = true, compact = false }: GpuSelectorProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
supported,
devices,
selectedDeviceId,
isLoadingDetect,
isLoadingList,
refetch,
} = useCodexLensGpu({ enabled });
const { selectGpu, resetGpu, isSelecting, isResetting } = useSelectGpu();
const [isDetecting, setIsDetecting] = useState(false);
const isLoading = isLoadingDetect || isLoadingList || isDetecting;
const handleDetect = async () => {
setIsDetecting(true);
try {
await refetch();
success(
formatMessage({ id: 'codexlens.gpu.detectSuccess' }),
formatMessage({ id: 'codexlens.gpu.detectComplete' }, { count: devices?.length ?? 0 })
);
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.detectFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.detectError' })
);
} finally {
setIsDetecting(false);
}
};
const handleSelect = async (deviceId: string | number) => {
try {
const result = await selectGpu(deviceId);
if (result.success) {
success(
formatMessage({ id: 'codexlens.gpu.selectSuccess' }),
result.message || formatMessage({ id: 'codexlens.gpu.gpuSelected' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
result.message || formatMessage({ id: 'codexlens.gpu.selectError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
);
}
};
const handleReset = async () => {
try {
const result = await resetGpu();
if (result.success) {
success(
formatMessage({ id: 'codexlens.gpu.resetSuccess' }),
result.message || formatMessage({ id: 'codexlens.gpu.gpuReset' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
result.message || formatMessage({ id: 'codexlens.gpu.resetError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
);
}
};
if (compact) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.status' })}:
</span>
{supported !== false ? (
selectedDeviceId !== undefined ? (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.enabled' })}
</Badge>
) : (
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.available' })}
</Badge>
)
) : (
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.unavailable' })}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDetect}
disabled={isLoading}
>
<Search className={cn('w-3 h-3 mr-1', isLoading && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.detect' })}
</Button>
{selectedDeviceId !== undefined && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
>
<X className="w-3 h-3 mr-1" />
{formatMessage({ id: 'codexlens.gpu.reset' })}
</Button>
)}
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{/* Header Card */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
supported !== false ? 'bg-success/10' : 'bg-secondary'
)}>
<Cpu className={cn(
'w-5 h-5',
supported !== false ? 'text-success' : 'text-muted-foreground'
)} />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'codexlens.gpu.title' })}
</h4>
<p className="text-xs text-muted-foreground">
{supported !== false
? formatMessage({ id: 'codexlens.gpu.supported' })
: formatMessage({ id: 'codexlens.gpu.notSupported' })
}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDetect}
disabled={isLoading}
>
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.detect' })}
</Button>
{selectedDeviceId !== undefined && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
>
<RefreshCw className={cn('w-4 h-4 mr-2', isResetting && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.reset' })}
</Button>
)}
</div>
</div>
</Card>
{/* Device List */}
{devices && devices.length > 0 ? (
<div className="space-y-2">
{devices.map((device) => {
const deviceId = device.device_id ?? device.index;
return (
<DeviceCard
key={deviceId}
device={device}
isSelected={deviceId === selectedDeviceId}
onSelect={() => handleSelect(deviceId)}
isSelecting={isSelecting}
/>
);
})}
</div>
) : (
<Card className="p-8 text-center">
<Cpu className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
{supported !== false
? formatMessage({ id: 'codexlens.gpu.noDevices' })
: formatMessage({ id: 'codexlens.gpu.notAvailable' })
}
</p>
</Card>
)}
</div>
);
}
interface DeviceCardProps {
device: CodexLensGpuDevice;
isSelected: boolean;
onSelect: () => void;
isSelecting: boolean;
}
function DeviceCard({ device, isSelected, onSelect, isSelecting }: DeviceCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn(
'p-4 transition-colors',
isSelected && 'border-primary bg-primary/5'
)}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-foreground">
{device.name || formatMessage({ id: 'codexlens.gpu.unknownDevice' })}
</h5>
{isSelected && (
<Badge variant="success" className="text-xs">
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'codexlens.gpu.selected' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.type' })}: {formatMessage({ id: device.type === 'discrete' ? 'codexlens.gpu.discrete' : 'codexlens.gpu.integrated' })}
</p>
{device.memory?.total && (
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.memory' })}: {(device.memory.total / 1024).toFixed(1)} GB
</p>
)}
</div>
<Button
variant={isSelected ? 'outline' : 'default'}
size="sm"
onClick={onSelect}
disabled={isSelected || isSelecting}
>
{isSelected
? formatMessage({ id: 'codexlens.gpu.active' })
: formatMessage({ id: 'codexlens.gpu.select' })
}
</Button>
</div>
</Card>
);
}
export default GpuSelector;

View File

@@ -1,286 +0,0 @@
// ========================================
// CodexLens Index Operations Component
// ========================================
// Index management operations with progress tracking
import { useIntl } from 'react-intl';
import { useEffect, useState } from 'react';
import {
RotateCw,
Zap,
AlertCircle,
CheckCircle2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
useCodexLensIndexingStatus,
useRebuildIndex,
useUpdateIndex,
useCancelIndexing,
} from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { useWebSocket } from '@/hooks/useWebSocket';
interface IndexOperationsProps {
disabled?: boolean;
onRefresh?: () => void;
}
interface IndexProgress {
stage: string;
message: string;
percent: number;
path?: string;
}
type IndexOperation = {
id: string;
type: 'fts_full' | 'fts_incremental' | 'vector_full' | 'vector_incremental';
label: string;
description: string;
icon: React.ReactNode;
};
export function IndexOperations({ disabled = false, onRefresh }: IndexOperationsProps) {
const { formatMessage } = useIntl();
const { success, error: showError, wsLastMessage } = useNotifications();
const projectPath = useWorkflowStore(selectProjectPath);
const { inProgress } = useCodexLensIndexingStatus();
const { rebuildIndex, isRebuilding } = useRebuildIndex();
const { updateIndex, isUpdating } = useUpdateIndex();
const { cancelIndexing, isCancelling } = useCancelIndexing();
useWebSocket();
const [indexProgress, setIndexProgress] = useState<IndexProgress | null>(null);
const [activeOperation, setActiveOperation] = useState<string | null>(null);
// Listen for WebSocket progress updates
useEffect(() => {
if (wsLastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') {
const progress = wsLastMessage.payload as IndexProgress;
setIndexProgress(progress);
// Clear active operation when complete or error
if (progress.stage === 'complete' || progress.stage === 'error' || progress.stage === 'cancelled') {
if (progress.stage === 'complete') {
success(
formatMessage({ id: 'codexlens.index.operationComplete' }),
progress.message
);
onRefresh?.();
} else if (progress.stage === 'error') {
showError(
formatMessage({ id: 'codexlens.index.operationFailed' }),
progress.message
);
}
setActiveOperation(null);
setIndexProgress(null);
}
}
}, [wsLastMessage, formatMessage, success, showError, onRefresh]);
const isOperating = isRebuilding || isUpdating || inProgress || !!activeOperation;
const handleOperation = async (operation: IndexOperation) => {
if (!projectPath) {
showError(
formatMessage({ id: 'codexlens.index.noProject' }),
formatMessage({ id: 'codexlens.index.noProjectDesc' })
);
return;
}
setActiveOperation(operation.id);
setIndexProgress({ stage: 'start', message: formatMessage({ id: 'codexlens.index.starting' }), percent: 0 });
try {
// Determine index type and operation
const isVector = operation.type.includes('vector');
const isIncremental = operation.type.includes('incremental');
if (isIncremental) {
const result = await updateIndex(projectPath, {
indexType: isVector ? 'vector' : 'normal',
});
if (!result.success) {
throw new Error(result.error || 'Update failed');
}
} else {
const result = await rebuildIndex(projectPath, {
indexType: isVector ? 'vector' : 'normal',
});
if (!result.success) {
throw new Error(result.error || 'Rebuild failed');
}
}
} catch (err) {
setActiveOperation(null);
setIndexProgress(null);
showError(
formatMessage({ id: 'codexlens.index.operationFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.index.unknownError' })
);
}
};
const handleCancel = async () => {
const result = await cancelIndexing();
if (result.success) {
setActiveOperation(null);
setIndexProgress(null);
} else {
showError(
formatMessage({ id: 'codexlens.index.cancelFailed' }),
result.error || formatMessage({ id: 'codexlens.index.unknownError' })
);
}
};
const operations: IndexOperation[] = [
{
id: 'fts_full',
type: 'fts_full',
label: formatMessage({ id: 'codexlens.overview.actions.ftsFull' }),
description: formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' }),
icon: <RotateCw className="w-4 h-4" />,
},
{
id: 'fts_incremental',
type: 'fts_incremental',
label: formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' }),
description: formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' }),
icon: <Zap className="w-4 h-4" />,
},
{
id: 'vector_full',
type: 'vector_full',
label: formatMessage({ id: 'codexlens.overview.actions.vectorFull' }),
description: formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' }),
icon: <RotateCw className="w-4 h-4" />,
},
{
id: 'vector_incremental',
type: 'vector_incremental',
label: formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' }),
description: formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' }),
icon: <Zap className="w-4 h-4" />,
},
];
if (indexProgress && activeOperation) {
const operation = operations.find((op) => op.id === activeOperation);
const isComplete = indexProgress.stage === 'complete';
const isError = indexProgress.stage === 'error';
const isCancelled = indexProgress.stage === 'cancelled';
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<span>{operation?.label}</span>
{!isComplete && !isError && !isCancelled && (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
>
<X className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status Icon */}
<div className="flex items-center gap-3">
{isComplete ? (
<CheckCircle2 className="w-6 h-6 text-success" />
) : isError || isCancelled ? (
<AlertCircle className="w-6 h-6 text-destructive" />
) : (
<RotateCw className="w-6 h-6 text-primary animate-spin" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{isComplete
? formatMessage({ id: 'codexlens.index.complete' })
: isError
? formatMessage({ id: 'codexlens.index.failed' })
: isCancelled
? formatMessage({ id: 'codexlens.index.cancelled' })
: formatMessage({ id: 'codexlens.index.inProgress' })}
</p>
<p className="text-xs text-muted-foreground mt-1">{indexProgress.message}</p>
</div>
</div>
{/* Progress Bar */}
{!isComplete && !isError && !isCancelled && (
<div className="space-y-2">
<Progress value={indexProgress.percent} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{indexProgress.percent}%
</p>
</div>
)}
{/* Close Button */}
{(isComplete || isError || isCancelled) && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setActiveOperation(null);
setIndexProgress(null);
}}
>
{formatMessage({ id: 'common.actions.close' })}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.actions.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{operations.map((operation) => (
<Button
key={operation.id}
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
onClick={() => handleOperation(operation)}
disabled={disabled || isOperating}
>
<div className="flex items-center gap-2 w-full">
<span className={cn('text-muted-foreground', (disabled || isOperating) && 'opacity-50')}>
{operation.icon}
</span>
<span className="font-medium">{operation.label}</span>
</div>
<p className="text-xs text-muted-foreground">{operation.description}</p>
</Button>
))}
</div>
</CardContent>
</Card>
);
}
export default IndexOperations;

View File

@@ -1,256 +0,0 @@
// ========================================
// CodexLens Install Progress Overlay
// ========================================
// Dialog overlay showing 5-stage simulated progress during CodexLens bootstrap installation
import { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
Check,
Download,
Info,
Loader2,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { Card, CardContent } from '@/components/ui/Card';
// ----------------------------------------
// Types
// ----------------------------------------
interface InstallStage {
progress: number;
messageId: string;
}
interface InstallProgressOverlayProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: () => Promise<{ success: boolean }>;
onSuccess?: () => void;
}
// ----------------------------------------
// Constants
// ----------------------------------------
const INSTALL_STAGES: InstallStage[] = [
{ progress: 10, messageId: 'codexlens.install.stage.creatingVenv' },
{ progress: 30, messageId: 'codexlens.install.stage.installingPip' },
{ progress: 50, messageId: 'codexlens.install.stage.installingPackage' },
{ progress: 70, messageId: 'codexlens.install.stage.settingUpDeps' },
{ progress: 90, messageId: 'codexlens.install.stage.finalizing' },
];
const STAGE_INTERVAL_MS = 1500;
// ----------------------------------------
// Checklist items
// ----------------------------------------
interface ChecklistItem {
labelId: string;
descId: string;
}
const CHECKLIST_ITEMS: ChecklistItem[] = [
{ labelId: 'codexlens.install.pythonVenv', descId: 'codexlens.install.pythonVenvDesc' },
{ labelId: 'codexlens.install.codexlensPackage', descId: 'codexlens.install.codexlensPackageDesc' },
{ labelId: 'codexlens.install.sqliteFts', descId: 'codexlens.install.sqliteFtsDesc' },
];
// ----------------------------------------
// Component
// ----------------------------------------
export function InstallProgressOverlay({
open,
onOpenChange,
onInstall,
onSuccess,
}: InstallProgressOverlayProps) {
const { formatMessage } = useIntl();
const [isInstalling, setIsInstalling] = useState(false);
const [progress, setProgress] = useState(0);
const [stageText, setStageText] = useState('');
const [isComplete, setIsComplete] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearStageInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setIsInstalling(false);
setProgress(0);
setStageText('');
setIsComplete(false);
clearStageInterval();
}
}, [open, clearStageInterval]);
// Cleanup on unmount
useEffect(() => {
return () => clearStageInterval();
}, [clearStageInterval]);
const handleInstall = async () => {
setIsInstalling(true);
setProgress(0);
setIsComplete(false);
// Start stage simulation
let currentStage = 0;
setStageText(formatMessage({ id: INSTALL_STAGES[0].messageId }));
setProgress(INSTALL_STAGES[0].progress);
currentStage = 1;
intervalRef.current = setInterval(() => {
if (currentStage < INSTALL_STAGES.length) {
setStageText(formatMessage({ id: INSTALL_STAGES[currentStage].messageId }));
setProgress(INSTALL_STAGES[currentStage].progress);
currentStage++;
}
}, STAGE_INTERVAL_MS);
try {
const result = await onInstall();
clearStageInterval();
if (result.success) {
setProgress(100);
setStageText(formatMessage({ id: 'codexlens.install.stage.complete' }));
setIsComplete(true);
// Auto-close after showing completion
setTimeout(() => {
onOpenChange(false);
onSuccess?.();
}, 1200);
} else {
setIsInstalling(false);
setProgress(0);
setStageText('');
}
} catch {
clearStageInterval();
setIsInstalling(false);
setProgress(0);
setStageText('');
}
};
return (
<Dialog open={open} onOpenChange={isInstalling ? undefined : onOpenChange}>
<DialogContent className="max-w-lg" onPointerDownOutside={isInstalling ? (e) => e.preventDefault() : undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.install.title' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'codexlens.install.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Install Checklist */}
<div>
<h4 className="text-sm font-medium mb-2">
{formatMessage({ id: 'codexlens.install.checklist' })}
</h4>
<ul className="space-y-2">
{CHECKLIST_ITEMS.map((item) => (
<li key={item.labelId} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>
<strong>{formatMessage({ id: item.labelId })}</strong>
{' - '}
{formatMessage({ id: item.descId })}
</span>
</li>
))}
</ul>
</div>
{/* Install Location Info */}
<Card className="bg-primary/5 border-primary/20">
<CardContent className="p-3 flex items-start gap-2">
<Info className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">
{formatMessage({ id: 'codexlens.install.location' })}
</p>
<p className="mt-1">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
{formatMessage({ id: 'codexlens.install.locationPath' })}
</code>
</p>
<p className="mt-1">
{formatMessage({ id: 'codexlens.install.timeEstimate' })}
</p>
</div>
</CardContent>
</Card>
{/* Progress Section - shown during install */}
{isInstalling && (
<div className="space-y-2">
<div className="flex items-center gap-3">
{isComplete ? (
<Check className="w-5 h-5 text-green-500" />
) : (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
<span className="text-sm">{stageText}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isInstalling}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleInstall}
disabled={isInstalling}
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'codexlens.install.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.install.installNow' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default InstallProgressOverlay;

View File

@@ -1,157 +0,0 @@
// ========================================
// CodexLens LSP Server Card
// ========================================
// Displays LSP server status, stats, and start/stop/restart controls
import { useIntl } from 'react-intl';
import {
Server,
Power,
PowerOff,
RotateCw,
FolderOpen,
Layers,
Cpu,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useCodexLensLspStatus, useCodexLensLspMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
interface LspServerCardProps {
disabled?: boolean;
}
export function LspServerCard({ disabled = false }: LspServerCardProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const {
available,
semanticAvailable,
projectCount,
modes,
isLoading,
} = useCodexLensLspStatus();
const { startLsp, stopLsp, restartLsp, isStarting, isStopping, isRestarting } = useCodexLensLspMutations();
const isMutating = isStarting || isStopping || isRestarting;
const handleToggle = async () => {
if (available) {
await stopLsp(projectPath);
} else {
await startLsp(projectPath);
}
};
const handleRestart = async () => {
await restartLsp(projectPath);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.lsp.title' })}</span>
</div>
<Badge variant={available ? 'success' : 'secondary'}>
{available
? formatMessage({ id: 'codexlens.lsp.status.running' })
: formatMessage({ id: 'codexlens.lsp.status.stopped' })
}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3">
<div className="flex items-center gap-2 text-sm">
<FolderOpen className={cn('w-4 h-4', available ? 'text-success' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.projects' })}
</p>
<p className="font-semibold text-foreground">
{available ? projectCount : '--'}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Cpu className={cn('w-4 h-4', semanticAvailable ? 'text-info' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.semanticAvailable' })}
</p>
<p className="font-semibold text-foreground">
{semanticAvailable
? formatMessage({ id: 'codexlens.lsp.available' })
: formatMessage({ id: 'codexlens.lsp.unavailable' })
}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Layers className={cn('w-4 h-4', available && modes.length > 0 ? 'text-accent' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.modes' })}
</p>
<p className="font-semibold text-foreground">
{available ? modes.length : '--'}
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant={available ? 'outline' : 'default'}
size="sm"
className="flex-1"
onClick={handleToggle}
disabled={disabled || isMutating || isLoading}
>
{available ? (
<>
<PowerOff className="w-4 h-4 mr-2" />
{isStopping
? formatMessage({ id: 'codexlens.lsp.stopping' })
: formatMessage({ id: 'codexlens.lsp.stop' })
}
</>
) : (
<>
<Power className="w-4 h-4 mr-2" />
{isStarting
? formatMessage({ id: 'codexlens.lsp.starting' })
: formatMessage({ id: 'codexlens.lsp.start' })
}
</>
)}
</Button>
{available && (
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={disabled || isMutating || isLoading}
>
<RotateCw className={cn('w-4 h-4 mr-2', isRestarting && 'animate-spin')} />
{isRestarting
? formatMessage({ id: 'codexlens.lsp.restarting' })
: formatMessage({ id: 'codexlens.lsp.restart' })
}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
export default LspServerCard;

View File

@@ -1,234 +0,0 @@
// ========================================
// Model Card Component
// ========================================
// Individual model display card with actions
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Download,
Trash2,
Package,
HardDrive,
X,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import { Input } from '@/components/ui/Input';
import type { CodexLensModel } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface ModelCardProps {
model: CodexLensModel;
isDownloading?: boolean;
downloadProgress?: number;
isDeleting?: boolean;
onDownload: (profile: string) => void;
onDelete: (profile: string) => void;
onCancelDownload?: () => void;
}
// ========== Helper Functions ==========
function getModelTypeVariant(type: 'embedding' | 'reranker'): 'default' | 'secondary' {
return type === 'embedding' ? 'default' : 'secondary';
}
function formatSize(size?: string): string {
if (!size) return '-';
return size;
}
// ========== Component ==========
export function ModelCard({
model,
isDownloading = false,
downloadProgress = 0,
isDeleting = false,
onDownload,
onDelete,
onCancelDownload,
}: ModelCardProps) {
const { formatMessage } = useIntl();
const handleDownload = () => {
onDownload(model.profile);
};
const handleDelete = () => {
if (confirm(formatMessage({ id: 'codexlens.models.deleteConfirm' }, { modelName: model.name }))) {
onDelete(model.profile);
}
};
return (
<Card className={cn('overflow-hidden hover-glow', !model.installed && 'opacity-80')}>
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
'p-2 rounded-lg flex-shrink-0',
model.installed ? 'bg-success/10' : 'bg-muted'
)}>
{model.installed ? (
<HardDrive className="w-4 h-4 text-success" />
) : (
<Package className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{model.name}
</span>
<Badge
variant={getModelTypeVariant(model.type)}
className="text-xs flex-shrink-0"
>
{model.type}
</Badge>
<Badge
variant={model.installed ? 'success' : 'outline'}
className="text-xs flex-shrink-0"
>
{model.installed
? formatMessage({ id: 'codexlens.models.status.downloaded' })
: formatMessage({ id: 'codexlens.models.status.available' })
}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{model.dimensions && <span>{model.dimensions}d</span>}
<span>{formatSize(model.size)}</span>
{model.recommended && (
<Badge variant="success" className="text-[10px] px-1 py-0">Rec</Badge>
)}
</div>
{model.description && (
<p className="text-xs text-muted-foreground mt-1">
{model.description}
</p>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
{isDownloading ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onCancelDownload}
title={formatMessage({ id: 'codexlens.models.actions.cancel' })}
>
<X className="w-4 h-4" />
</Button>
) : model.installed ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDelete}
disabled={isDeleting}
title={formatMessage({ id: 'codexlens.models.actions.delete' })}
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={handleDownload}
title={formatMessage({ id: 'codexlens.models.actions.download' })}
>
<Download className="w-4 h-4 mr-1" />
<span className="text-xs">{formatMessage({ id: 'codexlens.models.actions.download' })}</span>
</Button>
)}
</div>
</div>
{/* Download Progress */}
{isDownloading && (
<div className="mt-3 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.models.downloading' })}
</span>
<span className="font-medium">{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
</div>
)}
</div>
</Card>
);
}
// ========== Custom Model Input ==========
export interface CustomModelInputProps {
isDownloading: boolean;
onDownload: (modelName: string, modelType: 'embedding' | 'reranker') => void;
}
export function CustomModelInput({ isDownloading, onDownload }: CustomModelInputProps) {
const { formatMessage } = useIntl();
const [modelName, setModelName] = useState('');
const [modelType, setModelType] = useState<'embedding' | 'reranker'>('embedding');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (modelName.trim()) {
onDownload(modelName.trim(), modelType);
setModelName('');
}
};
return (
<Card className="p-4 bg-primary/5 border-primary/20">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<Package className="w-4 h-4 text-primary" />
{formatMessage({ id: 'codexlens.models.custom.title' })}
</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex gap-2">
<Input
placeholder={formatMessage({ id: 'codexlens.models.custom.placeholder' })}
value={modelName}
onChange={(e) => setModelName(e.target.value)}
disabled={isDownloading}
className="flex-1"
/>
<select
value={modelType}
onChange={(e) => setModelType(e.target.value as 'embedding' | 'reranker')}
disabled={isDownloading}
className="px-3 py-2 text-sm rounded-md border border-input bg-background"
>
<option value="embedding">{formatMessage({ id: 'codexlens.models.types.embedding' })}</option>
<option value="reranker">{formatMessage({ id: 'codexlens.models.types.reranker' })}</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.models.custom.description' })}
</p>
</form>
</Card>
);
}
export default ModelCard;

View File

@@ -1,195 +0,0 @@
// ========================================
// ModelSelectField Component
// ========================================
// Combobox-style input for selecting models from local + API sources
import { useState, useRef, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EnvVarFieldSchema, ModelGroup } from '@/types/codexlens';
import type { CodexLensModel } from '@/lib/api';
interface ModelSelectFieldProps {
field: EnvVarFieldSchema;
value: string;
onChange: (value: string) => void;
/** Currently loaded local models (installed) */
localModels?: CodexLensModel[];
/** Backend type determines which model list to show */
backendType: 'local' | 'api';
disabled?: boolean;
}
interface ModelOption {
id: string;
label: string;
group: string;
}
export function ModelSelectField({
field,
value,
onChange,
localModels = [],
backendType,
disabled = false,
}: ModelSelectFieldProps) {
const { formatMessage } = useIntl();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [open]);
// Build model options based on backend type
const options = useMemo<ModelOption[]>(() => {
const result: ModelOption[] = [];
if (backendType === 'api') {
// API mode: show preset API models from schema
const apiGroups: ModelGroup[] = field.apiModels || [];
for (const group of apiGroups) {
for (const item of group.items) {
result.push({ id: item, label: item, group: group.group });
}
}
} else {
// Local mode: show installed local models, then preset profiles as fallback
if (localModels.length > 0) {
for (const model of localModels) {
const modelId = model.profile || model.name;
const displayText =
model.profile && model.name && model.profile !== model.name
? `${model.profile} (${model.name})`
: model.name || model.profile;
result.push({
id: modelId,
label: displayText,
group: formatMessage({ id: 'codexlens.downloadedModels', defaultMessage: 'Downloaded Models' }),
});
}
} else {
// Fallback to preset local models from schema
const localGroups: ModelGroup[] = field.localModels || [];
for (const group of localGroups) {
for (const item of group.items) {
result.push({ id: item, label: item, group: group.group });
}
}
}
}
return result;
}, [backendType, field.apiModels, field.localModels, localModels, formatMessage]);
// Filter by search
const filtered = useMemo(() => {
if (!search) return options;
const q = search.toLowerCase();
return options.filter(
(opt) => opt.id.toLowerCase().includes(q) || opt.label.toLowerCase().includes(q)
);
}, [options, search]);
// Group filtered options
const grouped = useMemo(() => {
const groups: Record<string, ModelOption[]> = {};
for (const opt of filtered) {
if (!groups[opt.group]) groups[opt.group] = [];
groups[opt.group].push(opt);
}
return groups;
}, [filtered]);
const handleSelect = (modelId: string) => {
onChange(modelId);
setOpen(false);
setSearch('');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setSearch(val);
onChange(val);
if (!open) setOpen(true);
};
return (
<div ref={containerRef} className="relative flex-1">
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search || value : value}
onChange={handleInputChange}
onFocus={() => {
setOpen(true);
setSearch('');
}}
placeholder={field.placeholder || 'Select model...'}
disabled={disabled}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 pr-8 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'
)}
/>
<ChevronDown
className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
/>
</div>
{open && !disabled && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-card shadow-md">
<div className="max-h-56 overflow-y-auto p-1">
{Object.keys(grouped).length === 0 ? (
<div className="py-3 text-center text-xs text-muted-foreground">
{backendType === 'api'
? formatMessage({ id: 'codexlens.noConfiguredModels', defaultMessage: 'No models configured' })
: formatMessage({ id: 'codexlens.noLocalModels', defaultMessage: 'No models downloaded' })}
</div>
) : (
Object.entries(grouped).map(([group, items]) => (
<div key={group}>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group}
</div>
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleSelect(item.id)}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-xs cursor-pointer',
'hover:bg-accent hover:text-accent-foreground',
value === item.id && 'bg-accent/50'
)}
>
{item.label}
</button>
))}
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default ModelSelectField;

View File

@@ -1,405 +0,0 @@
// ========================================
// Models Tab Component Tests
// ========================================
// Tests for CodexLens Models Tab component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { ModelsTab } from './ModelsTab';
import type { CodexLensModel } from '@/lib/api';
// Mock hooks - use importOriginal to preserve all exports
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensModels: vi.fn(),
useCodexLensMutations: vi.fn(),
};
});
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
const mockModels: CodexLensModel[] = [
{
profile: 'embedding1',
name: 'BAAI/bge-small-en-v1.5',
type: 'embedding',
backend: 'onnx',
installed: true,
cache_path: '/cache/embedding1',
},
{
profile: 'reranker1',
name: 'BAAI/bge-reranker-v2-m3',
type: 'reranker',
backend: 'onnx',
installed: false,
cache_path: '/cache/reranker1',
},
{
profile: 'embedding2',
name: 'sentence-transformers/all-MiniLM-L6-v2',
type: 'embedding',
backend: 'torch',
installed: false,
cache_path: '/cache/embedding2',
},
];
const mockMutations = {
updateConfig: vi.fn().mockResolvedValue({ success: true }) as any,
isUpdatingConfig: false,
bootstrap: vi.fn().mockResolvedValue({ success: true }) as any,
isBootstrapping: false,
installSemantic: vi.fn().mockResolvedValue({ success: true }) as any,
isInstallingSemantic: false,
uninstall: vi.fn().mockResolvedValue({ success: true }) as any,
isUninstalling: false,
downloadModel: vi.fn().mockResolvedValue({ success: true }) as any,
downloadCustomModel: vi.fn().mockResolvedValue({ success: true }) as any,
isDownloading: false,
deleteModel: vi.fn().mockResolvedValue({ success: true }) as any,
deleteModelByPath: vi.fn().mockResolvedValue({ success: true }) as any,
isDeleting: false,
updateEnv: vi.fn().mockResolvedValue({ success: true, env: {}, settings: {}, raw: '' }) as any,
isUpdatingEnv: false,
selectGpu: vi.fn().mockResolvedValue({ success: true }) as any,
resetGpu: vi.fn().mockResolvedValue({ success: true }) as any,
isSelectingGpu: false,
updatePatterns: vi.fn().mockResolvedValue({ patterns: [], extensionFilters: [], defaults: {} }) as any,
isUpdatingPatterns: false,
rebuildIndex: vi.fn().mockResolvedValue({ success: true }) as any,
isRebuildingIndex: false,
updateIndex: vi.fn().mockResolvedValue({ success: true }) as any,
isUpdatingIndex: false,
cancelIndexing: vi.fn().mockResolvedValue({ success: true }) as any,
isCancellingIndexing: false,
isMutating: false,
};
describe('ModelsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render search input', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByPlaceholderText(/Search models/i)).toBeInTheDocument();
});
it('should render filter buttons with counts', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText(/All/)).toBeInTheDocument();
expect(screen.getByText(/Embedding Models/)).toBeInTheDocument();
expect(screen.getByText(/Reranker Models/)).toBeInTheDocument();
expect(screen.getByText(/Downloaded/)).toBeInTheDocument();
expect(screen.getByText(/Available/)).toBeInTheDocument();
});
it('should render model list', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.getByText('sentence-transformers/all-MiniLM-L6-v2')).toBeInTheDocument();
});
it('should filter models by search query', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const searchInput = screen.getByPlaceholderText(/Search models/i);
await user.type(searchInput, 'bge');
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('sentence-transformers/all-MiniLM-L6-v2')).not.toBeInTheDocument();
});
it('should filter by embedding type', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const embeddingButton = screen.getByText(/Embedding Models/i);
await user.click(embeddingButton);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
});
it('should filter by reranker type', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const rerankerButton = screen.getByText(/Reranker Models/i);
await user.click(rerankerButton);
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
});
it('should filter by downloaded status', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const downloadedButton = screen.getByText(/Downloaded/i);
await user.click(downloadedButton);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
});
it('should filter by available status', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const availableButton = screen.getByText(/Available/i);
await user.click(availableButton);
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
});
it('should call downloadModel when download clicked', async () => {
const downloadModel = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
downloadModel,
});
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
// Filter to show available models
const availableButton = screen.getByText(/Available/i);
await user.click(availableButton);
const downloadButton = screen.getAllByText(/Download/i)[0];
await user.click(downloadButton);
await waitFor(() => {
expect(downloadModel).toHaveBeenCalled();
});
});
it('should refresh models on refresh button click', async () => {
const refetch = vi.fn();
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch,
});
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
expect(refetch).toHaveBeenCalledOnce();
});
});
describe('when not installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should show not installed message', () => {
render(<ModelsTab installed={false} />);
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
expect(screen.getByText(/Please install CodexLens to use model management features/i)).toBeInTheDocument();
});
});
describe('loading states', () => {
it('should show loading state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
});
describe('empty states', () => {
it('should show empty state when no models', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
expect(screen.getByText(/Try adjusting your search or filter criteria/i)).toBeInTheDocument();
});
it('should show empty state when search returns no results', async () => {
const user = userEvent.setup();
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
const searchInput = screen.getByPlaceholderText(/Search models/i);
await user.type(searchInput, 'nonexistent-model');
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should display translated text', () => {
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByPlaceholderText(/搜索模型/i)).toBeInTheDocument();
expect(screen.getByText(/筛选/i)).toBeInTheDocument();
expect(screen.getByText(/全部/i)).toBeInTheDocument();
expect(screen.getByText(/嵌入模型/i)).toBeInTheDocument();
expect(screen.getByText(/重排序模型/i)).toBeInTheDocument();
expect(screen.getByText(/已下载/i)).toBeInTheDocument();
expect(screen.getByText(/可用/i)).toBeInTheDocument();
expect(screen.getByText(/刷新/i)).toBeInTheDocument();
});
it('should translate empty state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByText(/没有找到模型/i)).toBeInTheDocument();
expect(screen.getByText(/尝试调整搜索或筛选条件/i)).toBeInTheDocument();
});
it('should translate not installed state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={false} />, { locale: 'zh' });
expect(screen.getByText(/CodexLens 未安装/i)).toBeInTheDocument();
});
});
describe('custom model input', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render custom model input section', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText(/Custom Model/i)).toBeInTheDocument();
});
it('should translate custom model section in Chinese', () => {
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByText(/自定义模型/i)).toBeInTheDocument();
});
});
describe('error handling', () => {
it('should handle API errors gracefully', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: new Error('API Error'),
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
// Component should still render despite error
expect(screen.getByText(/BAAI\/bge-small-en-v1.5/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,309 +0,0 @@
// ========================================
// Models Tab Component
// ========================================
// Model management tab with list, search, and download actions
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Search,
RefreshCw,
Package,
Filter,
AlertCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { ModelCard, CustomModelInput } from './ModelCard';
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
import type { CodexLensModel } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
type FilterType = 'all' | 'embedding' | 'reranker' | 'downloaded' | 'available';
// ========== Helper Functions ==========
function filterModels(models: CodexLensModel[], filter: FilterType, search: string): CodexLensModel[] {
let filtered = models;
// Apply type/status filter
if (filter === 'embedding') {
filtered = filtered.filter(m => m.type === 'embedding');
} else if (filter === 'reranker') {
filtered = filtered.filter(m => m.type === 'reranker');
} else if (filter === 'downloaded') {
filtered = filtered.filter(m => m.installed);
} else if (filter === 'available') {
filtered = filtered.filter(m => !m.installed);
}
// Apply search filter
if (search.trim()) {
const query = search.toLowerCase();
filtered = filtered.filter(m =>
m.name.toLowerCase().includes(query) ||
m.profile.toLowerCase().includes(query) ||
(m.description?.toLowerCase().includes(query) ?? false)
);
}
return filtered;
}
// ========== Component ==========
export interface ModelsTabProps {
installed?: boolean;
}
export function ModelsTab({ installed = false }: ModelsTabProps) {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<FilterType>('all');
const [downloadingProfile, setDownloadingProfile] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState(0);
const {
models,
isLoading,
error,
refetch,
} = useCodexLensModels({
enabled: installed,
});
const {
downloadModel,
downloadCustomModel,
deleteModel,
isDownloading,
isDeleting,
} = useCodexLensMutations();
// Filter models based on search and filter
const filteredModels = useMemo(() => {
if (!models) return [];
return filterModels(models, filterType, searchQuery);
}, [models, filterType, searchQuery]);
// Count models by type and status
const stats = useMemo(() => {
if (!models) return null;
return {
total: models.length,
embedding: models.filter(m => m.type === 'embedding').length,
reranker: models.filter(m => m.type === 'reranker').length,
downloaded: models.filter(m => m.installed).length,
available: models.filter(m => !m.installed).length,
};
}, [models]);
// Handle model download
const handleDownload = async (profile: string) => {
setDownloadingProfile(profile);
setDownloadProgress(0);
// Simulate progress for demo (in real implementation, use WebSocket or polling)
const progressInterval = setInterval(() => {
setDownloadProgress(prev => {
if (prev >= 95) {
clearInterval(progressInterval);
return 95;
}
return prev + 5;
});
}, 500);
try {
const result = await downloadModel(profile);
if (result.success) {
setDownloadProgress(100);
setTimeout(() => {
setDownloadingProfile(null);
setDownloadProgress(0);
refetch();
}, 500);
} else {
setDownloadingProfile(null);
setDownloadProgress(0);
}
} catch (error) {
setDownloadingProfile(null);
setDownloadProgress(0);
} finally {
clearInterval(progressInterval);
}
};
// Handle custom model download
const handleCustomDownload = async (modelName: string, modelType: 'embedding' | 'reranker') => {
try {
const result = await downloadCustomModel(modelName, modelType);
if (result.success) {
refetch();
}
} catch (error) {
console.error('Failed to download custom model:', error);
}
};
// Handle model delete
const handleDelete = async (profile: string) => {
const result = await deleteModel(profile);
if (result.success) {
refetch();
}
};
// Filter buttons
const filterButtons: Array<{ type: FilterType; label: string; count: number | undefined }> = [
{ type: 'all', label: formatMessage({ id: 'codexlens.models.filters.all' }), count: stats?.total },
{ type: 'embedding', label: formatMessage({ id: 'codexlens.models.types.embedding' }), count: stats?.embedding },
{ type: 'reranker', label: formatMessage({ id: 'codexlens.models.types.reranker' }), count: stats?.reranker },
{ type: 'downloaded', label: formatMessage({ id: 'codexlens.models.status.downloaded' }), count: stats?.downloaded },
{ type: 'available', label: formatMessage({ id: 'codexlens.models.status.available' }), count: stats?.available },
];
if (!installed) {
return (
<Card className="p-12 text-center">
<Package className="w-16 h-16 mx-auto text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'codexlens.models.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.models.notInstalled.description' })}
</p>
</Card>
);
}
return (
<div className="space-y-4">
{/* Header with Search and Actions */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'codexlens.models.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={cn('w-4 h-4 mr-1', isLoading && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
</div>
</Card>
{/* Stats and Filters */}
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'codexlens.models.filters.label' })}
</span>
</div>
<div className="flex flex-wrap gap-2">
{filterButtons.map(({ type, label, count }) => (
<Button
key={type}
variant={filterType === type ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterType(type)}
className="relative"
>
{label}
{count !== undefined && (
<Badge variant={filterType === type ? 'secondary' : 'default'} className="ml-2">
{count}
</Badge>
)}
</Button>
))}
</div>
</Card>
{/* Custom Model Input */}
<CustomModelInput
isDownloading={isDownloading}
onDownload={handleCustomDownload}
/>
{/* Model List */}
{error ? (
<Card className="p-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50 mb-3" />
<h3 className="text-sm font-medium text-destructive-foreground mb-1">
{formatMessage({ id: 'codexlens.models.error.title' })}
</h3>
<p className="text-xs text-muted-foreground mb-3">
{error.message || formatMessage({ id: 'codexlens.models.error.description' })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
>
<RefreshCw className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</Card>
) : isLoading ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
</Card>
) : filteredModels.length === 0 ? (
<Card className="p-8 text-center">
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{models && models.length > 0
? formatMessage({ id: 'codexlens.models.empty.filtered' })
: formatMessage({ id: 'codexlens.models.empty.title' })
}
</h3>
<p className="text-xs text-muted-foreground">
{models && models.length > 0
? formatMessage({ id: 'codexlens.models.empty.filteredDesc' })
: formatMessage({ id: 'codexlens.models.empty.description' })
}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredModels.map((model) => (
<ModelCard
key={model.profile}
model={model}
isDownloading={downloadingProfile === model.profile}
downloadProgress={downloadProgress}
isDeleting={isDeleting && downloadingProfile !== model.profile}
onDownload={handleDownload}
onDelete={handleDelete}
onCancelDownload={() => {
setDownloadingProfile(null);
setDownloadProgress(0);
}}
/>
))}
</div>
)}
</div>
);
}
export default ModelsTab;

View File

@@ -1,276 +0,0 @@
// ========================================
// Overview Tab Component Tests
// ========================================
// Tests for CodexLens Overview Tab component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { OverviewTab } from './OverviewTab';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
const mockStatus: CodexLensVenvStatus = {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
};
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
};
// Mock window.alert
global.alert = vi.fn();
describe('OverviewTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when installed and ready', () => {
const defaultProps = {
installed: true,
status: mockStatus,
config: mockConfig,
isLoading: false,
};
it('should render status cards', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
expect(screen.getByText(/Ready/i)).toBeInTheDocument();
expect(screen.getByText(/Version/i)).toBeInTheDocument();
expect(screen.getByText(/1.0.0/i)).toBeInTheDocument();
});
it('should render index path with full path in title', () => {
render(<OverviewTab {...defaultProps} />);
const indexPath = screen.getByText(/Index Path/i).nextElementSibling as HTMLElement;
expect(indexPath).toHaveTextContent('~/.codexlens/indexes');
expect(indexPath).toHaveAttribute('title', '~/.codexlens/indexes');
});
it('should render index count', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Index Count/i)).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('should render quick actions section', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Quick Actions/i)).toBeInTheDocument();
expect(screen.getByText(/FTS Full/i)).toBeInTheDocument();
expect(screen.getByText(/FTS Incremental/i)).toBeInTheDocument();
expect(screen.getByText(/Vector Full/i)).toBeInTheDocument();
expect(screen.getByText(/Vector Incremental/i)).toBeInTheDocument();
});
it('should render venv details section', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Python Virtual Environment Details/i)).toBeInTheDocument();
expect(screen.getByText(/Python Version/i)).toBeInTheDocument();
expect(screen.getByText(/3.11.0/i)).toBeInTheDocument();
});
it('should show coming soon alert when action clicked', async () => {
const user = userEvent.setup();
render(<OverviewTab {...defaultProps} />);
const ftsFullButton = screen.getByText(/FTS Full/i);
await user.click(ftsFullButton);
expect(global.alert).toHaveBeenCalledWith(expect.stringContaining('Coming Soon'));
});
});
describe('when installed but not ready', () => {
const notReadyProps = {
installed: true,
status: { ...mockStatus, ready: false },
config: mockConfig,
isLoading: false,
};
it('should show not ready status', () => {
render(<OverviewTab {...notReadyProps} />);
expect(screen.getByText(/Not Ready/i)).toBeInTheDocument();
});
it('should disable action buttons when not ready', () => {
render(<OverviewTab {...notReadyProps} />);
const ftsFullButton = screen.getByText(/FTS Full/i).closest('button');
expect(ftsFullButton).toBeDisabled();
});
});
describe('when not installed', () => {
const notInstalledProps = {
installed: false,
status: undefined,
config: undefined,
isLoading: false,
};
it('should show not installed message', () => {
render(<OverviewTab {...notInstalledProps} />);
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
expect(screen.getByText(/Please install CodexLens to use semantic code search features/i)).toBeInTheDocument();
});
});
describe('loading state', () => {
it('should show loading skeleton', () => {
const { container } = render(
<OverviewTab
installed={false}
status={undefined}
config={undefined}
isLoading={true}
/>
);
// Check for pulse/skeleton elements
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe('i18n - Chinese locale', () => {
const defaultProps = {
installed: true,
status: mockStatus,
config: mockConfig,
isLoading: false,
};
it('should display translated text in Chinese', () => {
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
expect(screen.getByText(/安装状态/i)).toBeInTheDocument();
expect(screen.getByText(/就绪/i)).toBeInTheDocument();
expect(screen.getByText(/版本/i)).toBeInTheDocument();
expect(screen.getByText(/索引路径/i)).toBeInTheDocument();
expect(screen.getByText(/索引数量/i)).toBeInTheDocument();
expect(screen.getByText(/快速操作/i)).toBeInTheDocument();
expect(screen.getByText(/Python 虚拟环境详情/i)).toBeInTheDocument();
});
it('should translate action buttons', () => {
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
expect(screen.getByText(/FTS 全量/i)).toBeInTheDocument();
expect(screen.getByText(/FTS 增量/i)).toBeInTheDocument();
expect(screen.getByText(/向量全量/i)).toBeInTheDocument();
expect(screen.getByText(/向量增量/i)).toBeInTheDocument();
});
});
describe('status card colors', () => {
it('should show success color when ready', () => {
const { container } = render(
<OverviewTab
installed={true}
status={mockStatus}
config={mockConfig}
isLoading={false}
/>
);
// Check for success/ready indication (check icon or success color)
const statusCard = container.querySelector('.bg-success\\/10');
expect(statusCard).toBeInTheDocument();
});
it('should show warning color when not ready', () => {
const { container } = render(
<OverviewTab
installed={true}
status={{ ...mockStatus, ready: false }}
config={mockConfig}
isLoading={false}
/>
);
// Check for warning/not ready indication
const statusCard = container.querySelector('.bg-warning\\/10');
expect(statusCard).toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle missing status gracefully', () => {
render(
<OverviewTab
installed={true}
status={undefined}
config={mockConfig}
isLoading={false}
/>
);
// Should not crash and render available data
expect(screen.getByText(/Version/i)).toBeInTheDocument();
});
it('should handle missing config gracefully', () => {
render(
<OverviewTab
installed={true}
status={mockStatus}
config={undefined}
isLoading={false}
/>
);
// Should not crash and render available data
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
});
it('should handle empty index path', () => {
const emptyConfig: CodexLensConfig = {
index_dir: '',
index_count: 0,
};
render(
<OverviewTab
installed={true}
status={mockStatus}
config={emptyConfig}
isLoading={false}
/>
);
expect(screen.getByText(/Index Path/i)).toBeInTheDocument();
});
it('should handle unknown version', () => {
const unknownVersionStatus: CodexLensVenvStatus = {
...mockStatus,
version: '',
};
render(
<OverviewTab
installed={true}
status={unknownVersionStatus}
config={mockConfig}
isLoading={false}
/>
);
expect(screen.getByText(/Version/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,184 +0,0 @@
// ========================================
// CodexLens Overview Tab
// ========================================
// Overview status display and quick actions for CodexLens
import { useIntl } from 'react-intl';
import {
Database,
FileText,
CheckCircle2,
XCircle,
Zap,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { cn } from '@/lib/utils';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
import { IndexOperations } from './IndexOperations';
import { FileWatcherCard } from './FileWatcherCard';
interface OverviewTabProps {
installed: boolean;
status?: CodexLensVenvStatus;
config?: CodexLensConfig;
isLoading: boolean;
onRefresh?: () => void;
}
export function OverviewTab({ installed, status, config, isLoading, onRefresh }: OverviewTabProps) {
const { formatMessage } = useIntl();
if (isLoading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-muted rounded w-20 mb-2" />
<div className="h-8 bg-muted rounded w-16" />
</div>
</Card>
))}
</div>
</div>
);
}
if (!installed) {
return (
<Card className="p-8 text-center">
<Database className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'codexlens.overview.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.notInstalled.message' })}
</p>
</Card>
);
}
const isReady = status?.ready ?? false;
const version = status?.version ?? 'Unknown';
const indexDir = config?.index_dir ?? '~/.codexlens/indexes';
const indexCount = config?.index_count ?? 0;
return (
<div className="space-y-6">
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Installation Status */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
isReady ? 'bg-success/10' : 'bg-warning/10'
)}>
{isReady ? (
<CheckCircle2 className="w-5 h-5 text-success" />
) : (
<XCircle className="w-5 h-5 text-warning" />
)}
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.installation' })}
</p>
<p className="text-lg font-semibold text-foreground">
{isReady
? formatMessage({ id: 'codexlens.overview.status.ready' })
: formatMessage({ id: 'codexlens.overview.status.notReady' })
}
</p>
</div>
</div>
</Card>
{/* Version */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Database className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.version' })}
</p>
<p className="text-lg font-semibold text-foreground">{version}</p>
</div>
</div>
</Card>
{/* Index Path */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-info/10">
<FileText className="w-5 h-5 text-info" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.indexPath' })}
</p>
<p className="text-sm font-semibold text-foreground truncate" title={indexDir}>
{indexDir}
</p>
</div>
</div>
</Card>
{/* Index Count */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent/10">
<Zap className="w-5 h-5 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.indexCount' })}
</p>
<p className="text-lg font-semibold text-foreground">{indexCount}</p>
</div>
</div>
</Card>
</div>
{/* Service Management */}
<div className="grid grid-cols-1 gap-4">
<FileWatcherCard disabled={!isReady} />
</div>
{/* Index Operations */}
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />
{/* Venv Details */}
{status && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.venv.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.venv.pythonVersion' })}
</span>
<span className="text-foreground font-mono">{status.pythonVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.venv.venvPath' })}
</span>
<span className="text-foreground font-mono truncate ml-4" title={status.venvPath}>
{status.venvPath || 'Unknown'}
</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,286 +0,0 @@
// ========================================
// SchemaFormRenderer Component
// ========================================
// Renders structured form groups from EnvVarGroupsSchema definition
// Supports select, number, checkbox, text, and model-select field types
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Box,
ArrowUpDown,
Cpu,
GitBranch,
type LucideIcon,
} from 'lucide-react';
import { Label } from '@/components/ui/Label';
import { Input } from '@/components/ui/Input';
import { Checkbox } from '@/components/ui/Checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/Collapsible';
import { cn } from '@/lib/utils';
import { evaluateShowWhen } from './envVarSchema';
import { ModelSelectField } from './ModelSelectField';
import type { EnvVarGroupsSchema, EnvVarFieldSchema } from '@/types/codexlens';
import type { CodexLensModel } from '@/lib/api';
// Icon mapping for group icons
const iconMap: Record<string, LucideIcon> = {
box: Box,
'arrow-up-down': ArrowUpDown,
cpu: Cpu,
'git-branch': GitBranch,
};
interface SchemaFormRendererProps {
/** The schema defining all groups and fields */
groups: EnvVarGroupsSchema;
/** Current form values keyed by env var name */
values: Record<string, string>;
/** Called when a field value changes */
onChange: (key: string, value: string) => void;
/** Whether the form is disabled (loading state) */
disabled?: boolean;
/** Local embedding models (installed) for model-select */
localEmbeddingModels?: CodexLensModel[];
/** Local reranker models (installed) for model-select */
localRerankerModels?: CodexLensModel[];
}
export function SchemaFormRenderer({
groups,
values,
onChange,
disabled = false,
localEmbeddingModels = [],
localRerankerModels = [],
}: SchemaFormRendererProps) {
const { formatMessage } = useIntl();
const groupEntries = useMemo(() => Object.entries(groups), [groups]);
return (
<div className="space-y-3">
{groupEntries.map(([groupKey, group]) => {
const IconComponent = iconMap[group.icon] || Box;
return (
<Collapsible key={groupKey} defaultOpen>
<div className="border border-border rounded-lg">
<CollapsibleTrigger className="flex w-full items-center gap-2 p-3 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
<IconComponent className="w-3.5 h-3.5" />
{formatMessage({ id: group.labelKey, defaultMessage: groupKey })}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-2">
{Object.entries(group.vars).map(([varKey, field]) => {
const visible = evaluateShowWhen(field, values);
if (!visible) return null;
return (
<FieldRenderer
key={varKey}
field={field}
value={values[varKey] ?? field.default ?? ''}
onChange={(val) => onChange(varKey, val)}
allValues={values}
disabled={disabled}
localModels={
varKey.includes('EMBEDDING')
? localEmbeddingModels
: localRerankerModels
}
formatMessage={formatMessage}
/>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
);
}
// ========================================
// Individual Field Renderer
// ========================================
interface FieldRendererProps {
field: EnvVarFieldSchema;
value: string;
onChange: (value: string) => void;
allValues: Record<string, string>;
disabled: boolean;
localModels: CodexLensModel[];
formatMessage: (descriptor: { id: string; defaultMessage?: string }) => string;
}
function FieldRenderer({
field,
value,
onChange,
allValues,
disabled,
localModels,
formatMessage,
}: FieldRendererProps) {
const label = formatMessage({ id: field.labelKey, defaultMessage: field.key });
switch (field.type) {
case 'select':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Select
value={value}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={cn('flex-1 h-8 text-xs')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
case 'number':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="number"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={disabled}
/>
</div>
);
case 'checkbox':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<div className="flex-1 flex items-center h-8">
<Checkbox
checked={value === 'true'}
onCheckedChange={(checked) => onChange(checked ? 'true' : 'false')}
disabled={disabled}
/>
</div>
</div>
);
case 'model-select': {
// Determine backend type from related backend env var
const isEmbedding = field.key.includes('EMBED');
const backendKey = isEmbedding
? 'CODEXLENS_EMBEDDING_BACKEND'
: 'CODEXLENS_RERANKER_BACKEND';
const backendValue = allValues[backendKey];
const backendType = backendValue === 'api' ? 'api' : 'local';
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<ModelSelectField
field={field}
value={value}
onChange={onChange}
localModels={localModels}
backendType={backendType}
disabled={disabled}
/>
</div>
);
}
case 'password':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="password"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
autoComplete="off"
/>
</div>
);
case 'text':
default:
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="text"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
/>
</div>
);
}
}
export default SchemaFormRenderer;

View File

@@ -1,445 +0,0 @@
// ========================================
// CodexLens Search Tab
// ========================================
// Semantic code search interface with multiple search types
// Includes LSP availability check and hybrid search mode switching
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Search, FileCode, Code, Sparkles, CheckCircle, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
useCodexLensSearch,
useCodexLensFilesSearch,
useCodexLensSymbolSearch,
useCodexLensLspStatus,
useCodexLensSemanticSearch,
} from '@/hooks/useCodexLens';
import type {
CodexLensSearchParams,
CodexLensSemanticSearchMode,
CodexLensFusionStrategy,
CodexLensStagedStage2Mode,
} from '@/lib/api';
import { cn } from '@/lib/utils';
type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic';
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
interface SearchTabProps {
enabled: boolean;
}
export function SearchTab({ enabled }: SearchTabProps) {
const { formatMessage } = useIntl();
const [searchType, setSearchType] = useState<SearchType>('search');
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
const [semanticMode, setSemanticMode] = useState<CodexLensSemanticSearchMode>('fusion');
const [fusionStrategy, setFusionStrategy] = useState<CodexLensFusionStrategy>('rrf');
const [stagedStage2Mode, setStagedStage2Mode] = useState<CodexLensStagedStage2Mode>('precomputed');
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
// LSP status check
const lspStatus = useCodexLensLspStatus({ enabled });
// Build search params based on search type
const searchParams: CodexLensSearchParams = {
query,
limit: 20,
mode: searchType !== 'symbol' && searchType !== 'semantic' ? searchMode : undefined,
max_content_length: 200,
extra_files_count: 10,
};
// Search hooks - only enable when hasSearched is true and query is not empty
const contentSearch = useCodexLensSearch(
searchParams,
{ enabled: enabled && hasSearched && searchType === 'search' && query.trim().length > 0 }
);
const fileSearch = useCodexLensFilesSearch(
searchParams,
{ enabled: enabled && hasSearched && searchType === 'search_files' && query.trim().length > 0 }
);
const symbolSearch = useCodexLensSymbolSearch(
{ query, limit: 20 },
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 }
);
const semanticSearch = useCodexLensSemanticSearch(
{
query,
mode: semanticMode,
fusion_strategy: semanticMode === 'fusion' ? fusionStrategy : undefined,
staged_stage2_mode: semanticMode === 'fusion' && fusionStrategy === 'staged' ? stagedStage2Mode : undefined,
limit: 20,
include_match_reason: true,
},
{ enabled: enabled && hasSearched && searchType === 'semantic' && query.trim().length > 0 }
);
// Get loading state based on search type
const isLoading = searchType === 'search'
? contentSearch.isLoading
: searchType === 'search_files'
? fileSearch.isLoading
: searchType === 'symbol'
? symbolSearch.isLoading
: semanticSearch.isLoading;
const handleSearch = () => {
if (query.trim()) {
setHasSearched(true);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleSearchTypeChange = (value: SearchType) => {
setSearchType(value);
setHasSearched(false);
};
const handleSearchModeChange = (value: SearchMode) => {
setSearchMode(value);
setHasSearched(false);
};
const handleSemanticModeChange = (value: CodexLensSemanticSearchMode) => {
setSemanticMode(value);
setHasSearched(false);
};
const handleFusionStrategyChange = (value: CodexLensFusionStrategy) => {
setFusionStrategy(value);
setHasSearched(false);
};
const handleStagedStage2ModeChange = (value: CodexLensStagedStage2Mode) => {
setStagedStage2Mode(value);
setHasSearched(false);
};
const handleQueryChange = (value: string) => {
setQuery(value);
setHasSearched(false);
};
// Get result count for display
const getResultCount = (): string => {
if (searchType === 'symbol') {
return symbolSearch.data?.success
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'search') {
return contentSearch.data?.success
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'search_files') {
return fileSearch.data?.success
? `${fileSearch.data.files?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'semantic') {
return semanticSearch.data?.success
? `${semanticSearch.data.count ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
return '';
};
if (!enabled) {
return (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'codexlens.search.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.search.notInstalled.description' })}
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* LSP Status Indicator */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.search.lspStatus' })}:</span>
{lspStatus.isLoading ? (
<span className="text-muted-foreground">...</span>
) : lspStatus.available ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspAvailable' })}
</span>
) : !lspStatus.semanticAvailable ? (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoSemantic' })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoVector' })}
</span>
)}
</div>
{/* Search Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Type */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.type' })}</Label>
<Select value={searchType} onValueChange={handleSearchTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.content' })}
</div>
</SelectItem>
<SelectItem value="search_files">
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.files' })}
</div>
</SelectItem>
<SelectItem value="symbol">
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.symbol' })}
</div>
</SelectItem>
<SelectItem value="semantic" disabled={!lspStatus.available}>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.semantic' })}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Search Mode - for CLI search types (content / file) */}
{(searchType === 'search' || searchType === 'search_files') && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
<Select value={searchMode} onValueChange={handleSearchModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.mode.semantic' })}
</SelectItem>
<SelectItem value="fts">
{formatMessage({ id: 'codexlens.search.mode.exact' })}
</SelectItem>
<SelectItem value="fuzzy">
{formatMessage({ id: 'codexlens.search.mode.fuzzy' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Semantic Search Mode - for semantic search type */}
{searchType === 'semantic' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.semanticMode' })}</Label>
<Select value={semanticMode} onValueChange={handleSemanticModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fusion">
{formatMessage({ id: 'codexlens.search.semanticMode.fusion' })}
</SelectItem>
<SelectItem value="vector">
{formatMessage({ id: 'codexlens.search.semanticMode.vector' })}
</SelectItem>
<SelectItem value="structural">
{formatMessage({ id: 'codexlens.search.semanticMode.structural' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Fusion Strategy - only when semantic + fusion mode */}
{searchType === 'semantic' && semanticMode === 'fusion' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.fusionStrategy' })}</Label>
<Select value={fusionStrategy} onValueChange={handleFusionStrategyChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rrf">
{formatMessage({ id: 'codexlens.search.fusionStrategy.rrf' })}
</SelectItem>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.fusionStrategy.dense_rerank' })}
</SelectItem>
<SelectItem value="binary">
{formatMessage({ id: 'codexlens.search.fusionStrategy.binary' })}
</SelectItem>
<SelectItem value="hybrid">
{formatMessage({ id: 'codexlens.search.fusionStrategy.hybrid' })}
</SelectItem>
<SelectItem value="staged">
{formatMessage({ id: 'codexlens.search.fusionStrategy.staged' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Staged Stage-2 Mode - only when semantic + fusion + staged */}
{searchType === 'semantic' && semanticMode === 'fusion' && fusionStrategy === 'staged' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.stagedStage2Mode' })}</Label>
<Select value={stagedStage2Mode} onValueChange={handleStagedStage2ModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="precomputed">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.precomputed' })}
</SelectItem>
<SelectItem value="realtime">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.realtime' })}
</SelectItem>
<SelectItem value="static_global_graph">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.static_global_graph' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Query Input */}
<div className="space-y-2">
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
<Input
id="search-query"
placeholder={formatMessage({ id: 'codexlens.search.queryPlaceholder' })}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isLoading}
/>
</div>
{/* Search Button */}
<Button
onClick={handleSearch}
disabled={!query.trim() || isLoading}
className="w-full"
>
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
{isLoading
? formatMessage({ id: 'codexlens.search.searching' })
: formatMessage({ id: 'codexlens.search.button' })
}
</Button>
{/* Results */}
{hasSearched && !isLoading && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{formatMessage({ id: 'codexlens.search.results' })}
</h3>
<span className="text-xs text-muted-foreground">
{getResultCount()}
</span>
</div>
{searchType === 'symbol' && symbolSearch.data && (
symbolSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(symbolSearch.data.symbols, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{symbolSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'search' && contentSearch.data && (
contentSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(contentSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{contentSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'search_files' && fileSearch.data && (
fileSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(fileSearch.data.files, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{fileSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'semantic' && semanticSearch.data && (
semanticSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(semanticSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{semanticSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
</div>
)}
</div>
);
}
export default SearchTab;

View File

@@ -1,205 +0,0 @@
// ========================================
// CodexLens Semantic Install Dialog
// ========================================
// Dialog for installing semantic search dependencies with GPU mode selection
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Cpu,
Zap,
Monitor,
CheckCircle2,
AlertCircle,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Label } from '@/components/ui/Label';
import { Card, CardContent } from '@/components/ui/Card';
import { useNotifications } from '@/hooks/useNotifications';
import { useCodexLensMutations } from '@/hooks';
import { cn } from '@/lib/utils';
type GpuMode = 'cpu' | 'cuda' | 'directml';
interface GpuModeOption {
value: GpuMode;
label: string;
description: string;
icon: React.ReactNode;
recommended?: boolean;
}
interface SemanticInstallDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function SemanticInstallDialog({ open, onOpenChange, onSuccess }: SemanticInstallDialogProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const { installSemantic, isInstallingSemantic } = useCodexLensMutations();
const [selectedMode, setSelectedMode] = useState<GpuMode>('cpu');
const gpuModes: GpuModeOption[] = [
{
value: 'cpu',
label: formatMessage({ id: 'codexlens.semantic.gpu.cpu' }),
description: formatMessage({ id: 'codexlens.semantic.gpu.cpuDesc' }),
icon: <Cpu className="w-5 h-5" />,
},
{
value: 'directml',
label: formatMessage({ id: 'codexlens.semantic.gpu.directml' }),
description: formatMessage({ id: 'codexlens.semantic.gpu.directmlDesc' }),
icon: <Monitor className="w-5 h-5" />,
recommended: true,
},
{
value: 'cuda',
label: formatMessage({ id: 'codexlens.semantic.gpu.cuda' }),
description: formatMessage({ id: 'codexlens.semantic.gpu.cudaDesc' }),
icon: <Zap className="w-5 h-5" />,
},
];
const handleInstall = async () => {
try {
const result = await installSemantic(selectedMode);
if (result.success) {
success(
formatMessage({ id: 'codexlens.semantic.installSuccess' }),
result.message || formatMessage({ id: 'codexlens.semantic.installSuccessDesc' }, { mode: selectedMode })
);
onSuccess?.();
onOpenChange(false);
} else {
throw new Error(result.message || 'Installation failed');
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.semantic.installFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.semantic.unknownError' })
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.semantic.installTitle' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'codexlens.semantic.installDescription' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Info Card */}
<Card className="bg-muted/50 border-muted">
<CardContent className="p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-info mt-0.5 flex-shrink-0" />
<div className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.semantic.installInfo' })}
</div>
</CardContent>
</Card>
{/* GPU Mode Selection */}
<RadioGroup value={selectedMode} onValueChange={(v) => setSelectedMode(v as GpuMode)}>
<div className="grid grid-cols-1 gap-3">
{gpuModes.map((mode) => (
<Card
key={mode.value}
className={cn(
"cursor-pointer transition-colors hover:bg-accent/50",
selectedMode === mode.value && "border-primary bg-accent"
)}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<RadioGroupItem
value={mode.value}
id={`gpu-mode-${mode.value}`}
className="mt-1"
/>
<div className="flex items-start gap-3 flex-1">
<div className={cn(
"p-2 rounded-lg",
selectedMode === mode.value
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
)}>
{mode.icon}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<Label
htmlFor={`gpu-mode-${mode.value}`}
className="font-medium cursor-pointer"
>
{mode.label}
</Label>
{mode.recommended && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{formatMessage({ id: 'codexlens.semantic.recommended' })}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{mode.description}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</RadioGroup>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isInstallingSemantic}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleInstall}
disabled={isInstallingSemantic}
>
{isInstallingSemantic ? (
<>
<Zap className="w-4 h-4 mr-2 animate-pulse" />
{formatMessage({ id: 'codexlens.semantic.installing' })}
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.semantic.install' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default SemanticInstallDialog;

View File

@@ -1,431 +0,0 @@
// ========================================
// Settings Tab Component Tests
// ========================================
// Tests for CodexLens Settings Tab component with schema-driven form
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { SettingsTab } from './SettingsTab';
import type { CodexLensConfig } from '@/lib/api';
// Mock hooks - use importOriginal to preserve all exports
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensConfig: vi.fn(),
useCodexLensEnv: vi.fn(),
useUpdateCodexLensEnv: vi.fn(),
useCodexLensModels: vi.fn(),
useNotifications: vi.fn(() => ({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
})),
};
});
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
useNotifications,
} from '@/hooks';
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
};
const mockEnv: Record<string, string> = {
CODEXLENS_EMBEDDING_BACKEND: 'local',
CODEXLENS_EMBEDDING_MODEL: 'fast',
CODEXLENS_AUTO_EMBED_MISSING: 'true',
CODEXLENS_USE_GPU: 'true',
CODEXLENS_RERANKER_ENABLED: 'true',
CODEXLENS_RERANKER_BACKEND: 'onnx',
CODEXLENS_API_MAX_WORKERS: '4',
CODEXLENS_API_BATCH_SIZE: '8',
CODEXLENS_CASCADE_STRATEGY: 'dense_rerank',
};
function setupDefaultMocks() {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: mockEnv, settings: {}, path: '~/.codexlens/.env' },
env: mockEnv,
settings: {},
raw: '',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
}
describe('SettingsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when enabled and config loaded', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should render current info card', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Current Index Count/i)).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText(/Current Workers/i)).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText(/Current Batch Size/i)).toBeInTheDocument();
expect(screen.getByText('8')).toBeInTheDocument();
});
it('should render configuration form with index directory', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Basic Configuration/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Index Directory/i)).toBeInTheDocument();
});
it('should render env var group sections', () => {
render(<SettingsTab enabled={true} />);
// Schema groups should be rendered (labels come from i18n, check for group icons/sections)
expect(screen.getByText(/Embedding/i)).toBeInTheDocument();
expect(screen.getByText(/Reranker/i)).toBeInTheDocument();
expect(screen.getByText(/Concurrency/i)).toBeInTheDocument();
expect(screen.getByText(/Cascade/i)).toBeInTheDocument();
expect(screen.getByText(/Chunking/i)).toBeInTheDocument();
expect(screen.getByText(/Auto Build Missing Vectors/i)).toBeInTheDocument();
});
it('should initialize index dir from config', () => {
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
});
it('should show save button enabled when changes are made', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
const saveButton = screen.getByText(/Save/i);
expect(saveButton).toBeEnabled();
});
it('should disable save and reset buttons when no changes', () => {
render(<SettingsTab enabled={true} />);
const saveButton = screen.getByText(/Save/i);
const resetButton = screen.getByText(/Reset/i);
expect(saveButton).toBeDisabled();
expect(resetButton).toBeDisabled();
});
it('should call updateEnv on save', async () => {
const updateEnv = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv,
isUpdating: false,
error: null,
});
const success = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success,
warning: vi.fn(),
error: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
await waitFor(() => {
expect(updateEnv).toHaveBeenCalledWith({
env: expect.objectContaining({
CODEXLENS_EMBEDDING_BACKEND: 'local',
CODEXLENS_EMBEDDING_MODEL: 'fast',
}),
});
});
});
it('should reset form on reset button click', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
expect(indexDirInput.value).toBe('/new/index/path');
const resetButton = screen.getByText(/Reset/i);
await user.click(resetButton);
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
});
});
describe('form validation', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should validate index dir is required', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
});
it('should clear error when user fixes invalid input', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
await user.type(indexDirInput, '/valid/path');
expect(screen.queryByText(/Index directory is required/i)).not.toBeInTheDocument();
});
});
describe('when disabled', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should not render when enabled is false', () => {
render(<SettingsTab enabled={false} />);
// When not enabled, hooks are disabled so no config/env data
expect(screen.queryByText(/Basic Configuration/i)).not.toBeInTheDocument();
});
});
describe('loading states', () => {
it('should disable inputs when loading config', () => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
isLoading: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: mockEnv, settings: {}, path: '' },
env: mockEnv,
settings: {},
raw: '',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
expect(indexDirInput).toBeDisabled();
});
it('should show saving state when updating', async () => {
setupDefaultMocks();
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true }),
isUpdating: true,
error: null,
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/path');
const saveButton = screen.getByText(/Saving/i);
expect(saveButton).toBeInTheDocument();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should display translated labels', () => {
render(<SettingsTab enabled={true} />, { locale: 'zh' });
expect(screen.getByText(/当前索引数量/i)).toBeInTheDocument();
expect(screen.getByText(/当前工作线程/i)).toBeInTheDocument();
expect(screen.getByText(/当前批次大小/i)).toBeInTheDocument();
expect(screen.getByText(/基本配置/i)).toBeInTheDocument();
expect(screen.getByText(/索引目录/i)).toBeInTheDocument();
expect(screen.getByText(/保存/i)).toBeInTheDocument();
expect(screen.getByText(/重置/i)).toBeInTheDocument();
});
it('should display translated validation errors', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />, { locale: 'zh' });
const indexDirInput = screen.getByLabelText(/索引目录/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/保存/i);
await user.click(saveButton);
expect(screen.getByText(/索引目录不能为空/i)).toBeInTheDocument();
});
});
describe('error handling', () => {
it('should show error notification on save failure', async () => {
setupDefaultMocks();
const error = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error,
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: false, message: 'Save failed' }),
isUpdating: false,
error: null,
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/path');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
await waitFor(() => {
expect(error).toHaveBeenCalledWith(
expect.stringContaining('Save failed'),
expect.any(String)
);
});
});
});
});

View File

@@ -1,276 +0,0 @@
// ========================================
// CodexLens Settings Tab
// ========================================
// Structured form for CodexLens v2 env configuration
// Renders 4 groups: embedding, reranker, search, indexing
// Plus a general config section (index_dir)
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { SchemaFormRenderer } from './SchemaFormRenderer';
import { envVarGroupsSchema, getSchemaDefaults } from './envVarSchema';
// ========== Settings Tab ==========
interface SettingsTabProps {
enabled?: boolean;
}
export function SettingsTab({ enabled = true }: SettingsTabProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
// Fetch current config (index_dir, index_count)
const {
config,
indexCount,
isLoading: isLoadingConfig,
refetch: refetchConfig,
} = useCodexLensConfig({ enabled });
// Fetch env vars and settings
const {
env: serverEnv,
settings: serverSettings,
isLoading: isLoadingEnv,
refetch: refetchEnv,
} = useCodexLensEnv({ enabled });
// Fetch local models for model-select fields
const {
embeddingModels: localEmbeddingModels,
rerankerModels: localRerankerModels,
} = useCodexLensModels({ enabled });
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
// General form state (index_dir)
const [indexDir, setIndexDir] = useState('');
const [indexDirError, setIndexDirError] = useState('');
// Schema-driven env var form state
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [hasChanges, setHasChanges] = useState(false);
// Store the initial values for change detection
const [initialEnvValues, setInitialEnvValues] = useState<Record<string, string>>({});
const [initialIndexDir, setInitialIndexDir] = useState('');
// Initialize form from server data
useEffect(() => {
if (config) {
setIndexDir(config.index_dir || '');
setInitialIndexDir(config.index_dir || '');
}
}, [config]);
useEffect(() => {
if (serverEnv || serverSettings) {
const defaults = getSchemaDefaults();
const merged: Record<string, string> = { ...defaults };
// Settings.json values override defaults
if (serverSettings) {
for (const [key, val] of Object.entries(serverSettings)) {
if (val) merged[key] = val;
}
}
// .env values override settings
if (serverEnv) {
for (const [key, val] of Object.entries(serverEnv)) {
if (val) merged[key] = val;
}
}
setEnvValues(merged);
setInitialEnvValues(merged);
setHasChanges(false);
}
}, [serverEnv, serverSettings]);
// Check for changes
const detectChanges = useCallback(
(currentEnv: Record<string, string>, currentIndexDir: string) => {
if (currentIndexDir !== initialIndexDir) return true;
for (const key of Object.keys(currentEnv)) {
if (currentEnv[key] !== initialEnvValues[key]) return true;
}
return false;
},
[initialEnvValues, initialIndexDir]
);
const handleEnvChange = useCallback(
(key: string, value: string) => {
setEnvValues((prev) => {
const next = { ...prev, [key]: value };
setHasChanges(detectChanges(next, indexDir));
return next;
});
},
[detectChanges, indexDir]
);
const handleIndexDirChange = useCallback(
(value: string) => {
setIndexDir(value);
setIndexDirError('');
setHasChanges(detectChanges(envValues, value));
},
[detectChanges, envValues]
);
// Installed local models filtered to installed-only
const installedEmbeddingModels = useMemo(
() => (localEmbeddingModels || []).filter((m) => m.installed),
[localEmbeddingModels]
);
const installedRerankerModels = useMemo(
() => (localRerankerModels || []).filter((m) => m.installed),
[localRerankerModels]
);
const handleSave = async () => {
// Validate index_dir
if (!indexDir.trim()) {
setIndexDirError(
formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' })
);
return;
}
try {
const result = await updateEnv({ env: envValues });
if (result.success) {
success(
formatMessage({ id: 'codexlens.settings.saveSuccess' }),
result.message ||
formatMessage({ id: 'codexlens.settings.configUpdated' })
);
refetchEnv();
refetchConfig();
setHasChanges(false);
setInitialEnvValues(envValues);
setInitialIndexDir(indexDir);
} else {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
result.message ||
formatMessage({ id: 'codexlens.settings.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
err instanceof Error
? err.message
: formatMessage({ id: 'codexlens.settings.unknownError' })
);
}
};
const handleReset = () => {
setEnvValues(initialEnvValues);
setIndexDir(initialIndexDir);
setIndexDirError('');
setHasChanges(false);
};
const isLoading = isLoadingConfig || isLoadingEnv;
return (
<div className="space-y-6">
{/* Current Info Card */}
<Card className="p-4 bg-muted/30">
<div className="text-sm">
<div>
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.currentCount' })}
</span>
<p className="text-foreground font-medium">{indexCount}</p>
</div>
</div>
</Card>
{/* General Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'codexlens.settings.configTitle' })}
</h3>
{/* Index Directory */}
<div className="space-y-2 mb-4">
<Label htmlFor="index_dir">
{formatMessage({ id: 'codexlens.settings.indexDir.label' })}
</Label>
<Input
id="index_dir"
value={indexDir}
onChange={(e) => handleIndexDirChange(e.target.value)}
placeholder={formatMessage({
id: 'codexlens.settings.indexDir.placeholder',
})}
error={!!indexDirError}
disabled={isLoading}
/>
{indexDirError && (
<p className="text-sm text-destructive">{indexDirError}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
</p>
</div>
{/* Schema-driven Env Var Groups */}
<SchemaFormRenderer
groups={envVarGroupsSchema}
values={envValues}
onChange={handleEnvChange}
disabled={isLoading}
localEmbeddingModels={installedEmbeddingModels}
localRerankerModels={installedRerankerModels}
/>
{/* Action Buttons */}
<div className="flex items-center gap-2 mt-6">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save
className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')}
/>
{isUpdating
? formatMessage({ id: 'codexlens.settings.saving' })
: formatMessage({ id: 'codexlens.settings.save' })}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.settings.reset' })}
</Button>
</div>
</Card>
</div>
);
}
export default SettingsTab;

View File

@@ -1,389 +0,0 @@
// ========================================
// CodexLens v2 Environment Variable Schema
// ========================================
// Defines structured groups for codexlens-search v2 configuration.
// Env var names match what the Python bridge CLI reads.
import type { EnvVarGroupsSchema } from '@/types/codexlens';
export const envVarGroupsSchema: EnvVarGroupsSchema = {
embedding: {
id: 'embedding',
labelKey: 'codexlens.envGroup.embedding',
icon: 'box',
vars: {
CODEXLENS_EMBEDDING_BACKEND: {
key: 'CODEXLENS_EMBEDDING_BACKEND',
labelKey: 'codexlens.envField.backend',
type: 'select',
options: ['local', 'api'],
default: 'local',
settingsPath: 'embedding.backend',
},
CODEXLENS_EMBED_API_URL: {
key: 'CODEXLENS_EMBED_API_URL',
labelKey: 'codexlens.envField.apiUrl',
type: 'text',
placeholder: 'https://api.siliconflow.cn/v1',
default: '',
settingsPath: 'embedding.api_url',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_KEY: {
key: 'CODEXLENS_EMBED_API_KEY',
labelKey: 'codexlens.envField.apiKey',
type: 'password',
placeholder: 'sk-...',
default: '',
settingsPath: 'embedding.api_key',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_MODEL: {
key: 'CODEXLENS_EMBED_API_MODEL',
labelKey: 'codexlens.envField.model',
type: 'model-select',
placeholder: 'Select or enter model...',
default: '',
settingsPath: 'embedding.api_model',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
localModels: [],
apiModels: [
{
group: 'SiliconFlow',
items: ['BAAI/bge-m3', 'BAAI/bge-large-zh-v1.5', 'BAAI/bge-large-en-v1.5'],
},
{
group: 'OpenAI',
items: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],
},
{
group: 'Cohere',
items: ['embed-english-v3.0', 'embed-multilingual-v3.0', 'embed-english-light-v3.0'],
},
{
group: 'Voyage',
items: ['voyage-3', 'voyage-3-lite', 'voyage-code-3'],
},
{
group: 'Jina',
items: ['jina-embeddings-v3', 'jina-embeddings-v2-base-en'],
},
],
},
CODEXLENS_EMBED_API_ENDPOINTS: {
key: 'CODEXLENS_EMBED_API_ENDPOINTS',
labelKey: 'codexlens.envField.multiEndpoints',
type: 'text',
placeholder: 'url1|key1|model1,url2|key2|model2',
default: '',
settingsPath: 'embedding.api_endpoints',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_DIM: {
key: 'CODEXLENS_EMBED_DIM',
labelKey: 'codexlens.envField.embedDim',
type: 'number',
placeholder: '384',
default: '384',
settingsPath: 'embedding.dim',
min: 64,
max: 4096,
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_CONCURRENCY: {
key: 'CODEXLENS_EMBED_API_CONCURRENCY',
labelKey: 'codexlens.envField.apiConcurrency',
type: 'number',
placeholder: '4',
default: '4',
settingsPath: 'embedding.api_concurrency',
min: 1,
max: 32,
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_MAX_TOKENS: {
key: 'CODEXLENS_EMBED_API_MAX_TOKENS',
labelKey: 'codexlens.envField.maxTokensPerBatch',
type: 'number',
placeholder: '8192',
default: '8192',
settingsPath: 'embedding.api_max_tokens_per_batch',
min: 512,
max: 65536,
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBEDDING_MODEL: {
key: 'CODEXLENS_EMBEDDING_MODEL',
labelKey: 'codexlens.envField.localModel',
type: 'model-select',
placeholder: 'Select local model...',
default: 'BAAI/bge-small-en-v1.5',
settingsPath: 'embedding.model',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] !== 'api',
localModels: [
{
group: 'FastEmbed Profiles',
items: ['small', 'base', 'large', 'code'],
},
],
apiModels: [],
},
CODEXLENS_USE_GPU: {
key: 'CODEXLENS_USE_GPU',
labelKey: 'codexlens.envField.useGpu',
type: 'select',
options: ['auto', 'cuda', 'cpu'],
default: 'auto',
settingsPath: 'embedding.device',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] !== 'api',
},
CODEXLENS_EMBED_BATCH_SIZE: {
key: 'CODEXLENS_EMBED_BATCH_SIZE',
labelKey: 'codexlens.envField.batchSize',
type: 'number',
placeholder: '64',
default: '64',
settingsPath: 'embedding.batch_size',
min: 1,
max: 512,
},
},
},
reranker: {
id: 'reranker',
labelKey: 'codexlens.envGroup.reranker',
icon: 'arrow-up-down',
vars: {
CODEXLENS_RERANKER_BACKEND: {
key: 'CODEXLENS_RERANKER_BACKEND',
labelKey: 'codexlens.envField.backend',
type: 'select',
options: ['local', 'api'],
default: 'local',
settingsPath: 'reranker.backend',
},
CODEXLENS_RERANKER_API_URL: {
key: 'CODEXLENS_RERANKER_API_URL',
labelKey: 'codexlens.envField.apiUrl',
type: 'text',
placeholder: 'https://api.siliconflow.cn/v1',
default: '',
settingsPath: 'reranker.api_url',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
},
CODEXLENS_RERANKER_API_KEY: {
key: 'CODEXLENS_RERANKER_API_KEY',
labelKey: 'codexlens.envField.apiKey',
type: 'password',
placeholder: 'sk-...',
default: '',
settingsPath: 'reranker.api_key',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
},
CODEXLENS_RERANKER_API_MODEL: {
key: 'CODEXLENS_RERANKER_API_MODEL',
labelKey: 'codexlens.envField.model',
type: 'model-select',
placeholder: 'Select or enter model...',
default: '',
settingsPath: 'reranker.api_model',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
localModels: [],
apiModels: [
{
group: 'SiliconFlow',
items: ['BAAI/bge-reranker-v2-m3', 'BAAI/bge-reranker-large', 'BAAI/bge-reranker-base'],
},
{
group: 'Cohere',
items: ['rerank-english-v3.0', 'rerank-multilingual-v3.0'],
},
{
group: 'Jina',
items: ['jina-reranker-v2-base-multilingual'],
},
],
},
CODEXLENS_RERANKER_MODEL: {
key: 'CODEXLENS_RERANKER_MODEL',
labelKey: 'codexlens.envField.localModel',
type: 'model-select',
placeholder: 'Select local model...',
default: 'Xenova/ms-marco-MiniLM-L-6-v2',
settingsPath: 'reranker.model',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] !== 'api',
localModels: [
{
group: 'FastEmbed/ONNX',
items: [
'Xenova/ms-marco-MiniLM-L-6-v2',
'cross-encoder/ms-marco-MiniLM-L-6-v2',
'BAAI/bge-reranker-base',
],
},
],
apiModels: [],
},
CODEXLENS_RERANKER_TOP_K: {
key: 'CODEXLENS_RERANKER_TOP_K',
labelKey: 'codexlens.envField.topKResults',
type: 'number',
placeholder: '20',
default: '20',
settingsPath: 'reranker.top_k',
min: 5,
max: 200,
},
CODEXLENS_RERANKER_BATCH_SIZE: {
key: 'CODEXLENS_RERANKER_BATCH_SIZE',
labelKey: 'codexlens.envField.batchSize',
type: 'number',
placeholder: '32',
default: '32',
settingsPath: 'reranker.batch_size',
min: 1,
max: 128,
},
},
},
search: {
id: 'search',
labelKey: 'codexlens.envGroup.search',
icon: 'git-branch',
vars: {
CODEXLENS_BINARY_TOP_K: {
key: 'CODEXLENS_BINARY_TOP_K',
labelKey: 'codexlens.envField.binaryTopK',
type: 'number',
placeholder: '200',
default: '200',
settingsPath: 'search.binary_top_k',
min: 10,
max: 1000,
},
CODEXLENS_ANN_TOP_K: {
key: 'CODEXLENS_ANN_TOP_K',
labelKey: 'codexlens.envField.annTopK',
type: 'number',
placeholder: '50',
default: '50',
settingsPath: 'search.ann_top_k',
min: 5,
max: 500,
},
CODEXLENS_FTS_TOP_K: {
key: 'CODEXLENS_FTS_TOP_K',
labelKey: 'codexlens.envField.ftsTopK',
type: 'number',
placeholder: '50',
default: '50',
settingsPath: 'search.fts_top_k',
min: 5,
max: 500,
},
CODEXLENS_FUSION_K: {
key: 'CODEXLENS_FUSION_K',
labelKey: 'codexlens.envField.fusionK',
type: 'number',
placeholder: '60',
default: '60',
settingsPath: 'search.fusion_k',
min: 1,
max: 200,
},
},
},
indexing: {
id: 'indexing',
labelKey: 'codexlens.envGroup.indexing',
icon: 'cpu',
vars: {
CODEXLENS_CODE_AWARE_CHUNKING: {
key: 'CODEXLENS_CODE_AWARE_CHUNKING',
labelKey: 'codexlens.envField.codeAwareChunking',
type: 'checkbox',
default: 'true',
settingsPath: 'indexing.code_aware_chunking',
},
CODEXLENS_INDEX_WORKERS: {
key: 'CODEXLENS_INDEX_WORKERS',
labelKey: 'codexlens.envField.indexWorkers',
type: 'number',
placeholder: '2',
default: '2',
settingsPath: 'indexing.workers',
min: 1,
max: 16,
},
CODEXLENS_MAX_FILE_SIZE: {
key: 'CODEXLENS_MAX_FILE_SIZE',
labelKey: 'codexlens.envField.maxFileSize',
type: 'number',
placeholder: '1000000',
default: '1000000',
settingsPath: 'indexing.max_file_size_bytes',
min: 10000,
max: 10000000,
},
CODEXLENS_HNSW_EF: {
key: 'CODEXLENS_HNSW_EF',
labelKey: 'codexlens.envField.hnswEf',
type: 'number',
placeholder: '150',
default: '150',
settingsPath: 'indexing.hnsw_ef',
min: 10,
max: 500,
},
CODEXLENS_HNSW_M: {
key: 'CODEXLENS_HNSW_M',
labelKey: 'codexlens.envField.hnswM',
type: 'number',
placeholder: '32',
default: '32',
settingsPath: 'indexing.hnsw_M',
min: 4,
max: 128,
},
},
},
};
/**
* Get all env var keys from the schema
*/
export function getAllEnvVarKeys(): string[] {
const keys: string[] = [];
for (const group of Object.values(envVarGroupsSchema)) {
for (const key of Object.keys(group.vars)) {
keys.push(key);
}
}
return keys;
}
/**
* Evaluate showWhen condition for a field
*/
export function evaluateShowWhen(
field: { showWhen?: (env: Record<string, string>) => boolean },
values: Record<string, string>
): boolean {
if (!field.showWhen) return true;
return field.showWhen(values);
}
/**
* Get default values for all env vars in the schema
*/
export function getSchemaDefaults(): Record<string, string> {
const defaults: Record<string, string> = {};
for (const group of Object.values(envVarGroupsSchema)) {
for (const [key, field] of Object.entries(group.vars)) {
if (field.default !== undefined) {
defaults[key] = field.default;
}
}
}
return defaults;
}

View File

@@ -290,84 +290,15 @@ export type {
WorkspaceQueryKeys,
} from './useWorkspaceQueryKeys';
// ========== CodexLens ==========
// ========== CodexLens (v2) ==========
export {
useCodexLensDashboard,
useCodexLensStatus,
useCodexLensWorkspaceStatus,
useCodexLensConfig,
useCodexLensModels,
useCodexLensModelInfo,
useCodexLensEnv,
useCodexLensGpu,
useCodexLensIgnorePatterns,
useUpdateCodexLensConfig,
useBootstrapCodexLens,
useUninstallCodexLens,
useDownloadModel,
useDeleteModel,
useUpdateCodexLensEnv,
useSelectGpu,
useUpdateIgnorePatterns,
useCodexLensMutations,
codexLensKeys,
useCodexLensIndexes,
useCodexLensIndexingStatus,
useRebuildIndex,
useUpdateIndex,
useCancelIndexing,
useCodexLensWatcher,
useCodexLensWatcherMutations,
useCodexLensLspStatus,
useCodexLensLspMutations,
useCodexLensRerankerConfig,
useUpdateRerankerConfig,
useCcwToolsList,
} from './useCodexLens';
useV2SearchManager,
} from './useV2SearchManager';
export type {
UseCodexLensDashboardOptions,
UseCodexLensDashboardReturn,
UseCodexLensStatusOptions,
UseCodexLensStatusReturn,
UseCodexLensWorkspaceStatusOptions,
UseCodexLensWorkspaceStatusReturn,
UseCodexLensConfigOptions,
UseCodexLensConfigReturn,
UseCodexLensModelsOptions,
UseCodexLensModelsReturn,
UseCodexLensModelInfoOptions,
UseCodexLensModelInfoReturn,
UseCodexLensEnvOptions,
UseCodexLensEnvReturn,
UseCodexLensGpuOptions,
UseCodexLensGpuReturn,
UseCodexLensIgnorePatternsOptions,
UseCodexLensIgnorePatternsReturn,
UseUpdateCodexLensConfigReturn,
UseBootstrapCodexLensReturn,
UseUninstallCodexLensReturn,
UseDownloadModelReturn,
UseDeleteModelReturn,
UseUpdateCodexLensEnvReturn,
UseSelectGpuReturn,
UseUpdateIgnorePatternsReturn,
UseCodexLensIndexesOptions,
UseCodexLensIndexesReturn,
UseCodexLensIndexingStatusReturn,
UseRebuildIndexReturn,
UseUpdateIndexReturn,
UseCancelIndexingReturn,
UseCodexLensWatcherOptions,
UseCodexLensWatcherReturn,
UseCodexLensWatcherMutationsReturn,
UseCodexLensLspStatusOptions,
UseCodexLensLspStatusReturn,
UseCodexLensLspMutationsReturn,
UseCodexLensRerankerConfigOptions,
UseCodexLensRerankerConfigReturn,
UseUpdateRerankerConfigReturn,
UseCcwToolsListReturn,
} from './useCodexLens';
V2IndexStatus,
V2SearchTestResult,
UseV2SearchManagerReturn,
} from './useV2SearchManager';
// ========== Skill Hub ==========
export {

View File

@@ -1,418 +0,0 @@
// ========================================
// useCodexLens Hook Tests
// ========================================
// Tests for all CodexLens TanStack Query hooks
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as api from '../lib/api';
import {
useCodexLensDashboard,
useCodexLensStatus,
useCodexLensConfig,
useCodexLensModels,
useCodexLensEnv,
useCodexLensGpu,
useUpdateCodexLensConfig,
useBootstrapCodexLens,
useUninstallCodexLens,
useDownloadModel,
useDeleteModel,
useUpdateCodexLensEnv,
useSelectGpu,
} from './useCodexLens';
// Mock api module
vi.mock('../lib/api', () => ({
fetchCodexLensDashboardInit: vi.fn(),
fetchCodexLensStatus: vi.fn(),
fetchCodexLensConfig: vi.fn(),
updateCodexLensConfig: vi.fn(),
bootstrapCodexLens: vi.fn(),
uninstallCodexLens: vi.fn(),
fetchCodexLensModels: vi.fn(),
fetchCodexLensModelInfo: vi.fn(),
downloadCodexLensModel: vi.fn(),
downloadCodexLensCustomModel: vi.fn(),
deleteCodexLensModel: vi.fn(),
deleteCodexLensModelByPath: vi.fn(),
fetchCodexLensEnv: vi.fn(),
updateCodexLensEnv: vi.fn(),
fetchCodexLensGpuDetect: vi.fn(),
fetchCodexLensGpuList: vi.fn(),
selectCodexLensGpu: vi.fn(),
resetCodexLensGpu: vi.fn(),
fetchCodexLensIgnorePatterns: vi.fn(),
updateCodexLensIgnorePatterns: vi.fn(),
}));
// Mock workflowStore
vi.mock('../stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => () => '/test/project'),
selectProjectPath: vi.fn(() => '/test/project'),
}));
const mockDashboardData = {
installed: true,
status: {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
},
config: {
index_dir: '~/.codexlens/indexes',
index_count: 100,
},
semantic: { available: true },
};
const mockModelsData = {
models: [
{
profile: 'model1',
name: 'Embedding Model 1',
type: 'embedding',
backend: 'onnx',
installed: true,
cache_path: '/path/to/cache1',
},
{
profile: 'model2',
name: 'Reranker Model 1',
type: 'reranker',
backend: 'onnx',
installed: false,
cache_path: '/path/to/cache2',
},
],
};
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
describe('useCodexLens Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCodexLensDashboard', () => {
it('should fetch dashboard data', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledOnce();
expect(result.current.installed).toBe(true);
expect(result.current.status?.ready).toBe(true);
expect(result.current.config?.index_dir).toBe('~/.codexlens/indexes');
});
it('should handle errors', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockRejectedValue(new Error('API Error'));
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBeTruthy();
// TanStack Query wraps errors, so just check error exists
expect(result.current.error).toBeDefined();
});
it('should be disabled when enabled is false', async () => {
const { result } = renderHook(() => useCodexLensDashboard({ enabled: false }), { wrapper });
expect(api.fetchCodexLensDashboardInit).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
});
});
describe('useCodexLensStatus', () => {
it('should fetch status data', async () => {
const mockStatus = { ready: true, installed: true, version: '1.0.0' };
vi.mocked(api.fetchCodexLensStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useCodexLensStatus(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensStatus).toHaveBeenCalledOnce();
expect(result.current.ready).toBe(true);
expect(result.current.installed).toBe(true);
});
});
describe('useCodexLensConfig', () => {
it('should fetch config data', async () => {
const mockConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
};
vi.mocked(api.fetchCodexLensConfig).mockResolvedValue(mockConfig);
const { result } = renderHook(() => useCodexLensConfig(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensConfig).toHaveBeenCalledOnce();
expect(result.current.indexDir).toBe('~/.codexlens/indexes');
expect(result.current.indexCount).toBe(100);
});
});
describe('useCodexLensModels', () => {
it('should fetch and filter models by type', async () => {
vi.mocked(api.fetchCodexLensModels).mockResolvedValue(mockModelsData as any);
const { result } = renderHook(() => useCodexLensModels(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.models).toHaveLength(2);
expect(result.current.embeddingModels).toHaveLength(1);
expect(result.current.rerankerModels).toHaveLength(1);
expect(result.current.embeddingModels?.[0].type).toBe('embedding');
});
});
describe('useCodexLensEnv', () => {
it('should fetch environment variables', async () => {
const mockEnv = {
env: { KEY1: 'value1', KEY2: 'value2' },
settings: { SETTING1: 'setting1' },
raw: 'KEY1=value1\nKEY2=value2',
};
vi.mocked(api.fetchCodexLensEnv).mockResolvedValue(mockEnv as any);
const { result } = renderHook(() => useCodexLensEnv(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensEnv).toHaveBeenCalledOnce();
expect(result.current.env).toEqual({ KEY1: 'value1', KEY2: 'value2' });
expect(result.current.settings).toEqual({ SETTING1: 'setting1' });
expect(result.current.raw).toBe('KEY1=value1\nKEY2=value2');
});
});
describe('useCodexLensGpu', () => {
it('should fetch GPU detect and list data', async () => {
const mockDetect = { supported: true, has_cuda: true };
const mockList = {
devices: [
{ id: 0, name: 'GPU 0', type: 'cuda', driver: '12.0', memory: '8GB' },
],
selected_device_id: 0,
};
vi.mocked(api.fetchCodexLensGpuDetect).mockResolvedValue(mockDetect as any);
vi.mocked(api.fetchCodexLensGpuList).mockResolvedValue(mockList as any);
const { result } = renderHook(() => useCodexLensGpu(), { wrapper });
await waitFor(() => expect(result.current.isLoadingDetect).toBe(false));
await waitFor(() => expect(result.current.isLoadingList).toBe(false));
expect(api.fetchCodexLensGpuDetect).toHaveBeenCalledOnce();
expect(api.fetchCodexLensGpuList).toHaveBeenCalledOnce();
expect(result.current.supported).toBe(true);
expect(result.current.devices).toHaveLength(1);
expect(result.current.selectedDeviceId).toBe(0);
});
});
describe('useUpdateCodexLensConfig', () => {
it('should update config and invalidate queries', async () => {
vi.mocked(api.updateCodexLensConfig).mockResolvedValue({
success: true,
message: 'Config updated',
});
const { result } = renderHook(() => useUpdateCodexLensConfig(), { wrapper });
const updateResult = await result.current.updateConfig({
index_dir: '~/.codexlens/indexes',
});
expect(api.updateCodexLensConfig).toHaveBeenCalledWith({
index_dir: '~/.codexlens/indexes',
});
expect(updateResult.success).toBe(true);
expect(updateResult.message).toBe('Config updated');
});
});
describe('useBootstrapCodexLens', () => {
it('should bootstrap CodexLens and invalidate queries', async () => {
vi.mocked(api.bootstrapCodexLens).mockResolvedValue({
success: true,
version: '1.0.0',
});
const { result } = renderHook(() => useBootstrapCodexLens(), { wrapper });
const bootstrapResult = await result.current.bootstrap();
expect(api.bootstrapCodexLens).toHaveBeenCalledOnce();
expect(bootstrapResult.success).toBe(true);
expect(bootstrapResult.version).toBe('1.0.0');
});
});
describe('useUninstallCodexLens', () => {
it('should uninstall CodexLens and invalidate queries', async () => {
vi.mocked(api.uninstallCodexLens).mockResolvedValue({
success: true,
message: 'CodexLens uninstalled',
});
const { result } = renderHook(() => useUninstallCodexLens(), { wrapper });
const uninstallResult = await result.current.uninstall();
expect(api.uninstallCodexLens).toHaveBeenCalledOnce();
expect(uninstallResult.success).toBe(true);
});
});
describe('useDownloadModel', () => {
it('should download model by profile', async () => {
vi.mocked(api.downloadCodexLensModel).mockResolvedValue({
success: true,
message: 'Model downloaded',
});
const { result } = renderHook(() => useDownloadModel(), { wrapper });
const downloadResult = await result.current.downloadModel('model1');
expect(api.downloadCodexLensModel).toHaveBeenCalledWith('model1');
expect(downloadResult.success).toBe(true);
});
it('should download custom model', async () => {
vi.mocked(api.downloadCodexLensCustomModel).mockResolvedValue({
success: true,
message: 'Custom model downloaded',
});
const { result } = renderHook(() => useDownloadModel(), { wrapper });
const downloadResult = await result.current.downloadCustomModel('custom/model', 'embedding');
expect(api.downloadCodexLensCustomModel).toHaveBeenCalledWith('custom/model', 'embedding');
expect(downloadResult.success).toBe(true);
});
});
describe('useDeleteModel', () => {
it('should delete model by profile', async () => {
vi.mocked(api.deleteCodexLensModel).mockResolvedValue({
success: true,
message: 'Model deleted',
});
const { result } = renderHook(() => useDeleteModel(), { wrapper });
const deleteResult = await result.current.deleteModel('model1');
expect(api.deleteCodexLensModel).toHaveBeenCalledWith('model1');
expect(deleteResult.success).toBe(true);
});
it('should delete model by path', async () => {
vi.mocked(api.deleteCodexLensModelByPath).mockResolvedValue({
success: true,
message: 'Model deleted',
});
const { result } = renderHook(() => useDeleteModel(), { wrapper });
const deleteResult = await result.current.deleteModelByPath('/path/to/model');
expect(api.deleteCodexLensModelByPath).toHaveBeenCalledWith('/path/to/model');
expect(deleteResult.success).toBe(true);
});
});
describe('useUpdateCodexLensEnv', () => {
it('should update environment variables', async () => {
vi.mocked(api.updateCodexLensEnv).mockResolvedValue({
success: true,
env: { KEY1: 'newvalue' },
settings: {},
raw: 'KEY1=newvalue',
} as any);
const { result } = renderHook(() => useUpdateCodexLensEnv(), { wrapper });
const updateResult = await result.current.updateEnv({
raw: 'KEY1=newvalue',
} as any);
expect(api.updateCodexLensEnv).toHaveBeenCalledWith({ raw: 'KEY1=newvalue' });
expect(updateResult.success).toBe(true);
});
});
describe('useSelectGpu', () => {
it('should select GPU', async () => {
vi.mocked(api.selectCodexLensGpu).mockResolvedValue({
success: true,
message: 'GPU selected',
});
const { result } = renderHook(() => useSelectGpu(), { wrapper });
const selectResult = await result.current.selectGpu(0);
expect(api.selectCodexLensGpu).toHaveBeenCalledWith(0);
expect(selectResult.success).toBe(true);
});
it('should reset GPU', async () => {
vi.mocked(api.resetCodexLensGpu).mockResolvedValue({
success: true,
message: 'GPU reset',
});
const { result } = renderHook(() => useSelectGpu(), { wrapper });
const resetResult = await result.current.resetGpu();
expect(api.resetCodexLensGpu).toHaveBeenCalledOnce();
expect(resetResult.success).toBe(true);
});
});
describe('query refetch', () => {
it('should refetch dashboard data', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(1);
await result.current.refetch();
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(2);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
// ========================================
// useV2SearchManager Hook
// ========================================
// React hook for v2 search management via smart_search tool
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ========== Types ==========
export interface V2IndexStatus {
indexed: boolean;
totalFiles: number;
totalChunks: number;
lastIndexedAt: string | null;
dbSizeBytes: number;
vectorDimension: number | null;
ftsEnabled: boolean;
}
export interface V2SearchTestResult {
query: string;
results: Array<{
file: string;
score: number;
snippet: string;
}>;
timingMs: number;
totalResults: number;
}
export interface UseV2SearchManagerReturn {
status: V2IndexStatus | null;
isLoadingStatus: boolean;
statusError: Error | null;
refetchStatus: () => void;
search: (query: string) => Promise<V2SearchTestResult>;
isSearching: boolean;
searchResult: V2SearchTestResult | null;
reindex: () => Promise<void>;
isReindexing: boolean;
}
// ========== API helpers ==========
async function fetchWithJson<T>(url: string, body?: Record<string, unknown>): Promise<T> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
async function fetchV2Status(): Promise<V2IndexStatus> {
const data = await fetchWithJson<{ result?: V2IndexStatus; error?: string }>('/api/tools', {
tool_name: 'smart_search',
action: 'status',
});
if (data.error) {
throw new Error(data.error);
}
// Provide defaults for fields that may be missing
return {
indexed: false,
totalFiles: 0,
totalChunks: 0,
lastIndexedAt: null,
dbSizeBytes: 0,
vectorDimension: null,
ftsEnabled: false,
...data.result,
};
}
async function fetchV2Search(query: string): Promise<V2SearchTestResult> {
const data = await fetchWithJson<{ result?: V2SearchTestResult; error?: string }>('/api/tools', {
tool_name: 'smart_search',
action: 'search',
params: { query, limit: 10 },
});
if (data.error) {
throw new Error(data.error);
}
return data.result ?? { query, results: [], timingMs: 0, totalResults: 0 };
}
async function fetchV2Reindex(): Promise<void> {
const data = await fetchWithJson<{ error?: string }>('/api/tools', {
tool_name: 'smart_search',
action: 'reindex',
});
if (data.error) {
throw new Error(data.error);
}
}
// ========== Query Keys ==========
export const v2SearchKeys = {
all: ['v2-search'] as const,
status: () => [...v2SearchKeys.all, 'status'] as const,
};
// ========== Hook ==========
export function useV2SearchManager(): UseV2SearchManagerReturn {
const queryClient = useQueryClient();
const [searchResult, setSearchResult] = useState<V2SearchTestResult | null>(null);
// Status query
const statusQuery = useQuery({
queryKey: v2SearchKeys.status(),
queryFn: fetchV2Status,
staleTime: 30_000,
retry: 1,
});
// Search mutation
const searchMutation = useMutation({
mutationFn: (query: string) => fetchV2Search(query),
onSuccess: (data) => {
setSearchResult(data);
},
});
// Reindex mutation
const reindexMutation = useMutation({
mutationFn: fetchV2Reindex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: v2SearchKeys.status() });
},
});
const search = useCallback(async (query: string) => {
const result = await searchMutation.mutateAsync(query);
return result;
}, [searchMutation]);
const reindex = useCallback(async () => {
await reindexMutation.mutateAsync();
}, [reindexMutation]);
return {
status: statusQuery.data ?? null,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error as Error | null,
refetchStatus: () => statusQuery.refetch(),
search,
isSearching: searchMutation.isPending,
searchResult,
reindex,
isReindexing: reindexMutation.isPending,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,390 +1,28 @@
{
"title": "CodexLens",
"description": "Semantic code search engine",
"bootstrap": "Bootstrap",
"bootstrapping": "Bootstrapping...",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"confirmUninstall": "Are you sure you want to uninstall CodexLens? This action cannot be undone.",
"confirmUninstallTitle": "Confirm Uninstall",
"notInstalled": "CodexLens is not installed",
"comingSoon": "Coming Soon",
"tabs": {
"overview": "Overview",
"settings": "Settings",
"models": "Models",
"search": "Search",
"advanced": "Advanced"
},
"overview": {
"status": {
"installation": "Installation Status",
"ready": "Ready",
"notReady": "Not Ready",
"version": "Version",
"indexPath": "Index Path",
"indexCount": "Index Count"
},
"notInstalled": {
"title": "CodexLens Not Installed",
"message": "Please install CodexLens to use semantic code search features."
},
"actions": {
"title": "Quick Actions",
"ftsFull": "FTS Full",
"ftsFullDesc": "Rebuild full-text index",
"ftsIncremental": "FTS Incremental",
"ftsIncrementalDesc": "Incremental update full-text index",
"vectorFull": "Vector Full",
"vectorFullDesc": "Rebuild vector index",
"vectorIncremental": "Vector Incremental",
"vectorIncrementalDesc": "Incremental update vector index"
},
"venv": {
"title": "Python Virtual Environment Details",
"pythonVersion": "Python Version",
"venvPath": "Virtual Environment Path",
"lastCheck": "Last Check Time"
}
},
"index": {
"operationComplete": "Index Operation Complete",
"operationFailed": "Index Operation Failed",
"noProject": "No Project Selected",
"noProjectDesc": "Please open a project to perform index operations.",
"starting": "Starting index operation...",
"cancelFailed": "Failed to cancel operation",
"unknownError": "An unknown error occurred",
"complete": "Complete",
"failed": "Failed",
"cancelled": "Cancelled",
"inProgress": "In Progress"
},
"semantic": {
"installTitle": "Install Semantic Search",
"installDescription": "Install FastEmbed and semantic search dependencies with GPU acceleration support.",
"installInfo": "GPU acceleration requires compatible hardware. CPU mode works on all systems but is slower.",
"gpu": {
"cpu": "CPU Mode",
"cpuDesc": "Universal compatibility, slower processing. Works on all systems.",
"directml": "DirectML (Windows GPU)",
"directmlDesc": "Best for Windows with AMD/Intel GPUs. Recommended for most users.",
"cuda": "CUDA (NVIDIA GPU)",
"cudaDesc": "Best performance with NVIDIA GPUs. Requires CUDA toolkit."
},
"recommended": "Recommended",
"install": "Install",
"installing": "Installing...",
"installSuccess": "Installation Complete",
"installSuccessDesc": "Semantic search installed successfully with {mode} mode",
"installFailed": "Installation Failed",
"unknownError": "An unknown error occurred"
},
"settings": {
"currentCount": "Current Index Count",
"currentWorkers": "Current Workers",
"currentBatchSize": "Current Batch Size",
"configTitle": "Basic Configuration",
"indexDir": {
"label": "Index Directory",
"placeholder": "~/.codexlens/indexes",
"hint": "Directory path for storing code indexes"
},
"maxWorkers": {
"label": "Max Workers",
"hint": "API concurrent processing threads (1-32)"
},
"batchSize": {
"label": "Batch Size",
"hint": "Number of files processed per batch (1-64)"
},
"validation": {
"indexDirRequired": "Index directory is required",
"maxWorkersRange": "Workers must be between 1 and 32",
"batchSizeRange": "Batch size must be between 1 and 64"
},
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Configuration saved",
"saveFailed": "Save failed",
"configUpdated": "Configuration updated successfully",
"saveError": "Error saving configuration",
"unknownError": "An unknown error occurred"
},
"gpu": {
"title": "GPU Settings",
"status": "GPU Status",
"title": "Search Manager",
"description": "V2 semantic search index management",
"reindex": "Reindex",
"reindexing": "Reindexing...",
"statusError": "Failed to load search index status",
"indexStatus": {
"title": "Index Status",
"status": "Status",
"ready": "Ready",
"notIndexed": "Not Indexed",
"files": "Files",
"dbSize": "DB Size",
"lastIndexed": "Last Indexed",
"chunks": "Chunks",
"vectorDim": "Vector Dim",
"enabled": "Enabled",
"available": "Available",
"unavailable": "Unavailable",
"supported": "Your system supports GPU acceleration",
"notSupported": "Your system does not support GPU acceleration",
"detect": "Detect",
"detectSuccess": "GPU detection completed",
"detectFailed": "GPU detection failed",
"detectComplete": "Detected {count} GPU devices",
"detectError": "Error detecting GPU",
"select": "Select",
"selected": "Selected",
"active": "Current",
"selectSuccess": "GPU selected",
"selectFailed": "GPU selection failed",
"gpuSelected": "GPU device enabled",
"selectError": "Error selecting GPU",
"reset": "Reset",
"resetSuccess": "GPU reset",
"resetFailed": "GPU reset failed",
"gpuReset": "GPU disabled, will use CPU",
"resetError": "Error resetting GPU",
"unknownError": "An unknown error occurred",
"noDevices": "No GPU devices detected",
"notAvailable": "GPU functionality not available",
"unknownDevice": "Unknown device",
"type": "Type",
"discrete": "Discrete GPU",
"integrated": "Integrated GPU",
"driver": "Driver Version",
"memory": "Memory"
"disabled": "Disabled",
"unavailable": "Index status unavailable"
},
"advanced": {
"warningTitle": "Sensitive Operations Warning",
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.",
"loadError": "Failed to load environment variables",
"loadErrorDesc": "Unable to fetch environment configuration. Please check if CodexLens is properly installed.",
"currentVars": "Current Environment Variables",
"settingsVars": "Settings Variables",
"customVars": "Custom Variables",
"envEditor": "Environment Variable Editor",
"envFile": "File",
"envContent": "Environment Variable Content",
"envPlaceholder": "# Comment lines start with #\nKEY=value\nANOTHER_KEY=\"another value\"",
"envHint": "One variable per line, format: KEY=value. Comment lines start with #",
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Environment variables saved",
"saveFailed": "Save failed",
"envUpdated": "Environment variables updated, restart service to take effect",
"saveError": "Error saving environment variables",
"unknownError": "An unknown error occurred",
"validation": {
"invalidKeys": "Invalid variable names: {keys}"
},
"helpTitle": "Format Help",
"helpComment": "Comment lines start with #",
"helpFormat": "Variable format: KEY=value",
"helpQuotes": "Values with spaces should use quotes",
"helpRestart": "Restart service after changes to take effect"
},
"downloadedModels": "Downloaded Models",
"noConfiguredModels": "No models configured",
"noLocalModels": "No models downloaded",
"models": {
"title": "Model Management",
"searchPlaceholder": "Search models...",
"downloading": "Downloading...",
"status": {
"downloaded": "Downloaded",
"available": "Available"
},
"types": {
"embedding": "Embedding Models",
"reranker": "Reranker Models"
},
"filters": {
"label": "Filter",
"all": "All"
},
"actions": {
"download": "Download",
"delete": "Delete",
"cancel": "Cancel"
},
"custom": {
"title": "Custom Model",
"placeholder": "HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)",
"description": "Download custom models from HuggingFace. Ensure the model name is correct."
},
"deleteConfirm": "Are you sure you want to delete model {modelName}?",
"notInstalled": {
"title": "CodexLens Not Installed",
"description": "Please install CodexLens to use model management features."
},
"error": {
"title": "Failed to load models",
"description": "Unable to fetch model list. Please check if CodexLens is properly installed."
},
"empty": {
"title": "No models found",
"description": "No models are available. Try downloading models from the list.",
"filtered": "No models match your filter",
"filteredDesc": "Try adjusting your search or filter criteria"
}
},
"search": {
"type": "Search Type",
"content": "Content Search",
"files": "File Search",
"symbol": "Symbol Search",
"semantic": "Semantic Search (LSP)",
"mode": "Mode",
"mode.semantic": "Semantic (default)",
"mode.exact": "Exact (FTS)",
"mode.fuzzy": "Fuzzy",
"semanticMode": "Search Mode",
"semanticMode.fusion": "Fusion Search",
"semanticMode.vector": "Vector Search",
"semanticMode.structural": "Structural Search",
"fusionStrategy": "Fusion Strategy",
"fusionStrategy.rrf": "RRF (default)",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"stagedStage2Mode": "Staged Stage 2",
"stagedStage2Mode.precomputed": "Precomputed (graph_neighbors)",
"stagedStage2Mode.realtime": "Realtime (LSP)",
"stagedStage2Mode.static_global_graph": "Static Global Graph",
"lspStatus": "LSP Status",
"lspAvailable": "Semantic search available",
"lspUnavailable": "Semantic search unavailable",
"lspNoVector": "Vector index required",
"lspNoSemantic": "Semantic dependencies required",
"query": "Query",
"queryPlaceholder": "Enter search query...",
"searchTest": {
"title": "Search Test",
"placeholder": "Enter search query...",
"button": "Search",
"searching": "Searching...",
"results": "Results",
"resultsCount": "results",
"notInstalled": {
"title": "CodexLens Not Installed",
"description": "Please install CodexLens to use semantic code search features."
}
},
"reranker": {
"title": "Reranker Configuration",
"description": "Configure the reranker backend, model, and provider for search result ranking.",
"backend": "Backend",
"backendHint": "Inference backend for reranking",
"model": "Model",
"modelHint": "Reranker model name or LiteLLM endpoint",
"provider": "API Provider",
"providerHint": "API provider for reranker service",
"apiKeyStatus": "API Key",
"apiKeySet": "Configured",
"apiKeyNotSet": "Not configured",
"configSource": "Config Source",
"save": "Save Reranker Config",
"saving": "Saving...",
"saveSuccess": "Reranker configuration saved",
"saveFailed": "Failed to save reranker configuration",
"noBackends": "No backends available",
"noModels": "No models available",
"noProviders": "No providers available",
"litellmModels": "LiteLLM Models",
"selectBackend": "Select backend...",
"selectModel": "Select model...",
"selectProvider": "Select provider..."
},
"envGroup": {
"embedding": "Embedding",
"reranker": "Reranker",
"search": "Search Pipeline",
"indexing": "Indexing"
},
"envField": {
"backend": "Backend",
"model": "Model",
"localModel": "Local Model",
"apiUrl": "API URL",
"apiKey": "API Key",
"multiEndpoints": "Multi-Endpoint",
"embedDim": "Embed Dimension",
"apiConcurrency": "Concurrency",
"maxTokensPerBatch": "Max Tokens/Batch",
"useGpu": "Device",
"topKResults": "Top K Results",
"batchSize": "Batch Size",
"binaryTopK": "Binary Top K",
"annTopK": "ANN Top K",
"ftsTopK": "FTS Top K",
"fusionK": "Fusion K",
"codeAwareChunking": "Code-Aware Chunking",
"indexWorkers": "Index Workers",
"maxFileSize": "Max File Size (bytes)",
"hnswEf": "HNSW ef",
"hnswM": "HNSW M"
},
"install": {
"title": "Install CodexLens",
"description": "Set up Python virtual environment and install CodexLens package.",
"checklist": "What will be installed",
"pythonVenv": "Python Virtual Environment",
"pythonVenvDesc": "Isolated Python environment for CodexLens",
"codexlensPackage": "CodexLens Package",
"codexlensPackageDesc": "Core semantic code search engine",
"sqliteFts": "SQLite FTS5",
"sqliteFtsDesc": "Full-text search extension for fast code lookup",
"location": "Install Location",
"locationPath": "~/.codexlens/venv",
"timeEstimate": "Installation may take 1-3 minutes depending on network speed.",
"stage": {
"creatingVenv": "Creating Python virtual environment...",
"installingPip": "Installing pip dependencies...",
"installingPackage": "Installing CodexLens package...",
"settingUpDeps": "Setting up dependencies...",
"finalizing": "Finalizing installation...",
"complete": "Installation complete!"
},
"installNow": "Install Now",
"installing": "Installing..."
},
"mcp": {
"title": "CCW Tools Registry",
"loading": "Loading tools...",
"error": "Failed to load tools",
"errorDesc": "Unable to fetch CCW tools list. Please check if the server is running.",
"emptyDesc": "No tools are currently registered.",
"totalCount": "{count} tools",
"codexLensSection": "CodexLens Tools",
"otherSection": "Other Tools"
},
"watcher": {
"title": "File Watcher",
"status": {
"running": "Running",
"stopped": "Stopped"
},
"eventsProcessed": "Events Processed",
"uptime": "Uptime",
"start": "Start Watcher",
"starting": "Starting...",
"stop": "Stop Watcher",
"stopping": "Stopping...",
"started": "File watcher started",
"stopped": "File watcher stopped"
},
"lsp": {
"title": "LSP Server",
"status": {
"running": "Running",
"stopped": "Stopped"
},
"projects": "Projects",
"embeddings": "Embeddings",
"modes": "Modes",
"semanticAvailable": "Semantic",
"available": "Available",
"unavailable": "Unavailable",
"start": "Start Server",
"starting": "Starting...",
"stop": "Stop Server",
"stopping": "Stopping...",
"restart": "Restart",
"restarting": "Restarting...",
"started": "LSP server started",
"stopped": "LSP server stopped",
"restarted": "LSP server restarted"
"results": "results",
"noResults": "No results found"
}
}

View File

@@ -27,7 +27,7 @@
"prompts": "Prompt History",
"settings": "Settings",
"mcp": "MCP Servers",
"codexlens": "CodexLens",
"codexlens": "Search Manager",
"apiSettings": "API Settings",
"endpoints": "CLI Endpoints",
"installations": "Installations",

View File

@@ -1,390 +1,28 @@
{
"title": "CodexLens",
"description": "语义代码搜索引擎",
"bootstrap": "引导安装",
"bootstrapping": "安装中...",
"uninstall": "卸载",
"uninstalling": "卸载中...",
"confirmUninstall": "确定要卸载 CodexLens 吗?此操作无法撤销。",
"confirmUninstallTitle": "确认卸载",
"notInstalled": "CodexLens 尚未安装",
"comingSoon": "即将推出",
"tabs": {
"overview": "概览",
"settings": "设置",
"models": "模型",
"search": "搜索",
"advanced": "高级"
},
"overview": {
"status": {
"installation": "安装状态",
"ready": "就绪",
"notReady": "未就绪",
"version": "版本",
"indexPath": "索引路径",
"indexCount": "索引数量"
},
"notInstalled": {
"title": "CodexLens 未安装",
"message": "请先安装 CodexLens 以使用语义代码搜索功能。"
},
"actions": {
"title": "快速操作",
"ftsFull": "FTS 全量",
"ftsFullDesc": "重建全文索引",
"ftsIncremental": "FTS 增量",
"ftsIncrementalDesc": "增量更新全文索引",
"vectorFull": "向量全量",
"vectorFullDesc": "重建向量索引",
"vectorIncremental": "向量增量",
"vectorIncrementalDesc": "增量更新向量索引"
},
"venv": {
"title": "Python 虚拟环境详情",
"pythonVersion": "Python 版本",
"venvPath": "虚拟环境路径",
"lastCheck": "最后检查时间"
}
},
"index": {
"operationComplete": "索引操作完成",
"operationFailed": "索引操作失败",
"noProject": "未选择项目",
"noProjectDesc": "请打开一个项目以执行索引操作。",
"starting": "正在启动索引操作...",
"cancelFailed": "取消操作失败",
"unknownError": "发生未知错误",
"complete": "完成",
"failed": "失败",
"cancelled": "已取消",
"inProgress": "进行中"
},
"semantic": {
"installTitle": "安装语义搜索",
"installDescription": "安装 FastEmbed 和语义搜索依赖,支持 GPU 加速。",
"installInfo": "GPU 加速需要兼容的硬件。CPU 模式在所有系统上都可用,但速度较慢。",
"gpu": {
"cpu": "CPU 模式",
"cpuDesc": "通用兼容,处理较慢。适用于所有系统。",
"directml": "DirectMLWindows GPU",
"directmlDesc": "最适合带 AMD/Intel GPU 的 Windows 系统。推荐大多数用户使用。",
"cuda": "CUDANVIDIA GPU",
"cudaDesc": "NVIDIA GPU 性能最佳。需要 CUDA 工具包。"
},
"recommended": "推荐",
"install": "安装",
"installing": "安装中...",
"installSuccess": "安装完成",
"installSuccessDesc": "语义搜索已成功安装,使用 {mode} 模式",
"installFailed": "安装失败",
"unknownError": "发生未知错误"
},
"settings": {
"currentCount": "当前索引数量",
"currentWorkers": "当前工作线程",
"currentBatchSize": "当前批次大小",
"configTitle": "基本配置",
"indexDir": {
"label": "索引目录",
"placeholder": "~/.codexlens/indexes",
"hint": "存储代码索引的目录路径"
},
"maxWorkers": {
"label": "最大工作线程",
"hint": "API 并发处理线程数 (1-32)"
},
"batchSize": {
"label": "批次大小",
"hint": "每次批量处理的文件数量 (1-64)"
},
"validation": {
"indexDirRequired": "索引目录不能为空",
"maxWorkersRange": "工作线程数必须在 1-32 之间",
"batchSizeRange": "批次大小必须在 1-64 之间"
},
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "配置已保存",
"saveFailed": "保存失败",
"configUpdated": "配置更新成功",
"saveError": "保存配置时出错",
"unknownError": "发生未知错误"
},
"gpu": {
"title": "GPU 设置",
"status": "GPU 状态",
"title": "搜索管理",
"description": "V2 语义搜索索引管理",
"reindex": "重建索引",
"reindexing": "重建中...",
"statusError": "加载搜索索引状态失败",
"indexStatus": {
"title": "索引状态",
"status": "状态",
"ready": "就绪",
"notIndexed": "未索引",
"files": "文件数",
"dbSize": "数据库大小",
"lastIndexed": "上次索引",
"chunks": "分块数",
"vectorDim": "向量维度",
"enabled": "已启用",
"available": "用",
"unavailable": "不可用",
"supported": "您的系统支持 GPU 加速",
"notSupported": "您的系统不支持 GPU 加速",
"detect": "检测",
"detectSuccess": "GPU 检测完成",
"detectFailed": "GPU 检测失败",
"detectComplete": "检测到 {count} 个 GPU 设备",
"detectError": "检测 GPU 时出错",
"select": "选择",
"selected": "已选择",
"active": "当前",
"selectSuccess": "GPU 已选择",
"selectFailed": "GPU 选择失败",
"gpuSelected": "GPU 设备已启用",
"selectError": "选择 GPU 时出错",
"reset": "重置",
"resetSuccess": "GPU 已重置",
"resetFailed": "GPU 重置失败",
"gpuReset": "GPU 已禁用,将使用 CPU",
"resetError": "重置 GPU 时出错",
"unknownError": "发生未知错误",
"noDevices": "未检测到 GPU 设备",
"notAvailable": "GPU 功能不可用",
"unknownDevice": "未知设备",
"type": "类型",
"discrete": "独立显卡",
"integrated": "集成显卡",
"driver": "驱动版本",
"memory": "显存"
"disabled": "已禁用",
"unavailable": "索引状态不可用"
},
"advanced": {
"warningTitle": "敏感操作警告",
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。",
"loadError": "加载环境变量失败",
"loadErrorDesc": "无法获取环境配置。请检查 CodexLens 是否正确安装。",
"currentVars": "当前环境变量",
"settingsVars": "设置变量",
"customVars": "自定义变量",
"envEditor": "环境变量编辑器",
"envFile": "文件",
"envContent": "环境变量内容",
"envPlaceholder": "# 注释行以 # 开头\nKEY=value\nANOTHER_KEY=\"another value\"",
"envHint": "每行一个变量格式KEY=value。注释行以 # 开头",
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "环境变量已保存",
"saveFailed": "保存失败",
"envUpdated": "环境变量更新成功,重启服务后生效",
"saveError": "保存环境变量时出错",
"unknownError": "发生未知错误",
"validation": {
"invalidKeys": "无效的变量名: {keys}"
},
"helpTitle": "格式说明",
"helpComment": "注释行以 # 开头",
"helpFormat": "变量格式KEY=value",
"helpQuotes": "包含空格的值建议使用引号",
"helpRestart": "修改后需要重启服务才能生效"
},
"downloadedModels": "已下载模型",
"noConfiguredModels": "无已配置模型",
"noLocalModels": "无已下载模型",
"models": {
"title": "模型管理",
"searchPlaceholder": "搜索模型...",
"downloading": "下载中...",
"status": {
"downloaded": "已下载",
"available": "可用"
},
"types": {
"embedding": "嵌入模型",
"reranker": "重排序模型"
},
"filters": {
"label": "筛选",
"all": "全部"
},
"actions": {
"download": "下载",
"delete": "删除",
"cancel": "取消"
},
"custom": {
"title": "自定义模型",
"placeholder": "HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)",
"description": "从 HuggingFace 下载自定义模型。请确保模型名称正确。"
},
"deleteConfirm": "确定要删除模型 {modelName} 吗?",
"notInstalled": {
"title": "CodexLens 未安装",
"description": "请先安装 CodexLens 以使用模型管理功能。"
},
"error": {
"title": "加载模型失败",
"description": "无法获取模型列表。请检查 CodexLens 是否正确安装。"
},
"empty": {
"title": "没有找到模型",
"description": "当前没有可用模型。请从列表中下载模型。",
"filtered": "没有匹配的模型",
"filteredDesc": "尝试调整搜索或筛选条件"
}
},
"search": {
"type": "搜索类型",
"content": "内容搜索",
"files": "文件搜索",
"symbol": "符号搜索",
"semantic": "语义搜索 (LSP)",
"mode": "模式",
"mode.semantic": "语义(默认)",
"mode.exact": "精确FTS",
"mode.fuzzy": "模糊",
"semanticMode": "搜索模式",
"semanticMode.fusion": "融合搜索",
"semanticMode.vector": "向量搜索",
"semanticMode.structural": "结构搜索",
"fusionStrategy": "融合策略",
"fusionStrategy.rrf": "RRF默认",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"stagedStage2Mode": "Stage 2 扩展",
"stagedStage2Mode.precomputed": "预计算 (graph_neighbors)",
"stagedStage2Mode.realtime": "实时 (LSP)",
"stagedStage2Mode.static_global_graph": "静态全局图",
"lspStatus": "LSP 状态",
"lspAvailable": "语义搜索可用",
"lspUnavailable": "语义搜索不可用",
"lspNoVector": "需要先建立向量索引",
"lspNoSemantic": "需要先安装语义依赖",
"query": "查询",
"queryPlaceholder": "输入搜索查询...",
"searchTest": {
"title": "搜索测试",
"placeholder": "输入搜索查询...",
"button": "搜索",
"searching": "搜索中...",
"results": "结果",
"resultsCount": "个结果",
"notInstalled": {
"title": "CodexLens 未安装",
"description": "请先安装 CodexLens 以使用语义代码搜索功能。"
}
},
"reranker": {
"title": "重排序配置",
"description": "配置重排序后端、模型和提供商,用于搜索结果排序。",
"backend": "后端",
"backendHint": "重排序推理后端",
"model": "模型",
"modelHint": "重排序模型名称或 LiteLLM 端点",
"provider": "API 提供商",
"providerHint": "重排序服务的 API 提供商",
"apiKeyStatus": "API 密钥",
"apiKeySet": "已配置",
"apiKeyNotSet": "未配置",
"configSource": "配置来源",
"save": "保存重排序配置",
"saving": "保存中...",
"saveSuccess": "重排序配置已保存",
"saveFailed": "保存重排序配置失败",
"noBackends": "无可用后端",
"noModels": "无可用模型",
"noProviders": "无可用提供商",
"litellmModels": "LiteLLM 模型",
"selectBackend": "选择后端...",
"selectModel": "选择模型...",
"selectProvider": "选择提供商..."
},
"envGroup": {
"embedding": "嵌入模型",
"reranker": "重排序",
"search": "搜索流水线",
"indexing": "索引"
},
"envField": {
"backend": "后端",
"model": "模型",
"localModel": "本地模型",
"apiUrl": "API 地址",
"apiKey": "API 密钥",
"multiEndpoints": "多端点",
"embedDim": "向量维度",
"apiConcurrency": "并发数",
"maxTokensPerBatch": "每批最大Token数",
"useGpu": "设备",
"topKResults": "Top K 结果数",
"batchSize": "批次大小",
"binaryTopK": "二值粗筛 K",
"annTopK": "ANN 精筛 K",
"ftsTopK": "全文搜索 K",
"fusionK": "融合 K",
"codeAwareChunking": "代码感知分块",
"indexWorkers": "索引线程数",
"maxFileSize": "最大文件大小(字节)",
"hnswEf": "HNSW ef",
"hnswM": "HNSW M"
},
"install": {
"title": "安装 CodexLens",
"description": "设置 Python 虚拟环境并安装 CodexLens 包。",
"checklist": "将要安装的内容",
"pythonVenv": "Python 虚拟环境",
"pythonVenvDesc": "CodexLens 的隔离 Python 环境",
"codexlensPackage": "CodexLens 包",
"codexlensPackageDesc": "核心语义代码搜索引擎",
"sqliteFts": "SQLite FTS5",
"sqliteFtsDesc": "用于快速代码查找的全文搜索扩展",
"location": "安装位置",
"locationPath": "~/.codexlens/venv",
"timeEstimate": "安装可能需要 1-3 分钟,取决于网络速度。",
"stage": {
"creatingVenv": "正在创建 Python 虚拟环境...",
"installingPip": "正在安装 pip 依赖...",
"installingPackage": "正在安装 CodexLens 包...",
"settingUpDeps": "正在设置依赖项...",
"finalizing": "正在完成安装...",
"complete": "安装完成!"
},
"installNow": "立即安装",
"installing": "安装中..."
},
"mcp": {
"title": "CCW 工具注册表",
"loading": "加载工具中...",
"error": "加载工具失败",
"errorDesc": "无法获取 CCW 工具列表。请检查服务器是否正在运行。",
"emptyDesc": "当前没有已注册的工具。",
"totalCount": "{count} 个工具",
"codexLensSection": "CodexLens 工具",
"otherSection": "其他工具"
},
"watcher": {
"title": "文件监听器",
"status": {
"running": "运行中",
"stopped": "已停止"
},
"eventsProcessed": "已处理事件",
"uptime": "运行时间",
"start": "启动监听",
"starting": "启动中...",
"stop": "停止监听",
"stopping": "停止中...",
"started": "文件监听器已启动",
"stopped": "文件监听器已停止"
},
"lsp": {
"title": "LSP 服务器",
"status": {
"running": "运行中",
"stopped": "已停止"
},
"projects": "项目数",
"embeddings": "嵌入模型",
"modes": "模式",
"semanticAvailable": "语义搜索",
"available": "可用",
"unavailable": "不可用",
"start": "启动服务",
"starting": "启动中...",
"stop": "停止服务",
"stopping": "停止中...",
"restart": "重启",
"restarting": "重启中...",
"started": "LSP 服务器已启动",
"stopped": "LSP 服务器已停止",
"restarted": "LSP 服务器已重启"
"results": "个结果",
"noResults": "未找到结果"
}
}

View File

@@ -27,7 +27,7 @@
"prompts": "提示历史",
"settings": "设置",
"mcp": "MCP 服务器",
"codexlens": "CodexLens",
"codexlens": "搜索管理",
"apiSettings": "API 设置",
"endpoints": "CLI 端点",
"installations": "安装",

View File

@@ -1,361 +1,196 @@
// ========================================
// CodexLens Manager Page Tests
// CodexLens Manager Page Tests (v2)
// ========================================
// Integration tests for CodexLens manager page with tabs
// Tests for v2 search management page
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import { render, screen } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { CodexLensManagerPage } from './CodexLensManagerPage';
// Mock api module
vi.mock('@/lib/api', () => ({
fetchCodexLensDashboardInit: vi.fn(),
bootstrapCodexLens: vi.fn(),
uninstallCodexLens: vi.fn(),
// Mock the v2 search manager hook
vi.mock('@/hooks/useV2SearchManager', () => ({
useV2SearchManager: vi.fn(),
}));
// Mock hooks
vi.mock('@/hooks/useCodexLens', () => ({
useCodexLensDashboard: vi.fn(),
}));
import { useV2SearchManager } from '@/hooks/useV2SearchManager';
vi.mock('@/hooks/useCodexLens', () => ({
useCodexLensDashboard: vi.fn(),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: vi.fn(() => ({
success: vi.fn(),
error: vi.fn(),
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
addToast: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
})),
}));
// Mock the mutations hook separately
vi.mock('@/hooks/useCodexLens', async () => {
return {
useCodexLensDashboard: (await import('@/hooks/useCodexLens')).useCodexLensDashboard,
useCodexLensMutations: vi.fn(),
};
});
// Mock window.confirm
global.confirm = vi.fn(() => true);
const mockDashboardData = {
installed: true,
status: {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
},
config: {
index_dir: '~/.codexlens/indexes',
index_count: 100,
},
semantic: { available: true },
const mockStatus = {
indexed: true,
totalFiles: 150,
totalChunks: 1200,
lastIndexedAt: '2026-03-17T10:00:00Z',
dbSizeBytes: 5242880,
vectorDimension: 384,
ftsEnabled: true,
};
const mockMutations = {
bootstrap: vi.fn().mockResolvedValue({ success: true }),
uninstall: vi.fn().mockResolvedValue({ success: true }),
isBootstrapping: false,
isUninstalling: false,
const defaultHookReturn = {
status: mockStatus,
isLoadingStatus: false,
statusError: null,
refetchStatus: vi.fn(),
search: vi.fn().mockResolvedValue({
query: 'test',
results: [],
timingMs: 12.5,
totalResults: 0,
}),
isSearching: false,
searchResult: null,
reindex: vi.fn().mockResolvedValue(undefined),
isReindexing: false,
};
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
describe('CodexLensManagerPage', () => {
describe('CodexLensManagerPage (v2)', () => {
beforeEach(() => {
vi.clearAllMocks();
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
(vi.mocked(useV2SearchManager) as any).mockReturnValue(defaultHookReturn);
});
describe('when installed', () => {
beforeEach(() => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should render page title and description', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
});
it('should render all tabs', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
expect(screen.getByText(/Settings/i)).toBeInTheDocument();
expect(screen.getByText(/Models/i)).toBeInTheDocument();
expect(screen.getByText(/Advanced/i)).toBeInTheDocument();
});
it('should show uninstall button when installed', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
});
it('should switch between tabs', async () => {
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const settingsTab = screen.getByText(/Settings/i);
await user.click(settingsTab);
expect(settingsTab).toHaveAttribute('data-state', 'active');
});
it('should call refresh on button click', async () => {
const refetch = vi.fn();
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
expect(refetch).toHaveBeenCalledOnce();
});
it('should render page title', () => {
render(<CodexLensManagerPage />);
// The title comes from i18n codexlens.title
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
describe('when not installed', () => {
beforeEach(() => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should show bootstrap button', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
});
it('should show not installed alert', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/CodexLens is not installed/i)).toBeInTheDocument();
});
it('should call bootstrap on button click', async () => {
const bootstrap = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
bootstrap,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const bootstrapButton = screen.getByText(/Bootstrap/i);
await user.click(bootstrapButton);
await waitFor(() => {
expect(bootstrap).toHaveBeenCalledOnce();
});
});
it('should render index status section', () => {
render(<CodexLensManagerPage />);
// Check for file count display
expect(screen.getByText('150')).toBeInTheDocument();
});
describe('uninstall flow', () => {
beforeEach(() => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
});
it('should show confirmation dialog on uninstall', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
});
it('should call uninstall when confirmed', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
await waitFor(() => {
expect(uninstall).toHaveBeenCalledOnce();
});
});
it('should not call uninstall when cancelled', async () => {
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
const uninstall = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
expect(uninstall).not.toHaveBeenCalled();
});
it('should render search input', () => {
render(<CodexLensManagerPage />);
const input = screen.getByPlaceholderText(/search query/i);
expect(input).toBeInTheDocument();
});
describe('loading states', () => {
it('should show loading skeleton when loading', () => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
// Check for skeleton or loading indicator
const refreshButton = screen.getByText(/Refresh/i);
expect(refreshButton).toBeDisabled();
it('should call refetchStatus on refresh click', async () => {
const refetchStatus = vi.fn();
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
refetchStatus,
});
it('should disable refresh button when fetching', () => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: true,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
const user = userEvent.setup();
render(<CodexLensManagerPage />);
render(<CodexLensManagerPage />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
const refreshButton = screen.getByText(/Refresh/i);
expect(refreshButton).toBeDisabled();
expect(refetchStatus).toHaveBeenCalledOnce();
});
it('should call search when clicking search button', async () => {
const searchFn = vi.fn().mockResolvedValue({
query: 'test query',
results: [],
timingMs: 5,
totalResults: 0,
});
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
search: searchFn,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const input = screen.getByPlaceholderText(/search query/i);
await user.type(input, 'test query');
const searchButton = screen.getByText(/Search/i);
await user.click(searchButton);
expect(searchFn).toHaveBeenCalledWith('test query');
});
it('should display search results', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
searchResult: {
query: 'auth',
results: [
{ file: 'src/auth.ts', score: 0.95, snippet: 'export function authenticate()' },
],
timingMs: 8.2,
totalResults: 1,
},
});
render(<CodexLensManagerPage />);
expect(screen.getByText('src/auth.ts')).toBeInTheDocument();
expect(screen.getByText('95.0%')).toBeInTheDocument();
expect(screen.getByText('export function authenticate()')).toBeInTheDocument();
});
it('should call reindex on button click', async () => {
const reindexFn = vi.fn().mockResolvedValue(undefined);
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
reindex: reindexFn,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const reindexButton = screen.getByText(/Reindex/i);
await user.click(reindexButton);
expect(reindexFn).toHaveBeenCalledOnce();
});
it('should show loading skeleton when status is loading', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
status: null,
isLoadingStatus: true,
});
render(<CodexLensManagerPage />);
// Should have pulse animation elements
const pulseElements = document.querySelectorAll('.animate-pulse');
expect(pulseElements.length).toBeGreaterThan(0);
});
it('should show error alert when status fetch fails', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
status: null,
statusError: new Error('Network error'),
});
render(<CodexLensManagerPage />);
// Error message should be visible
expect(screen.getByText(/Failed to load/i)).toBeInTheDocument();
});
it('should show not indexed state', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
status: {
...mockStatus,
indexed: false,
totalFiles: 0,
totalChunks: 0,
},
});
render(<CodexLensManagerPage />);
expect(screen.getByText(/Not Indexed/i)).toBeInTheDocument();
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should display translated text in Chinese', () => {
render(<CodexLensManagerPage />, { locale: 'zh' });
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
expect(screen.getByText(/语义代码搜索引擎/i)).toBeInTheDocument();
expect(screen.getByText(/概览/i)).toBeInTheDocument();
expect(screen.getByText(/设置/i)).toBeInTheDocument();
expect(screen.getByText(/模型/i)).toBeInTheDocument();
expect(screen.getByText(/高级/i)).toBeInTheDocument();
});
it('should display translated uninstall button', () => {
render(<CodexLensManagerPage />, { locale: 'zh' });
expect(screen.getByText(/卸载/i)).toBeInTheDocument();
});
});
describe('error states', () => {
it('should handle API errors gracefully', () => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: false,
isFetching: false,
error: new Error('API Error'),
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
// Page should still render even with error
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
// Page title from zh codexlens.json
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
});
});

View File

@@ -1,85 +1,67 @@
// ========================================
// CodexLens Manager Page
// CodexLens Manager Page (v2)
// ========================================
// Manage CodexLens semantic code search with tabbed interface
// Supports Overview, Settings, Models, and Advanced tabs
// V2 search management interface with index status, search test, and configuration
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Sparkles,
Search,
RefreshCw,
Download,
Trash2,
Database,
Zap,
AlertCircle,
CheckCircle2,
Clock,
FileText,
HardDrive,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { OverviewTab } from '@/components/codexlens/OverviewTab';
import { SettingsTab } from '@/components/codexlens/SettingsTab';
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
import { ModelsTab } from '@/components/codexlens/ModelsTab';
import { SearchTab } from '@/components/codexlens/SearchTab';
import { SemanticInstallDialog } from '@/components/codexlens/SemanticInstallDialog';
import { InstallProgressOverlay } from '@/components/codexlens/InstallProgressOverlay';
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
import { useV2SearchManager } from '@/hooks';
import { cn } from '@/lib/utils';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
}
export function CodexLensManagerPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState('overview');
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
const [isSemanticInstallOpen, setIsSemanticInstallOpen] = useState(false);
const [isInstallOverlayOpen, setIsInstallOverlayOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const {
installed,
status,
config,
semantic,
isLoading,
isFetching,
refetch,
} = useCodexLensDashboard();
isLoadingStatus,
statusError,
refetchStatus,
search,
isSearching,
searchResult,
reindex,
isReindexing,
} = useV2SearchManager();
const {
bootstrap,
isBootstrapping,
uninstall,
isUninstalling,
} = useCodexLensMutations();
const handleRefresh = () => {
refetch();
const handleSearch = async () => {
if (!searchQuery.trim()) return;
await search(searchQuery.trim());
};
const handleBootstrap = () => {
setIsInstallOverlayOpen(true);
};
const handleBootstrapInstall = async () => {
const result = await bootstrap();
return result;
};
const handleUninstall = async () => {
const result = await uninstall();
if (result.success) {
refetch();
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
setIsUninstallDialogOpen(false);
};
return (
@@ -88,7 +70,7 @@ export function CodexLensManagerPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" />
<Search className="w-6 h-6 text-primary" />
{formatMessage({ id: 'codexlens.title' })}
</h1>
<p className="text-muted-foreground mt-1">
@@ -98,150 +80,196 @@ export function CodexLensManagerPage() {
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleRefresh}
disabled={isFetching}
onClick={refetchStatus}
disabled={isLoadingStatus}
>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
<RefreshCw className={cn('w-4 h-4 mr-2', isLoadingStatus && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
{!installed ? (
<Button
onClick={handleBootstrap}
disabled={isBootstrapping}
>
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} />
{isBootstrapping
? formatMessage({ id: 'codexlens.bootstrapping' })
: formatMessage({ id: 'codexlens.bootstrap' })
}
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setIsSemanticInstallOpen(true)}
disabled={!semantic?.available}
>
<Zap className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.semantic.install' })}
</Button>
<AlertDialog open={isUninstallDialogOpen} onOpenChange={setIsUninstallDialogOpen}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={isUninstalling}
>
<Trash2 className={cn('w-4 h-4 mr-2', isUninstalling && 'animate-spin')} />
{isUninstalling
? formatMessage({ id: 'codexlens.uninstalling' })
: formatMessage({ id: 'codexlens.uninstall' })
}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'codexlens.confirmUninstall' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isUninstalling}>
{formatMessage({ id: 'common.actions.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstall}
disabled={isUninstalling}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isUninstalling
? formatMessage({ id: 'codexlens.uninstalling' })
: formatMessage({ id: 'common.actions.confirm' })
}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
<Button
onClick={() => reindex()}
disabled={isReindexing}
>
<Zap className={cn('w-4 h-4 mr-2', isReindexing && 'animate-spin')} />
{isReindexing
? formatMessage({ id: 'codexlens.reindexing' })
: formatMessage({ id: 'codexlens.reindex' })
}
</Button>
</div>
</div>
{/* Installation Status Alert */}
{!installed && !isLoading && (
<Card className="p-4 bg-warning/10 border-warning/20">
<p className="text-sm text-warning-foreground">
{formatMessage({ id: 'codexlens.notInstalled' })}
</p>
{/* Error Alert */}
{statusError && (
<Card className="p-4 bg-destructive/10 border-destructive/20">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-destructive" />
<p className="text-sm text-destructive">
{formatMessage({ id: 'codexlens.statusError' })}
</p>
</div>
</Card>
)}
{/* Tabbed Interface */}
<TabsNavigation
value={activeTab}
onValueChange={setActiveTab}
tabs={[
{ value: 'overview', label: formatMessage({ id: 'codexlens.tabs.overview' }) },
{ value: 'settings', label: formatMessage({ id: 'codexlens.tabs.settings' }) },
{ value: 'models', label: formatMessage({ id: 'codexlens.tabs.models' }) },
{ value: 'search', label: formatMessage({ id: 'codexlens.tabs.search' }) },
{ value: 'advanced', label: formatMessage({ id: 'codexlens.tabs.advanced' }) },
]}
/>
{/* Index Status Section */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Database className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.indexStatus.title' })}
</h2>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="mt-4">
<OverviewTab
installed={installed}
status={status}
config={config}
isLoading={isLoading}
onRefresh={handleRefresh}
{isLoadingStatus ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-16 bg-muted/50 rounded-lg animate-pulse" />
))}
</div>
) : status ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
{status.indexed ? (
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5" />
)}
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.status' })}
</p>
<p className="text-sm font-medium">
{status.indexed
? formatMessage({ id: 'codexlens.indexStatus.ready' })
: formatMessage({ id: 'codexlens.indexStatus.notIndexed' })
}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
<FileText className="w-5 h-5 text-blue-500 mt-0.5" />
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.files' })}
</p>
<p className="text-sm font-medium">{status.totalFiles.toLocaleString()}</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
<HardDrive className="w-5 h-5 text-purple-500 mt-0.5" />
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.dbSize' })}
</p>
<p className="text-sm font-medium">{formatBytes(status.dbSizeBytes)}</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
<Clock className="w-5 h-5 text-orange-500 mt-0.5" />
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.lastIndexed' })}
</p>
<p className="text-sm font-medium">{formatDate(status.lastIndexedAt)}</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.unavailable' })}
</p>
)}
{status && (
<div className="mt-4 flex gap-4 text-xs text-muted-foreground">
<span>
{formatMessage({ id: 'codexlens.indexStatus.chunks' })}: {status.totalChunks.toLocaleString()}
</span>
{status.vectorDimension && (
<span>
{formatMessage({ id: 'codexlens.indexStatus.vectorDim' })}: {status.vectorDimension}
</span>
)}
<span>
FTS: {status.ftsEnabled
? formatMessage({ id: 'codexlens.indexStatus.enabled' })
: formatMessage({ id: 'codexlens.indexStatus.disabled' })
}
</span>
</div>
)}
</Card>
{/* Search Test Section */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Search className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.searchTest.title' })}
</h2>
<div className="flex gap-2 mb-4">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'codexlens.searchTest.placeholder' })}
className="flex-1 px-3 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<Button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
>
{isSearching ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
{formatMessage({ id: 'codexlens.searchTest.button' })}
</Button>
</div>
)}
{activeTab === 'settings' && (
<div className="mt-4">
<SettingsTab enabled={installed} />
</div>
)}
{searchResult && (
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-muted-foreground">
{searchResult.totalResults} {formatMessage({ id: 'codexlens.searchTest.results' })}
</p>
<p className="text-xs text-muted-foreground">
{searchResult.timingMs.toFixed(1)}ms
</p>
</div>
{activeTab === 'models' && (
<div className="mt-4">
<ModelsTab installed={installed} />
</div>
)}
{activeTab === 'search' && (
<div className="mt-4">
<SearchTab enabled={installed} />
</div>
)}
{activeTab === 'advanced' && (
<div className="mt-4">
<AdvancedTab enabled={installed} />
</div>
)}
{/* Semantic Install Dialog */}
<SemanticInstallDialog
open={isSemanticInstallOpen}
onOpenChange={setIsSemanticInstallOpen}
onSuccess={() => refetch()}
/>
{/* Install Progress Overlay */}
<InstallProgressOverlay
open={isInstallOverlayOpen}
onOpenChange={setIsInstallOverlayOpen}
onInstall={handleBootstrapInstall}
onSuccess={() => refetch()}
/>
{searchResult.results.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{searchResult.results.map((result, idx) => (
<div
key={idx}
className="p-3 rounded-lg border border-border bg-muted/20 hover:bg-muted/40 transition-colors"
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-mono text-primary truncate">
{result.file}
</span>
<span className="text-xs text-muted-foreground ml-2 shrink-0">
{(result.score * 100).toFixed(1)}%
</span>
</div>
<pre className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
{result.snippet}
</pre>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
{formatMessage({ id: 'codexlens.searchTest.noResults' })}
</p>
)}
</div>
)}
</Card>
</div>
);
}

View File

@@ -118,173 +118,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'issues.discovery.status.failed': 'Failed',
'issues.discovery.progress': 'Progress',
'issues.discovery.findings': 'Findings',
// CodexLens
'codexlens.title': 'CodexLens',
'codexlens.description': 'Semantic code search engine',
'codexlens.bootstrap': 'Bootstrap',
'codexlens.bootstrapping': 'Bootstrapping...',
'codexlens.uninstall': 'Uninstall',
'codexlens.uninstalling': 'Uninstalling...',
'codexlens.confirmUninstall': 'Are you sure you want to uninstall CodexLens?',
'codexlens.notInstalled': 'CodexLens is not installed',
'codexlens.comingSoon': 'Coming Soon',
'codexlens.tabs.overview': 'Overview',
'codexlens.tabs.settings': 'Settings',
'codexlens.tabs.models': 'Models',
'codexlens.tabs.advanced': 'Advanced',
'codexlens.overview.status.installation': 'Installation Status',
'codexlens.overview.status.ready': 'Ready',
'codexlens.overview.status.notReady': 'Not Ready',
'codexlens.overview.status.version': 'Version',
'codexlens.overview.status.indexPath': 'Index Path',
'codexlens.overview.status.indexCount': 'Index Count',
'codexlens.overview.notInstalled.title': 'CodexLens Not Installed',
'codexlens.overview.notInstalled.message': 'Please install CodexLens to use semantic code search features.',
'codexlens.overview.actions.title': 'Quick Actions',
'codexlens.overview.actions.ftsFull': 'FTS Full',
'codexlens.overview.actions.ftsFullDesc': 'Rebuild full-text index',
'codexlens.overview.actions.ftsIncremental': 'FTS Incremental',
'codexlens.overview.actions.ftsIncrementalDesc': 'Incremental update full-text index',
'codexlens.overview.actions.vectorFull': 'Vector Full',
'codexlens.overview.actions.vectorFullDesc': 'Rebuild vector index',
'codexlens.overview.actions.vectorIncremental': 'Vector Incremental',
'codexlens.overview.actions.vectorIncrementalDesc': 'Incremental update vector index',
'codexlens.overview.venv.title': 'Python Virtual Environment Details',
'codexlens.overview.venv.pythonVersion': 'Python Version',
'codexlens.overview.venv.venvPath': 'Virtual Environment Path',
'codexlens.overview.venv.lastCheck': 'Last Check Time',
'codexlens.install.title': 'Install CodexLens',
// Env Groups & Fields
'codexlens.envGroup.embedding': 'Embedding',
'codexlens.envGroup.reranker': 'Reranker',
'codexlens.envGroup.concurrency': 'Concurrency',
'codexlens.envGroup.cascade': 'Cascade Search',
'codexlens.envGroup.indexing': 'Indexing',
'codexlens.envGroup.chunking': 'Chunking',
'codexlens.envField.backend': 'Backend',
'codexlens.envField.model': 'Model',
'codexlens.envField.useGpu': 'Use GPU',
'codexlens.envField.highAvailability': 'High Availability',
'codexlens.envField.loadBalanceStrategy': 'Load Balance Strategy',
'codexlens.envField.rateLimitCooldown': 'Rate Limit Cooldown',
'codexlens.envField.enabled': 'Enabled',
'codexlens.envField.topKResults': 'Top K Results',
'codexlens.envField.maxWorkers': 'Max Workers',
'codexlens.envField.batchSize': 'Batch Size',
'codexlens.envField.dynamicBatchSize': 'Dynamic Batch Size',
'codexlens.envField.batchSizeUtilization': 'Utilization Factor',
'codexlens.envField.batchSizeMax': 'Max Batch Size',
'codexlens.envField.charsPerToken': 'Chars Per Token',
'codexlens.envField.searchStrategy': 'Search Strategy',
'codexlens.envField.coarseK': 'Coarse K',
'codexlens.envField.fineK': 'Fine K',
'codexlens.envField.stagedStage2Mode': 'Stage-2 Mode',
'codexlens.envField.stagedClusteringStrategy': 'Clustering Strategy',
'codexlens.envField.stagedClusteringMinSize': 'Cluster Min Size',
'codexlens.envField.enableStagedRerank': 'Enable Rerank',
'codexlens.envField.useAstGrep': 'Use ast-grep',
'codexlens.envField.staticGraphEnabled': 'Static Graph',
'codexlens.envField.staticGraphRelationshipTypes': 'Relationship Types',
'codexlens.envField.stripComments': 'Strip Comments',
'codexlens.envField.stripDocstrings': 'Strip Docstrings',
'codexlens.envField.testFilePenalty': 'Test File Penalty',
'codexlens.envField.docstringWeight': 'Docstring Weight',
'codexlens.install.description': 'Set up Python virtual environment and install CodexLens package.',
'codexlens.install.checklist': 'What will be installed',
'codexlens.install.pythonVenv': 'Python Virtual Environment',
'codexlens.install.pythonVenvDesc': 'Isolated Python environment for CodexLens',
'codexlens.install.codexlensPackage': 'CodexLens Package',
'codexlens.install.codexlensPackageDesc': 'Core semantic code search engine',
'codexlens.install.sqliteFts': 'SQLite FTS5',
'codexlens.install.sqliteFtsDesc': 'Full-text search extension for fast code lookup',
'codexlens.install.location': 'Install Location',
'codexlens.install.locationPath': '~/.codexlens/venv',
'codexlens.install.timeEstimate': 'Installation may take 1-3 minutes depending on network speed.',
'codexlens.install.stage.creatingVenv': 'Creating Python virtual environment...',
'codexlens.install.stage.installingPip': 'Installing pip dependencies...',
'codexlens.install.stage.installingPackage': 'Installing CodexLens package...',
'codexlens.install.stage.settingUpDeps': 'Setting up dependencies...',
'codexlens.install.stage.finalizing': 'Finalizing installation...',
'codexlens.install.stage.complete': 'Installation complete!',
'codexlens.install.installNow': 'Install Now',
'codexlens.install.installing': 'Installing...',
'codexlens.watcher.title': 'File Watcher',
'codexlens.watcher.status.running': 'Running',
'codexlens.watcher.status.stopped': 'Stopped',
'codexlens.watcher.eventsProcessed': 'Events Processed',
'codexlens.watcher.uptime': 'Uptime',
'codexlens.watcher.start': 'Start Watcher',
'codexlens.watcher.starting': 'Starting...',
'codexlens.watcher.stop': 'Stop Watcher',
'codexlens.watcher.stopping': 'Stopping...',
'codexlens.watcher.started': 'File watcher started',
'codexlens.watcher.stopped': 'File watcher stopped',
'codexlens.settings.currentCount': 'Current Index Count',
'codexlens.settings.currentWorkers': 'Current Workers',
'codexlens.settings.currentBatchSize': 'Current Batch Size',
'codexlens.settings.configTitle': 'Basic Configuration',
'codexlens.settings.indexDir.label': 'Index Directory',
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
'codexlens.settings.indexDir.hint': 'Directory path for storing code indexes',
'codexlens.settings.maxWorkers.label': 'Max Workers',
'codexlens.settings.maxWorkers.hint': 'API concurrent processing threads (1-32)',
'codexlens.settings.batchSize.label': 'Batch Size',
'codexlens.settings.batchSize.hint': 'Number of files processed per batch (1-64)',
'codexlens.settings.validation.indexDirRequired': 'Index directory is required',
'codexlens.settings.validation.maxWorkersRange': 'Workers must be between 1 and 32',
'codexlens.settings.validation.batchSizeRange': 'Batch size must be between 1 and 64',
'codexlens.settings.save': 'Save',
'codexlens.settings.saving': 'Saving...',
'codexlens.settings.reset': 'Reset',
'codexlens.settings.saveSuccess': 'Configuration saved',
'codexlens.settings.saveFailed': 'Save failed',
'codexlens.settings.configUpdated': 'Configuration updated successfully',
'codexlens.settings.saveError': 'Error saving configuration',
'codexlens.settings.unknownError': 'An unknown error occurred',
'codexlens.models.title': 'Model Management',
'codexlens.models.searchPlaceholder': 'Search models...',
'codexlens.models.downloading': 'Downloading...',
'codexlens.models.status.downloaded': 'Downloaded',
'codexlens.models.status.available': 'Available',
'codexlens.models.types.embedding': 'Embedding Models',
'codexlens.models.types.reranker': 'Reranker Models',
'codexlens.models.filters.label': 'Filter',
'codexlens.models.filters.all': 'All',
'codexlens.models.actions.download': 'Download',
'codexlens.models.actions.delete': 'Delete',
'codexlens.models.actions.cancel': 'Cancel',
'codexlens.models.custom.title': 'Custom Model',
'codexlens.models.custom.placeholder': 'HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)',
'codexlens.models.custom.description': 'Download custom models from HuggingFace. Ensure the model name is correct.',
'codexlens.models.deleteConfirm': 'Are you sure you want to delete model {modelName}?',
'codexlens.models.notInstalled.title': 'CodexLens Not Installed',
'codexlens.models.notInstalled.description': 'Please install CodexLens to use model management features.',
'codexlens.models.empty.title': 'No models found',
'codexlens.models.empty.description': 'Try adjusting your search or filter criteria',
// Reranker
'codexlens.reranker.title': 'Reranker Configuration',
'codexlens.reranker.description': 'Configure the reranker backend, model, and provider for search result ranking.',
'codexlens.reranker.backend': 'Backend',
'codexlens.reranker.backendHint': 'Inference backend for reranking',
'codexlens.reranker.model': 'Model',
'codexlens.reranker.modelHint': 'Reranker model name or LiteLLM endpoint',
'codexlens.reranker.provider': 'API Provider',
'codexlens.reranker.providerHint': 'API provider for reranker service',
'codexlens.reranker.apiKeyStatus': 'API Key',
'codexlens.reranker.apiKeySet': 'Configured',
'codexlens.reranker.apiKeyNotSet': 'Not configured',
'codexlens.reranker.configSource': 'Config Source',
'codexlens.reranker.save': 'Save Reranker Config',
'codexlens.reranker.saving': 'Saving...',
'codexlens.reranker.saveSuccess': 'Reranker configuration saved',
'codexlens.reranker.saveFailed': 'Failed to save reranker configuration',
'codexlens.reranker.noBackends': 'No backends available',
'codexlens.reranker.noModels': 'No models available',
'codexlens.reranker.noProviders': 'No providers available',
'codexlens.reranker.litellmModels': 'LiteLLM Models',
'codexlens.reranker.selectBackend': 'Select backend...',
'codexlens.reranker.selectModel': 'Select model...',
'codexlens.reranker.selectProvider': 'Select provider...',
// CodexLens (v2)
'codexlens.title': 'Search Manager',
'codexlens.description': 'V2 semantic search index management',
'codexlens.reindex': 'Reindex',
'codexlens.reindexing': 'Reindexing...',
'codexlens.statusError': 'Failed to load search index status',
'codexlens.indexStatus.title': 'Index Status',
'codexlens.indexStatus.status': 'Status',
'codexlens.indexStatus.ready': 'Ready',
'codexlens.indexStatus.notIndexed': 'Not Indexed',
'codexlens.indexStatus.files': 'Files',
'codexlens.indexStatus.dbSize': 'DB Size',
'codexlens.indexStatus.lastIndexed': 'Last Indexed',
'codexlens.indexStatus.chunks': 'Chunks',
'codexlens.indexStatus.vectorDim': 'Vector Dim',
'codexlens.indexStatus.enabled': 'Enabled',
'codexlens.indexStatus.disabled': 'Disabled',
'codexlens.indexStatus.unavailable': 'Index status unavailable',
'codexlens.searchTest.title': 'Search Test',
'codexlens.searchTest.placeholder': 'Enter search query...',
'codexlens.searchTest.button': 'Search',
'codexlens.searchTest.results': 'results',
'codexlens.searchTest.noResults': 'No results found',
// MCP - CCW Tools
'mcp.ccw.title': 'CCW MCP Server',
'mcp.ccw.description': 'Configure CCW MCP tools and paths',
@@ -438,173 +294,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'issues.discovery.status.failed': '失败',
'issues.discovery.progress': '进度',
'issues.discovery.findings': '发现',
// CodexLens
'codexlens.title': 'CodexLens',
'codexlens.description': '语义代码搜索引擎',
'codexlens.bootstrap': '引导安装',
'codexlens.bootstrapping': '安装中...',
'codexlens.uninstall': '卸载',
'codexlens.uninstalling': '卸载中...',
'codexlens.confirmUninstall': '确定要卸载 CodexLens 吗?',
'codexlens.notInstalled': 'CodexLens 尚未安装',
'codexlens.comingSoon': '即将推出',
'codexlens.tabs.overview': '概览',
'codexlens.tabs.settings': '设置',
'codexlens.tabs.models': '模型',
'codexlens.tabs.advanced': '高级',
'codexlens.overview.status.installation': '安装状态',
'codexlens.overview.status.ready': '就绪',
'codexlens.overview.status.notReady': '未就绪',
'codexlens.overview.status.version': '版本',
'codexlens.overview.status.indexPath': '索引路径',
'codexlens.overview.status.indexCount': '索引数量',
'codexlens.overview.notInstalled.title': 'CodexLens 未安装',
'codexlens.overview.notInstalled.message': '请先安装 CodexLens 以使用语义代码搜索功能。',
'codexlens.overview.actions.title': '快速操作',
'codexlens.overview.actions.ftsFull': 'FTS 全量',
'codexlens.overview.actions.ftsFullDesc': '重建全文索引',
'codexlens.overview.actions.ftsIncremental': 'FTS 增量',
'codexlens.overview.actions.ftsIncrementalDesc': '增量更新全文索引',
'codexlens.overview.actions.vectorFull': '向量全量',
'codexlens.overview.actions.vectorFullDesc': '重建向量索引',
'codexlens.overview.actions.vectorIncremental': '向量增量',
'codexlens.overview.actions.vectorIncrementalDesc': '增量更新向量索引',
'codexlens.overview.venv.title': 'Python 虚拟环境详情',
'codexlens.overview.venv.pythonVersion': 'Python 版本',
'codexlens.overview.venv.venvPath': '虚拟环境路径',
'codexlens.overview.venv.lastCheck': '最后检查时间',
'codexlens.install.title': '安装 CodexLens',
// Env Groups & Fields
'codexlens.envGroup.embedding': '嵌入模型',
'codexlens.envGroup.reranker': '重排序',
'codexlens.envGroup.concurrency': '并发',
'codexlens.envGroup.cascade': '级联搜索',
'codexlens.envGroup.indexing': '索引与解析',
'codexlens.envGroup.chunking': '分块',
'codexlens.envField.backend': '后端',
'codexlens.envField.model': '模型',
'codexlens.envField.useGpu': '使用 GPU',
'codexlens.envField.highAvailability': '高可用',
'codexlens.envField.loadBalanceStrategy': '负载均衡策略',
'codexlens.envField.rateLimitCooldown': '限流冷却时间',
'codexlens.envField.enabled': '启用',
'codexlens.envField.topKResults': 'Top K 结果数',
'codexlens.envField.maxWorkers': '最大工作线程',
'codexlens.envField.batchSize': '批次大小',
'codexlens.envField.dynamicBatchSize': '动态批次大小',
'codexlens.envField.batchSizeUtilization': '利用率因子',
'codexlens.envField.batchSizeMax': '最大批次大小',
'codexlens.envField.charsPerToken': '每 Token 字符数',
'codexlens.envField.searchStrategy': '搜索策略',
'codexlens.envField.coarseK': '粗筛 K 值',
'codexlens.envField.fineK': '精筛 K 值',
'codexlens.envField.stagedStage2Mode': 'Stage-2 模式',
'codexlens.envField.stagedClusteringStrategy': '聚类策略',
'codexlens.envField.stagedClusteringMinSize': '最小聚类大小',
'codexlens.envField.enableStagedRerank': '启用重排序',
'codexlens.envField.useAstGrep': '使用 ast-grep',
'codexlens.envField.staticGraphEnabled': '启用静态图',
'codexlens.envField.staticGraphRelationshipTypes': '关系类型',
'codexlens.envField.stripComments': '去除注释',
'codexlens.envField.stripDocstrings': '去除文档字符串',
'codexlens.envField.testFilePenalty': '测试文件惩罚',
'codexlens.envField.docstringWeight': '文档字符串权重',
'codexlens.install.description': '设置 Python 虚拟环境并安装 CodexLens 包。',
'codexlens.install.checklist': '将要安装的内容',
'codexlens.install.pythonVenv': 'Python 虚拟环境',
'codexlens.install.pythonVenvDesc': 'CodexLens 的隔离 Python 环境',
'codexlens.install.codexlensPackage': 'CodexLens 包',
'codexlens.install.codexlensPackageDesc': '核心语义代码搜索引擎',
'codexlens.install.sqliteFts': 'SQLite FTS5',
'codexlens.install.sqliteFtsDesc': '用于快速代码查找的全文搜索扩展',
'codexlens.install.location': '安装位置',
'codexlens.install.locationPath': '~/.codexlens/venv',
'codexlens.install.timeEstimate': '安装可能需要 1-3 分钟,取决于网络速度。',
'codexlens.install.stage.creatingVenv': '正在创建 Python 虚拟环境...',
'codexlens.install.stage.installingPip': '正在安装 pip 依赖...',
'codexlens.install.stage.installingPackage': '正在安装 CodexLens 包...',
'codexlens.install.stage.settingUpDeps': '正在设置依赖项...',
'codexlens.install.stage.finalizing': '正在完成安装...',
'codexlens.install.stage.complete': '安装完成!',
'codexlens.install.installNow': '立即安装',
'codexlens.install.installing': '安装中...',
'codexlens.watcher.title': '文件监听器',
'codexlens.watcher.status.running': '运行中',
'codexlens.watcher.status.stopped': '已停止',
'codexlens.watcher.eventsProcessed': '已处理事件',
'codexlens.watcher.uptime': '运行时间',
'codexlens.watcher.start': '启动监听',
'codexlens.watcher.starting': '启动中...',
'codexlens.watcher.stop': '停止监听',
'codexlens.watcher.stopping': '停止中...',
'codexlens.watcher.started': '文件监听器已启动',
'codexlens.watcher.stopped': '文件监听器已停止',
'codexlens.settings.currentCount': '当前索引数量',
'codexlens.settings.currentWorkers': '当前工作线程',
'codexlens.settings.currentBatchSize': '当前批次大小',
'codexlens.settings.configTitle': '基本配置',
'codexlens.settings.indexDir.label': '索引目录',
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
'codexlens.settings.indexDir.hint': '存储代码索引的目录路径',
'codexlens.settings.maxWorkers.label': '最大工作线程',
'codexlens.settings.maxWorkers.hint': 'API 并发处理线程数 (1-32)',
'codexlens.settings.batchSize.label': '批次大小',
'codexlens.settings.batchSize.hint': '每次批量处理的文件数量 (1-64)',
'codexlens.settings.validation.indexDirRequired': '索引目录不能为空',
'codexlens.settings.validation.maxWorkersRange': '工作线程数必须在 1-32 之间',
'codexlens.settings.validation.batchSizeRange': '批次大小必须在 1-64 之间',
'codexlens.settings.save': '保存',
'codexlens.settings.saving': '保存中...',
'codexlens.settings.reset': '重置',
'codexlens.settings.saveSuccess': '配置已保存',
'codexlens.settings.saveFailed': '保存失败',
'codexlens.settings.configUpdated': '配置更新成功',
'codexlens.settings.saveError': '保存配置时出错',
'codexlens.settings.unknownError': '发生未知错误',
'codexlens.models.title': '模型管理',
'codexlens.models.searchPlaceholder': '搜索模型...',
'codexlens.models.downloading': '下载中...',
'codexlens.models.status.downloaded': '已下载',
'codexlens.models.status.available': '可用',
'codexlens.models.types.embedding': '嵌入模型',
'codexlens.models.types.reranker': '重排序模型',
'codexlens.models.filters.label': '筛选',
'codexlens.models.filters.all': '全部',
'codexlens.models.actions.download': '下载',
'codexlens.models.actions.delete': '删除',
'codexlens.models.actions.cancel': '取消',
'codexlens.models.custom.title': '自定义模型',
'codexlens.models.custom.placeholder': 'HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)',
'codexlens.models.custom.description': '从 HuggingFace 下载自定义模型。请确保模型名称正确。',
'codexlens.models.deleteConfirm': '确定要删除模型 {modelName} 吗?',
'codexlens.models.notInstalled.title': 'CodexLens 未安装',
'codexlens.models.notInstalled.description': '请先安装 CodexLens 以使用模型管理功能。',
'codexlens.models.empty.title': '没有找到模型',
'codexlens.models.empty.description': '尝试调整搜索或筛选条件',
// Reranker
'codexlens.reranker.title': '重排序配置',
'codexlens.reranker.description': '配置重排序后端、模型和提供商,用于搜索结果排序。',
'codexlens.reranker.backend': '后端',
'codexlens.reranker.backendHint': '重排序推理后端',
'codexlens.reranker.model': '模型',
'codexlens.reranker.modelHint': '重排序模型名称或 LiteLLM 端点',
'codexlens.reranker.provider': 'API 提供商',
'codexlens.reranker.providerHint': '重排序服务的 API 提供商',
'codexlens.reranker.apiKeyStatus': 'API 密钥',
'codexlens.reranker.apiKeySet': '已配置',
'codexlens.reranker.apiKeyNotSet': '未配置',
'codexlens.reranker.configSource': '配置来源',
'codexlens.reranker.save': '保存重排序配置',
'codexlens.reranker.saving': '保存中...',
'codexlens.reranker.saveSuccess': '重排序配置已保存',
'codexlens.reranker.saveFailed': '保存重排序配置失败',
'codexlens.reranker.noBackends': '无可用后端',
'codexlens.reranker.noModels': '无可用模型',
'codexlens.reranker.noProviders': '无可用提供商',
'codexlens.reranker.litellmModels': 'LiteLLM 模型',
'codexlens.reranker.selectBackend': '选择后端...',
'codexlens.reranker.selectModel': '选择模型...',
'codexlens.reranker.selectProvider': '选择提供商...',
// CodexLens (v2)
'codexlens.title': '搜索管理',
'codexlens.description': 'V2 语义搜索索引管理',
'codexlens.reindex': '重建索引',
'codexlens.reindexing': '重建中...',
'codexlens.statusError': '加载搜索索引状态失败',
'codexlens.indexStatus.title': '索引状态',
'codexlens.indexStatus.status': '状态',
'codexlens.indexStatus.ready': '就绪',
'codexlens.indexStatus.notIndexed': '未索引',
'codexlens.indexStatus.files': '文件数',
'codexlens.indexStatus.dbSize': '数据库大小',
'codexlens.indexStatus.lastIndexed': '上次索引',
'codexlens.indexStatus.chunks': '分块数',
'codexlens.indexStatus.vectorDim': '向量维度',
'codexlens.indexStatus.enabled': '已启用',
'codexlens.indexStatus.disabled': '已禁用',
'codexlens.indexStatus.unavailable': '索引状态不可用',
'codexlens.searchTest.title': '搜索测试',
'codexlens.searchTest.placeholder': '输入搜索查询...',
'codexlens.searchTest.button': '搜索',
'codexlens.searchTest.results': '个结果',
'codexlens.searchTest.noResults': '未找到结果',
// MCP - CCW Tools
'mcp.ccw.title': 'CCW MCP 服务器',
'mcp.ccw.description': '配置 CCW MCP 工具与路径',

View File

@@ -1,63 +0,0 @@
// ========================================
// CodexLens Type Definitions
// ========================================
// TypeScript interfaces for structured env var form schema
/**
* Model group definition for model-select fields
*/
export interface ModelGroup {
group: string;
items: string[];
}
/**
* Schema for a single environment variable field
*/
export interface EnvVarFieldSchema {
/** Environment variable key (e.g. CODEXLENS_EMBEDDING_BACKEND) */
key: string;
/** i18n label key */
labelKey: string;
/** Field type determines which control to render */
type: 'select' | 'model-select' | 'number' | 'checkbox' | 'text' | 'password';
/** Options for select type */
options?: string[];
/** Default value */
default?: string;
/** Placeholder text */
placeholder?: string;
/** Conditional visibility based on current env values */
showWhen?: (env: Record<string, string>) => boolean;
/** Mapped path in settings.json (e.g. embedding.backend) */
settingsPath?: string;
/** Min value for number type */
min?: number;
/** Max value for number type */
max?: number;
/** Step value for number type */
step?: number;
/** Preset local models for model-select */
localModels?: ModelGroup[];
/** Preset API models for model-select */
apiModels?: ModelGroup[];
}
/**
* Schema for a group of related environment variables
*/
export interface EnvVarGroup {
/** Unique group identifier */
id: string;
/** i18n label key for group title */
labelKey: string;
/** Lucide icon name */
icon: string;
/** Ordered map of env var key to field schema */
vars: Record<string, EnvVarFieldSchema>;
}
/**
* Complete schema for all env var groups
*/
export type EnvVarGroupsSchema = Record<string, EnvVarGroup>;