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>;

View File

@@ -1,37 +1,13 @@
/**
* Memory Embedder Bridge - TypeScript interface to Python memory embedder
* Memory Embedder Bridge - STUB (v1 Python bridge removed)
*
* This module provides a TypeScript bridge to the Python memory_embedder.py script,
* which generates and searches embeddings for memory chunks using CodexLens's embedder.
*
* Features:
* - Reuses CodexLens venv at ~/.codexlens/venv
* - JSON protocol communication
* - Three commands: embed, search, status
* - Automatic availability checking
* - Stage1 output embedding for V2 pipeline
* The Python memory_embedder.py bridge has been removed. This module provides
* no-op stubs so that existing consumers compile without errors.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
import { getCoreMemoryStore } from './core-memory-store.js';
import type { Stage1Output } from './core-memory-store.js';
import { StoragePaths } from '../config/storage-paths.js';
const V1_REMOVED = 'Memory embedder Python bridge has been removed (v1 cleanup).';
// Get directory of this module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Venv paths (reuse CodexLens venv)
const VENV_PYTHON = getCodexLensHiddenPython();
// Script path
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'memory_embedder.py');
// Types
// Types (kept for backward compatibility)
export interface EmbedResult {
success: boolean;
chunks_processed: number;
@@ -78,197 +54,6 @@ export interface SearchOptions {
sourceType?: 'core_memory' | 'workflow' | 'cli_history';
}
/**
* Check if embedder is available (venv and script exist)
* @returns True if embedder is available
*/
export function isEmbedderAvailable(): boolean {
// Check venv python exists
if (!existsSync(VENV_PYTHON)) {
return false;
}
// Check script exists
if (!existsSync(EMBEDDER_SCRIPT)) {
return false;
}
return true;
}
/**
* Run Python script with arguments
* @param args - Command line arguments
* @param timeout - Timeout in milliseconds
* @returns JSON output from script
*/
function runPython(args: string[], timeout: number = 300000): Promise<string> {
return new Promise((resolve, reject) => {
// Check availability
if (!isEmbedderAvailable()) {
reject(
new Error(
'Memory embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
)
);
return;
}
// Spawn Python process
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT, ...args], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
}
});
child.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
reject(new Error('Python script timed out'));
} else {
reject(new Error(`Failed to spawn Python: ${err.message}`));
}
});
});
}
/**
* Generate embeddings for memory chunks
* @param dbPath - Path to SQLite database
* @param options - Embedding options
* @returns Embedding result
*/
export async function generateEmbeddings(
dbPath: string,
options: EmbedOptions = {}
): Promise<EmbedResult> {
const { sourceId, batchSize = 8, force = false } = options;
// Build arguments
const args = ['embed', dbPath];
if (sourceId) {
args.push('--source-id', sourceId);
}
if (batchSize !== 8) {
args.push('--batch-size', batchSize.toString());
}
if (force) {
args.push('--force');
}
try {
// Default timeout: 5 minutes
const output = await runPython(args, 300000);
const result = JSON.parse(output) as EmbedResult;
return result;
} catch (err) {
return {
success: false,
chunks_processed: 0,
chunks_failed: 0,
elapsed_time: 0,
error: (err as Error).message,
};
}
}
/**
* Search memory chunks using semantic search
* @param dbPath - Path to SQLite database
* @param query - Search query text
* @param options - Search options
* @returns Search results
*/
export async function searchMemories(
dbPath: string,
query: string,
options: SearchOptions = {}
): Promise<SearchResult> {
const { topK = 10, minScore = 0.3, sourceType } = options;
// Build arguments
const args = ['search', dbPath, query];
if (topK !== 10) {
args.push('--top-k', topK.toString());
}
if (minScore !== 0.3) {
args.push('--min-score', minScore.toString());
}
if (sourceType) {
args.push('--type', sourceType);
}
try {
// Default timeout: 30 seconds
const output = await runPython(args, 30000);
const result = JSON.parse(output) as SearchResult;
return result;
} catch (err) {
return {
success: false,
matches: [],
error: (err as Error).message,
};
}
}
/**
* Get embedding status statistics
* @param dbPath - Path to SQLite database
* @returns Embedding status
*/
export async function getEmbeddingStatus(dbPath: string): Promise<EmbeddingStatus> {
// Build arguments
const args = ['status', dbPath];
try {
// Default timeout: 30 seconds
const output = await runPython(args, 30000);
const result = JSON.parse(output) as EmbeddingStatus;
return { ...result, success: true };
} catch (err) {
return {
success: false,
total_chunks: 0,
embedded_chunks: 0,
pending_chunks: 0,
by_type: {},
error: (err as Error).message,
};
}
}
// ============================================================================
// Memory V2: Stage1 Output Embedding
// ============================================================================
/** Result of stage1 embedding operation */
export interface Stage1EmbedResult {
success: boolean;
chunksCreated: number;
@@ -276,98 +61,54 @@ export interface Stage1EmbedResult {
error?: string;
}
/**
* Chunk and embed stage1_outputs (raw_memory + rollout_summary) for semantic search.
*
* Reads all stage1_outputs from the DB, chunks their raw_memory and rollout_summary
* content, inserts chunks into memory_chunks with source_type='cli_history' and
* metadata indicating the V2 origin, then triggers embedding generation.
*
* Uses source_id format: "s1:{thread_id}" to differentiate from regular cli_history chunks.
*
* @param projectPath - Project root path
* @param force - Force re-chunking even if chunks exist
* @returns Embedding result
*/
export async function embedStage1Outputs(
projectPath: string,
force: boolean = false
): Promise<Stage1EmbedResult> {
try {
const store = getCoreMemoryStore(projectPath);
const stage1Outputs = store.listStage1Outputs();
if (stage1Outputs.length === 0) {
return { success: true, chunksCreated: 0, chunksEmbedded: 0 };
}
let totalChunksCreated = 0;
for (const output of stage1Outputs) {
const sourceId = `s1:${output.thread_id}`;
// Check if already chunked
const existingChunks = store.getChunks(sourceId);
if (existingChunks.length > 0 && !force) continue;
// Delete old chunks if force
if (force && existingChunks.length > 0) {
store.deleteChunks(sourceId);
}
// Combine raw_memory and rollout_summary for richer semantic content
const combinedContent = [
output.rollout_summary ? `## Summary\n${output.rollout_summary}` : '',
output.raw_memory ? `## Raw Memory\n${output.raw_memory}` : '',
].filter(Boolean).join('\n\n');
if (!combinedContent.trim()) continue;
// Chunk using the store's built-in chunking
const chunks = store.chunkContent(combinedContent, sourceId, 'cli_history');
// Insert chunks with V2 metadata
for (let i = 0; i < chunks.length; i++) {
store.insertChunk({
source_id: sourceId,
source_type: 'cli_history',
chunk_index: i,
content: chunks[i],
metadata: JSON.stringify({
v2_source: 'stage1_output',
thread_id: output.thread_id,
generated_at: output.generated_at,
}),
created_at: new Date().toISOString(),
});
totalChunksCreated++;
}
}
// If we created chunks, generate embeddings
let chunksEmbedded = 0;
if (totalChunksCreated > 0) {
const paths = StoragePaths.project(projectPath);
const dbPath = join(paths.root, 'core-memory', 'core_memory.db');
const embedResult = await generateEmbeddings(dbPath, { force: false });
if (embedResult.success) {
chunksEmbedded = embedResult.chunks_processed;
}
}
return {
success: true,
chunksCreated: totalChunksCreated,
chunksEmbedded,
};
} catch (err) {
return {
success: false,
chunksCreated: 0,
chunksEmbedded: 0,
error: (err as Error).message,
};
}
export function isEmbedderAvailable(): boolean {
return false;
}
export async function generateEmbeddings(
_dbPath: string,
_options: EmbedOptions = {}
): Promise<EmbedResult> {
return {
success: false,
chunks_processed: 0,
chunks_failed: 0,
elapsed_time: 0,
error: V1_REMOVED,
};
}
export async function searchMemories(
_dbPath: string,
_query: string,
_options: SearchOptions = {}
): Promise<SearchResult> {
return {
success: false,
matches: [],
error: V1_REMOVED,
};
}
export async function getEmbeddingStatus(_dbPath: string): Promise<EmbeddingStatus> {
return {
success: false,
total_chunks: 0,
embedded_chunks: 0,
pending_chunks: 0,
by_type: {},
error: V1_REMOVED,
};
}
export async function embedStage1Outputs(
_projectPath: string,
_force: boolean = false
): Promise<Stage1EmbedResult> {
return {
success: false,
chunksCreated: 0,
chunksEmbedded: 0,
error: V1_REMOVED,
};
}

View File

@@ -1,23 +0,0 @@
/**
* CodexLens Routes Module
* Handles all CodexLens-related API endpoints.
*/
import type { RouteContext } from './types.js';
import { handleCodexLensConfigRoutes } from './codexlens/config-handlers.js';
import { handleCodexLensIndexRoutes } from './codexlens/index-handlers.js';
import { handleCodexLensSemanticRoutes } from './codexlens/semantic-handlers.js';
import { handleCodexLensWatcherRoutes } from './codexlens/watcher-handlers.js';
/**
* Handle CodexLens routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean> {
if (await handleCodexLensIndexRoutes(ctx)) return true;
if (await handleCodexLensConfigRoutes(ctx)) return true;
if (await handleCodexLensSemanticRoutes(ctx)) return true;
if (await handleCodexLensWatcherRoutes(ctx)) return true;
return false;
}

View File

@@ -1,37 +0,0 @@
# CodexLens Routes
CodexLens-related HTTP endpoints are handled by `ccw/src/core/routes/codexlens-routes.ts`, which delegates to handler modules in this directory. Each handler returns `true` when it handles the current request.
## File Map
- `ccw/src/core/routes/codexlens/utils.ts` shared helpers (ANSI stripping + robust JSON extraction from CLI output).
- `ccw/src/core/routes/codexlens/index-handlers.ts` index/project management endpoints:
- `GET /api/codexlens/indexes`
- `POST /api/codexlens/clean`
- `POST /api/codexlens/init`
- `POST /api/codexlens/cancel`
- `GET /api/codexlens/indexing-status`
- `ccw/src/core/routes/codexlens/config-handlers.ts` install/config/environment endpoints:
- `GET /api/codexlens/status`
- `GET /api/codexlens/dashboard-init`
- `POST /api/codexlens/bootstrap`
- `POST /api/codexlens/uninstall`
- `GET /api/codexlens/config`
- `POST /api/codexlens/config`
- GPU: `GET /api/codexlens/gpu/detect`, `GET /api/codexlens/gpu/list`, `POST /api/codexlens/gpu/select`, `POST /api/codexlens/gpu/reset`
- Models: `GET /api/codexlens/models`, `POST /api/codexlens/models/download`, `POST /api/codexlens/models/delete`, `GET /api/codexlens/models/info`
- Env: `GET /api/codexlens/env`, `POST /api/codexlens/env`
- `ccw/src/core/routes/codexlens/semantic-handlers.ts` semantic search + reranker + SPLADE endpoints:
- Semantic: `GET /api/codexlens/semantic/status`, `GET /api/codexlens/semantic/metadata`, `POST /api/codexlens/semantic/install`
- Search: `GET /api/codexlens/search`, `GET /api/codexlens/search_files`, `GET /api/codexlens/symbol`, `POST /api/codexlens/enhance`
- Reranker: `GET /api/codexlens/reranker/config`, `POST /api/codexlens/reranker/config`, `GET /api/codexlens/reranker/models`, `POST /api/codexlens/reranker/models/download`, `POST /api/codexlens/reranker/models/delete`, `GET /api/codexlens/reranker/models/info`
- SPLADE: `GET /api/codexlens/splade/status`, `POST /api/codexlens/splade/install`, `GET /api/codexlens/splade/index-status`, `POST /api/codexlens/splade/rebuild`
- `ccw/src/core/routes/codexlens/watcher-handlers.ts` file watcher endpoints:
- `GET /api/codexlens/watch/status`
- `POST /api/codexlens/watch/start`
- `POST /api/codexlens/watch/stop`
- Also exports `stopWatcherForUninstall()` used during uninstall flow.
## Notes
- CodexLens CLI output may include logging + ANSI escapes even with `--json`; handlers use `extractJSON()` from `utils.ts` to parse reliably.

File diff suppressed because it is too large Load Diff

View File

@@ -1,459 +0,0 @@
/**
* CodexLens index management handlers.
*/
import {
cancelIndexing,
checkVenvStatus,
checkSemanticStatus,
ensureLiteLLMEmbedderReady,
executeCodexLens,
isIndexingInProgress,
} from '../../../tools/codex-lens.js';
import type { ProgressInfo } from '../../../tools/codex-lens.js';
import type { RouteContext } from '../types.js';
import { extractJSON, formatSize } from './utils.js';
/**
* Handle CodexLens index routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: CodexLens Index List - Get all indexed projects with details
if (pathname === '/api/codexlens/indexes') {
try {
// Check if CodexLens is installed first (without auto-installing)
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, indexes: [], totalSize: 0, totalSizeFormatted: '0 B' }));
return true;
}
// Execute all CLI commands in parallel
const [configResult, projectsResult, statusResult] = await Promise.all([
executeCodexLens(['config', '--json']),
executeCodexLens(['projects', 'list', '--json']),
executeCodexLens(['status', '--json'])
]);
let indexDir = '';
if (configResult.success) {
try {
const config = extractJSON(configResult.output ?? '');
if (config.success && config.result) {
// CLI returns index_dir (not index_root)
indexDir = config.result.index_dir || config.result.index_root || '';
}
} catch (e: unknown) {
console.error('[CodexLens] Failed to parse config for index list:', e instanceof Error ? e.message : String(e));
}
}
let indexes: any[] = [];
let totalSize = 0;
let vectorIndexCount = 0;
let normalIndexCount = 0;
if (projectsResult.success) {
try {
const projectsData = extractJSON(projectsResult.output ?? '');
if (projectsData.success && Array.isArray(projectsData.result)) {
const { stat, readdir } = await import('fs/promises');
const { existsSync } = await import('fs');
const { basename, join } = await import('path');
for (const project of projectsData.result) {
// Skip test/temp projects
if (project.source_root && (
project.source_root.includes('\\Temp\\') ||
project.source_root.includes('/tmp/') ||
project.total_files === 0
)) {
continue;
}
let projectSize = 0;
let hasVectorIndex = false;
let hasNormalIndex = true; // All projects have FTS index
let lastModified = null;
// Try to get actual index size from index_root
if (project.index_root && existsSync(project.index_root)) {
try {
const files = await readdir(project.index_root);
for (const file of files) {
try {
const filePath = join(project.index_root, file);
const fileStat = await stat(filePath);
projectSize += fileStat.size;
if (!lastModified || fileStat.mtime > lastModified) {
lastModified = fileStat.mtime;
}
// Check for vector/embedding files
if (file.includes('vector') || file.includes('embedding') ||
file.endsWith('.faiss') || file.endsWith('.npy') ||
file.includes('semantic_chunks')) {
hasVectorIndex = true;
}
} catch {
// Skip files we can't stat
}
}
} catch {
// Can't read index directory
}
}
if (hasVectorIndex) vectorIndexCount++;
if (hasNormalIndex) normalIndexCount++;
totalSize += projectSize;
// Use source_root as the display name
const displayName = project.source_root ? basename(project.source_root) : `project_${project.id}`;
indexes.push({
id: displayName,
path: project.source_root || '',
indexPath: project.index_root || '',
size: projectSize,
sizeFormatted: formatSize(projectSize),
fileCount: project.total_files || 0,
dirCount: project.total_dirs || 0,
hasVectorIndex,
hasNormalIndex,
status: project.status || 'active',
lastModified: lastModified ? lastModified.toISOString() : null
});
}
// Sort by file count (most files first), then by name
indexes.sort((a, b) => {
if (b.fileCount !== a.fileCount) return b.fileCount - a.fileCount;
return a.id.localeCompare(b.id);
});
}
} catch (e: unknown) {
console.error('[CodexLens] Failed to parse projects list:', e instanceof Error ? e.message : String(e));
}
}
// Parse summary stats from status command (already fetched in parallel)
let statusSummary: any = {};
if (statusResult.success) {
try {
const status = extractJSON(statusResult.output ?? '');
if (status.success && status.result) {
statusSummary = {
totalProjects: status.result.projects_count || indexes.length,
totalFiles: status.result.total_files || 0,
totalDirs: status.result.total_dirs || 0,
// Keep calculated totalSize for consistency with per-project sizes
// status.index_size_bytes includes shared resources (models, cache)
indexSizeBytes: totalSize,
indexSizeMb: totalSize / (1024 * 1024),
embeddings: status.result.embeddings || {},
// Store full index dir size separately for reference
fullIndexDirSize: status.result.index_size_bytes || 0,
fullIndexDirSizeFormatted: formatSize(status.result.index_size_bytes || 0)
};
}
} catch (e: unknown) {
console.error('[CodexLens] Failed to parse status:', e instanceof Error ? e.message : String(e));
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
indexDir,
indexes,
summary: {
totalProjects: indexes.length,
totalSize,
totalSizeFormatted: formatSize(totalSize),
vectorIndexCount,
normalIndexCount,
...statusSummary
}
}));
} catch (err: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) }));
}
return true;
}
// API: CodexLens Clean (Clean indexes)
if (pathname === '/api/codexlens/clean' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { all = false, path } = body as { all?: unknown; path?: unknown };
try {
const args = ['clean'];
if (all === true) {
args.push('--all');
} else if (typeof path === 'string' && path.trim().length > 0) {
// Path is passed as a positional argument, not as a flag
args.push(path);
}
args.push('--json');
const result = await executeCodexLens(args);
if (result.success) {
return { success: true, message: 'Indexes cleaned successfully' };
} else {
return { success: false, error: result.error || 'Failed to clean indexes', status: 500 };
}
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
// API: CodexLens Init (Initialize workspace index)
if (pathname === '/api/codexlens/init' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
path?: unknown;
indexType?: unknown;
embeddingModel?: unknown;
embeddingBackend?: unknown;
maxWorkers?: unknown;
};
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
// This prevents silent degradation where vector indexing is skipped without error
if (resolvedIndexType !== 'normal') {
if (resolvedEmbeddingBackend === 'litellm') {
// For litellm backend, ensure ccw-litellm is installed
const installResult = await ensureLiteLLMEmbedderReady();
if (!installResult.success) {
return {
success: false,
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
status: 500
};
}
} else {
// For fastembed backend (default), check semantic dependencies
const semanticStatus = await checkSemanticStatus();
if (!semanticStatus.available) {
return {
success: false,
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first (CodeLens Settings → Install Semantic).',
status: 500
};
}
}
}
// Build CLI arguments based on index type
// Use 'index init' subcommand (new CLI structure)
// --force flag ensures full reindex (not incremental)
const args = ['index', 'init', targetPath, '--force', '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {
// Add embedding model selection for vector index (use --model, not --embedding-model)
args.push('--model', resolvedEmbeddingModel);
// Add embedding backend if not using default fastembed (use --backend, not --embedding-backend)
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
args.push('--backend', resolvedEmbeddingBackend);
}
// Add max workers for concurrent API calls (useful for litellm backend)
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
args.push('--max-workers', String(resolvedMaxWorkers));
}
}
// Broadcast start event
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'start', message: 'Starting index...', percent: 0, path: targetPath, indexType: resolvedIndexType }
});
try {
const result = await executeCodexLens(args, {
cwd: targetPath,
timeout: 1800000, // 30 minutes for large codebases
onProgress: (progress: ProgressInfo) => {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { ...progress, path: targetPath }
});
}
});
if (result.success) {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'complete', message: 'Index complete', percent: 100, path: targetPath }
});
try {
const parsed = extractJSON(result.output ?? '');
return { success: true, result: parsed };
} catch {
return { success: true, output: result.output ?? '' };
}
} else {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
});
return { success: false, error: result.error, status: 500 };
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message, percent: 0, path: targetPath }
});
return { success: false, error: message, status: 500 };
}
});
return true;
}
// API: Cancel CodexLens Indexing
if (pathname === '/api/codexlens/cancel' && req.method === 'POST') {
const result = cancelIndexing();
// Broadcast cancellation event
if (result.success) {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'cancelled', message: 'Indexing cancelled by user', percent: 0 }
});
}
res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
return true;
}
// API: CodexLens Update (Incremental index update)
if (pathname === '/api/codexlens/update' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
path?: unknown;
indexType?: unknown;
embeddingModel?: unknown;
embeddingBackend?: unknown;
maxWorkers?: unknown;
};
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
if (resolvedIndexType !== 'normal') {
if (resolvedEmbeddingBackend === 'litellm') {
const installResult = await ensureLiteLLMEmbedderReady();
if (!installResult.success) {
return {
success: false,
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
status: 500
};
}
} else {
const semanticStatus = await checkSemanticStatus();
if (!semanticStatus.available) {
return {
success: false,
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first.',
status: 500
};
}
}
}
// Build CLI arguments for incremental update using 'index init' without --force
// 'index init' defaults to incremental mode (skip unchanged files)
// 'index update' is only for single-file updates in hooks
const args = ['index', 'init', targetPath, '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {
args.push('--model', resolvedEmbeddingModel);
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
args.push('--backend', resolvedEmbeddingBackend);
}
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
args.push('--max-workers', String(resolvedMaxWorkers));
}
}
// Broadcast start event
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'start', message: 'Starting incremental index update...', percent: 0, path: targetPath, indexType: resolvedIndexType }
});
try {
const result = await executeCodexLens(args, {
cwd: targetPath,
timeout: 1800000,
onProgress: (progress: ProgressInfo) => {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { ...progress, path: targetPath }
});
}
});
if (result.success) {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'complete', message: 'Incremental update complete', percent: 100, path: targetPath }
});
try {
const parsed = extractJSON(result.output ?? '');
return { success: true, result: parsed };
} catch {
return { success: true, output: result.output ?? '' };
}
} else {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
});
return { success: false, error: result.error, status: 500 };
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message, percent: 0, path: targetPath }
});
return { success: false, error: message, status: 500 };
}
});
return true;
}
// API: Check if indexing is in progress
if (pathname === '/api/codexlens/indexing-status') {
const inProgress = isIndexingInProgress();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, inProgress }));
return true;
}
return false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +0,0 @@
/**
* CodexLens route utilities.
*
* CodexLens CLI can emit logging + ANSI escapes even with --json, so helpers
* here normalize output for reliable JSON parsing.
*/
/**
* Strip ANSI color codes from string.
* Rich library adds color codes even with --json flag.
*/
export function stripAnsiCodes(str: string): string {
// ANSI escape code pattern: \x1b[...m or \x1b]...
return str.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/\x1b\][0-9;]*\x07/g, '')
.replace(/\x1b\][^\x07]*\x07/g, '');
}
/**
* Format file size to human readable string.
*/
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(i < 2 ? 0 : 1));
return size + ' ' + units[i];
}
/**
* Extract JSON from CLI output that may contain logging messages.
* CodexLens CLI outputs logs like "INFO ..." before the JSON.
* Also strips ANSI color codes that Rich library adds.
* Handles trailing content after JSON (e.g., "INFO: Done" messages).
*/
export function extractJSON(output: string): any {
// Strip ANSI color codes first
const cleanOutput = stripAnsiCodes(output);
// Find the first { or [ character (start of JSON)
const jsonStart = cleanOutput.search(/[{\[]/);
if (jsonStart === -1) {
throw new Error('No JSON found in output');
}
const startChar = cleanOutput[jsonStart];
const endChar = startChar === '{' ? '}' : ']';
// Find matching closing brace/bracket using a simple counter
let depth = 0;
let inString = false;
let escapeNext = false;
let jsonEnd = -1;
for (let i = jsonStart; i < cleanOutput.length; i++) {
const char = cleanOutput[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && inString) {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === startChar) {
depth++;
} else if (char === endChar) {
depth--;
if (depth === 0) {
jsonEnd = i + 1;
break;
}
}
}
}
if (jsonEnd === -1) {
// Fallback: try to parse from start to end (original behavior)
const jsonString = cleanOutput.substring(jsonStart);
return JSON.parse(jsonString);
}
const jsonString = cleanOutput.substring(jsonStart, jsonEnd);
return JSON.parse(jsonString);
}

View File

@@ -1,322 +0,0 @@
/**
* CodexLens file watcher handlers.
*
* Maintains watcher process state across requests to support dashboard controls.
*/
import {
checkVenvStatus,
executeCodexLens,
getVenvPythonPath,
useCodexLensV2,
} from '../../../tools/codex-lens.js';
import type { RouteContext } from '../types.js';
import { extractJSON, stripAnsiCodes } from './utils.js';
import type { ChildProcess } from 'child_process';
// File watcher state (persisted across requests)
let watcherProcess: any = null;
let watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null as Date | null
};
export async function stopWatcherForUninstall(): Promise<void> {
if (!watcherStats.running || !watcherProcess) return;
try {
watcherProcess.kill('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 500));
if (watcherProcess && !watcherProcess.killed) {
watcherProcess.kill('SIGKILL');
}
} catch {
// Ignore errors stopping watcher
}
watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null
};
watcherProcess = null;
}
/**
* Spawn v2 bridge watcher subprocess.
* Runs 'codexlens-search watch --root X --debounce-ms Y' and reads JSONL stdout.
* @param root - Root directory to watch
* @param debounceMs - Debounce interval in milliseconds
* @returns Spawned child process
*/
function spawnV2Watcher(root: string, debounceMs: number): ChildProcess {
const { spawn } = require('child_process') as typeof import('child_process');
return spawn('codexlens-search', [
'watch',
'--root', root,
'--debounce-ms', String(debounceMs),
'--db-path', require('path').join(root, '.codexlens'),
], {
cwd: root,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
}
/**
* Handle CodexLens watcher routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensWatcherRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Get File Watcher Status
if (pathname === '/api/codexlens/watch/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
running: watcherStats.running,
root_path: watcherStats.root_path,
events_processed: watcherStats.events_processed,
start_time: watcherStats.start_time?.toISOString() || null,
uptime_seconds: watcherStats.start_time
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
: 0
}));
return true;
}
// API: Start File Watcher
if (pathname === '/api/codexlens/watch/start' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: watchPath, debounce_ms = 1000 } = body as { path?: unknown; debounce_ms?: unknown };
const targetPath = typeof watchPath === 'string' && watchPath.trim().length > 0 ? watchPath : initialPath;
const resolvedDebounceMs = typeof debounce_ms === 'number' ? debounce_ms : Number(debounce_ms);
const debounceMs = !Number.isNaN(resolvedDebounceMs) && resolvedDebounceMs > 0 ? resolvedDebounceMs : 1000;
if (watcherStats.running) {
return { success: false, error: 'Watcher already running', status: 400 };
}
try {
const { spawn } = await import('child_process');
const { existsSync, statSync } = await import('fs');
// Validate path exists and is a directory
if (!existsSync(targetPath)) {
return { success: false, error: `Path does not exist: ${targetPath}`, status: 400 };
}
const pathStat = statSync(targetPath);
if (!pathStat.isDirectory()) {
return { success: false, error: `Path is not a directory: ${targetPath}`, status: 400 };
}
// Route to v2 or v1 watcher based on feature flag
if (useCodexLensV2()) {
// v2 bridge watcher: codexlens-search watch
console.log('[CodexLens] Using v2 bridge watcher');
watcherProcess = spawnV2Watcher(targetPath, debounceMs);
} else {
// v1 watcher: python -m codexlens watch
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
return { success: false, error: 'CodexLens not installed', status: 400 };
}
// Verify directory is indexed before starting watcher
try {
const statusResult = await executeCodexLens(['projects', 'list', '--json']);
if (statusResult.success && statusResult.output) {
const parsed = extractJSON(statusResult.output);
const projects = parsed.result || parsed || [];
const normalizedTarget = targetPath.toLowerCase().replace(/\\/g, '/');
const isIndexed = Array.isArray(projects) && projects.some((p: { source_root?: string }) =>
p.source_root && p.source_root.toLowerCase().replace(/\\/g, '/') === normalizedTarget
);
if (!isIndexed) {
return {
success: false,
error: `Directory is not indexed: ${targetPath}. Run 'codexlens init' first.`,
status: 400
};
}
}
} catch (err) {
console.warn('[CodexLens] Could not verify index status:', err);
// Continue anyway - watcher will fail with proper error if not indexed
}
// Spawn watch process using Python (no shell: true for security)
const pythonPath = getVenvPythonPath();
const args = ['-m', 'codexlens', 'watch', targetPath, '--debounce', String(debounceMs)];
watcherProcess = spawn(pythonPath, args, {
cwd: targetPath,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
});
}
watcherStats = {
running: true,
root_path: targetPath,
events_processed: 0,
start_time: new Date()
};
// Capture stderr for error messages (capped at 4KB to prevent memory leak)
const MAX_STDERR_SIZE = 4096;
let stderrBuffer = '';
if (watcherProcess.stderr) {
watcherProcess.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
// Cap buffer size to prevent memory leak in long-running watchers
if (stderrBuffer.length > MAX_STDERR_SIZE) {
stderrBuffer = stderrBuffer.slice(-MAX_STDERR_SIZE);
}
});
}
// Handle process output for event counting
const isV2Watcher = useCodexLensV2();
let stdoutLineBuffer = '';
if (watcherProcess.stdout) {
watcherProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
if (isV2Watcher) {
// v2 bridge outputs JSONL - parse line by line
stdoutLineBuffer += output;
const lines = stdoutLineBuffer.split('\n');
// Keep incomplete last line in buffer
stdoutLineBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const event = JSON.parse(trimmed);
// Count file change events (created, modified, deleted, moved)
if (event.event && event.event !== 'watching') {
watcherStats.events_processed += 1;
}
} catch {
// Not valid JSON, skip
}
}
} else {
// v1 watcher: count text-based event messages
const matches = output.match(/Processed \d+ events?/g);
if (matches) {
watcherStats.events_processed += matches.length;
}
}
});
}
// Handle spawn errors (e.g., ENOENT)
watcherProcess.on('error', (err: Error) => {
console.error(`[CodexLens] Watcher spawn error: ${err.message}`);
watcherStats.running = false;
watcherProcess = null;
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false, error: `Spawn error: ${err.message}` }
});
});
// Handle process exit
watcherProcess.on('exit', (code: number) => {
watcherStats.running = false;
watcherProcess = null;
console.log(`[CodexLens] Watcher exited with code ${code}`);
// Broadcast error if exited with non-zero code
if (code !== 0) {
const errorMsg = stderrBuffer.trim() || `Exited with code ${code}`;
const cleanError = stripAnsiCodes(errorMsg);
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false, error: cleanError }
});
} else {
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false }
});
}
});
// Broadcast watcher started
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: true, path: targetPath }
});
return {
success: true,
message: 'Watcher started',
path: targetPath,
pid: watcherProcess.pid
};
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
// API: Stop File Watcher
if (pathname === '/api/codexlens/watch/stop' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
if (!watcherStats.running || !watcherProcess) {
return { success: false, error: 'Watcher not running', status: 400 };
}
try {
watcherProcess.kill('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 500));
if (watcherProcess && !watcherProcess.killed) {
watcherProcess.kill('SIGKILL');
}
const finalStats = {
events_processed: watcherStats.events_processed,
uptime_seconds: watcherStats.start_time
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
: 0
};
watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null
};
watcherProcess = null;
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false }
});
return {
success: true,
message: 'Watcher stopped',
...finalStats
};
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -3,17 +3,6 @@
* Handles LiteLLM provider management, endpoint configuration, and cache management
*/
import { z } from 'zod';
import { spawn } from 'child_process';
import {
getSystemPythonCommand,
parsePythonCommandSpec,
type PythonCommandSpec,
} from '../../utils/python-utils.js';
import {
isUvAvailable,
createCodexLensUvManager
} from '../../utils/uv-manager.js';
import { ensureLiteLLMEmbedderReady } from '../../tools/codex-lens.js';
import type { RouteContext } from './types.js';
// ========== Input Validation Schemas ==========
@@ -81,106 +70,13 @@ import {
type EmbeddingPoolConfig,
} from '../../config/litellm-api-config-manager.js';
import { getContextCacheStore } from '../../tools/context-cache-store.js';
import { getLiteLLMClient } from '../../tools/litellm-client.js';
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
interface CcwLitellmEnvCheck {
python: string;
installed: boolean;
version?: string;
error?: string;
}
const V1_REMOVED = 'Python bridge has been removed (v1 cleanup).';
interface CcwLitellmStatusResponse {
/**
* Whether ccw-litellm is installed in the CodexLens venv.
* This is the environment used for the LiteLLM embedding backend.
*/
installed: boolean;
version?: string;
error?: string;
checks?: {
codexLensVenv: CcwLitellmEnvCheck;
systemPython?: CcwLitellmEnvCheck;
};
}
function checkCcwLitellmImport(
pythonCmd: string | PythonCommandSpec,
options: { timeout: number }
): Promise<CcwLitellmEnvCheck> {
const { timeout } = options;
const pythonSpec = typeof pythonCmd === 'string' ? parsePythonCommandSpec(pythonCmd) : pythonCmd;
const sanitizePythonError = (stderrText: string): string | undefined => {
const trimmed = stderrText.trim();
if (!trimmed) return undefined;
const lines = trimmed
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean);
// Prefer the final exception line (avoids leaking full traceback + file paths)
return lines[lines.length - 1] || undefined;
};
return new Promise((resolve) => {
const child = spawn(pythonSpec.command, [...pythonSpec.args, '-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
windowsHide: true,
shell: false,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('close', (code: number | null) => {
const version = stdout.trim();
const error = sanitizePythonError(stderr);
if (code === 0 && version) {
resolve({ python: pythonSpec.display, installed: true, version });
return;
}
if (code === null) {
resolve({ python: pythonSpec.display, installed: false, error: `Timed out after ${timeout}ms` });
return;
}
resolve({ python: pythonSpec.display, installed: false, error: error || undefined });
});
child.on('error', (err) => {
resolve({ python: pythonSpec.display, installed: false, error: err.message });
});
});
}
// Cache for ccw-litellm status check
let ccwLitellmStatusCache: {
data: CcwLitellmStatusResponse | null;
timestamp: number;
ttl: number;
} = {
data: null,
timestamp: 0,
ttl: 5 * 60 * 1000, // 5 minutes
};
// Clear cache (call after install)
// Clear cache (no-op stub, kept for backward compatibility)
export function clearCcwLitellmStatusCache() {
ccwLitellmStatusCache.data = null;
ccwLitellmStatusCache.timestamp = 0;
// no-op: Python bridge removed
}
function sanitizeProviderForResponse(provider: any): any {
@@ -922,57 +818,10 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
// CCW-LiteLLM Package Management
// ===========================
// GET /api/litellm-api/ccw-litellm/status - Check ccw-litellm installation status
// Supports ?refresh=true to bypass cache
// GET /api/litellm-api/ccw-litellm/status - Stub (v1 Python bridge removed)
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
const forceRefresh = url.searchParams.get('refresh') === 'true';
// Check cache first (unless force refresh)
if (!forceRefresh && ccwLitellmStatusCache.data &&
Date.now() - ccwLitellmStatusCache.timestamp < ccwLitellmStatusCache.ttl) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(ccwLitellmStatusCache.data));
return true;
}
try {
const uv = createCodexLensUvManager();
const venvPython = uv.getVenvPython();
const statusTimeout = process.platform === 'win32' ? 15000 : 10000;
const codexLensVenv = uv.isVenvValid()
? await checkCcwLitellmImport(venvPython, { timeout: statusTimeout })
: { python: venvPython, installed: false, error: 'CodexLens venv not valid' };
// Diagnostics only: if not installed in venv, also check system python so users understand mismatches.
// NOTE: `installed` flag remains the CodexLens venv status (we want isolated venv dependencies).
const systemPython = !codexLensVenv.installed
? await checkCcwLitellmImport(getSystemPythonCommand(), { timeout: statusTimeout })
: undefined;
const result: CcwLitellmStatusResponse = {
installed: codexLensVenv.installed,
version: codexLensVenv.version,
error: codexLensVenv.error,
checks: {
codexLensVenv,
...(systemPython ? { systemPython } : {}),
},
};
// Update cache
ccwLitellmStatusCache = {
data: result,
timestamp: Date.now(),
ttl: 5 * 60 * 1000,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
const errorResult = { installed: false, error: (err as Error).message };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(errorResult));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ installed: false, error: V1_REMOVED }));
return true;
}
@@ -1367,96 +1216,18 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package
// POST /api/litellm-api/ccw-litellm/install - Stub (v1 Python bridge removed)
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
// Delegate entirely to ensureLiteLLMEmbedderReady for consistent installation
// This uses unified package discovery and handles UV → pip fallback
const result = await ensureLiteLLMEmbedderReady();
if (result.success) {
clearCcwLitellmStatusCache();
broadcastToClients({
type: 'CCW_LITELLM_INSTALLED',
payload: { timestamp: new Date().toISOString(), method: 'unified' }
});
}
return result;
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, error: V1_REMOVED };
});
return true;
}
// POST /api/litellm-api/ccw-litellm/uninstall - Uninstall ccw-litellm package
// POST /api/litellm-api/ccw-litellm/uninstall - Stub (v1 Python bridge removed)
if (pathname === '/api/litellm-api/ccw-litellm/uninstall' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
// Priority 1: Use UV to uninstall from CodexLens venv
if (await isUvAvailable()) {
const uv = createCodexLensUvManager();
if (uv.isVenvValid()) {
console.log('[ccw-litellm uninstall] Using UV to uninstall from CodexLens venv...');
const uvResult = await uv.uninstall(['ccw-litellm']);
clearCcwLitellmStatusCache();
if (uvResult.success) {
broadcastToClients({
type: 'CCW_LITELLM_UNINSTALLED',
payload: { timestamp: new Date().toISOString() }
});
return { success: true, message: 'ccw-litellm uninstalled successfully via UV' };
}
console.log('[ccw-litellm uninstall] UV uninstall failed, falling back to pip:', uvResult.error);
}
}
// Priority 2: Fallback to system pip uninstall
console.log('[ccw-litellm uninstall] Using pip fallback...');
const pythonCmd = getSystemPythonCommand();
return new Promise((resolve) => {
const proc = spawn(
pythonCmd.command,
[...pythonCmd.args, '-m', 'pip', 'uninstall', '-y', 'ccw-litellm'],
{
shell: false,
timeout: 120000,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
},
);
let output = '';
let error = '';
proc.stdout?.on('data', (data) => { output += data.toString(); });
proc.stderr?.on('data', (data) => { error += data.toString(); });
proc.on('close', (code) => {
// Clear status cache after uninstallation attempt
clearCcwLitellmStatusCache();
if (code === 0) {
broadcastToClients({
type: 'CCW_LITELLM_UNINSTALLED',
payload: { timestamp: new Date().toISOString() }
});
resolve({ success: true, message: 'ccw-litellm uninstalled successfully' });
} else {
// Check if package was not installed
if (error.includes('not installed') || output.includes('not installed')) {
resolve({ success: true, message: 'ccw-litellm was not installed' });
} else {
resolve({ success: false, error: error || output || 'Uninstallation failed' });
}
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
});
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, error: V1_REMOVED };
});
return true;
}

View File

@@ -6,7 +6,6 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { getCliToolsStatus } from '../../tools/cli-executor.js';
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
import type { RouteContext } from './types.js';
// Performance logging helper
@@ -80,36 +79,14 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
const ccwInstallStatus = checkCcwInstallStatus();
perfLog('checkCcwInstallStatus', ccwStart);
// Execute all status checks in parallel with individual timing
// Execute async status checks
const cliStart = Date.now();
const codexStart = Date.now();
const semanticStart = Date.now();
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([
getCliToolsStatus().then(result => {
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(result).length });
return result;
}),
checkVenvStatus().then(result => {
perfLog('checkVenvStatus', codexStart, { ready: result.ready });
return result;
}),
// Always check semantic status (will return available: false if CodexLens not ready)
checkSemanticStatus()
.then(result => {
perfLog('checkSemanticStatus', semanticStart, { available: result.available });
return result;
})
.catch(() => {
perfLog('checkSemanticStatus (error)', semanticStart);
return { available: false, backend: null };
})
]);
const cliStatus = await getCliToolsStatus();
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(cliStatus).length });
const response = {
cli: cliStatus,
codexLens: codexLensStatus,
semantic: semanticStatus,
ccwInstall: ccwInstallStatus,
timestamp: new Date().toISOString()
};

View File

@@ -16,7 +16,6 @@ import { handleUnifiedMemoryRoutes } from './routes/unified-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
import { handleFilesRoutes } from './routes/files-routes.js';
@@ -66,7 +65,6 @@ import { getCliSessionManager } from './services/cli-session-manager.js';
import { QueueSchedulerService } from './services/queue-scheduler-service.js';
// Import status check functions for warmup
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
import { getCliToolsStatus } from '../tools/cli-executor.js';
import type { ServerConfig } from '../types/config.js';
@@ -302,28 +300,6 @@ async function warmupCaches(initialPath: string): Promise<void> {
// Run all warmup tasks in parallel for faster startup
const warmupTasks = [
// Warmup semantic status cache (Python process startup - can be slow first time)
(async () => {
const taskStart = Date.now();
try {
const semanticStatus = await checkSemanticStatus();
console.log(`[WARMUP] Semantic status: ${semanticStatus.available ? 'available' : 'not available'} (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] Semantic status check failed: ${(err as Error).message}`);
}
})(),
// Warmup venv status cache
(async () => {
const taskStart = Date.now();
try {
const venvStatus = await checkVenvStatus();
console.log(`[WARMUP] Venv status: ${venvStatus.ready ? 'ready' : 'not ready'} (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] Venv status check failed: ${(err as Error).message}`);
}
})(),
// Warmup CLI tools status cache
(async () => {
const taskStart = Date.now();
@@ -598,11 +574,6 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleUnsplashRoutes(routeContext)) return;
}
// CodexLens routes (/api/codexlens/*)
if (pathname.startsWith('/api/codexlens/')) {
if (await handleCodexLensRoutes(routeContext)) return;
}
// LiteLLM routes (/api/litellm/*)
if (pathname.startsWith('/api/litellm/')) {
if (await handleLiteLLMRoutes(routeContext)) return;

View File

@@ -1,79 +1,37 @@
/**
* Unified Vector Index - TypeScript bridge to unified_memory_embedder.py
* Unified Vector Index - STUB (v1 Python bridge removed)
*
* Provides HNSW-backed vector indexing and search for all memory content
* (core_memory, cli_history, workflow, entity, pattern) via CodexLens VectorStore.
*
* Features:
* - JSON stdin/stdout protocol to Python embedder
* - Content chunking (paragraph -> sentence splitting, CHUNK_SIZE=1500, OVERLAP=200)
* - Batch embedding via CodexLens EmbedderFactory
* - HNSW approximate nearest neighbor search (sub-10ms for 1000 chunks)
* - Category-based filtering
* The Python unified_memory_embedder.py bridge has been removed. This module
* provides no-op stubs so that existing consumers compile without errors.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
const V1_REMOVED = 'Unified vector index Python bridge has been removed (v1 cleanup).';
// Get directory of this module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ---------------------------------------------------------------------------
// Types (kept for backward compatibility)
// ---------------------------------------------------------------------------
// Venv python path (reuse CodexLens venv)
const VENV_PYTHON = getCodexLensHiddenPython();
// Script path
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'unified_memory_embedder.py');
// Chunking constants (match existing core-memory-store.ts)
const CHUNK_SIZE = 1500;
const OVERLAP = 200;
// =============================================================================
// Types
// =============================================================================
/** Valid source types for vector content */
export type SourceType = 'core_memory' | 'workflow' | 'cli_history';
/** Valid category values for vector filtering */
export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern';
/** Metadata attached to each chunk in the vector store */
export interface ChunkMetadata {
/** Source identifier (e.g., memory ID, session ID) */
source_id: string;
/** Source type */
source_type: SourceType;
/** Category for filtering */
category: VectorCategory;
/** Chunk index within the source */
chunk_index?: number;
/** Additional metadata */
[key: string]: unknown;
}
/** A chunk to be embedded and indexed */
export interface VectorChunk {
/** Text content */
content: string;
/** Source identifier */
source_id: string;
/** Source type */
source_type: SourceType;
/** Category for filtering */
category: VectorCategory;
/** Chunk index */
chunk_index: number;
/** Additional metadata */
metadata?: Record<string, unknown>;
}
/** Result of an embed operation */
export interface EmbedResult {
success: boolean;
chunks_processed: number;
@@ -82,7 +40,6 @@ export interface EmbedResult {
error?: string;
}
/** A single search match */
export interface VectorSearchMatch {
content: string;
score: number;
@@ -93,7 +50,6 @@ export interface VectorSearchMatch {
metadata: Record<string, unknown>;
}
/** Result of a search operation */
export interface VectorSearchResult {
success: boolean;
matches: VectorSearchMatch[];
@@ -102,14 +58,12 @@ export interface VectorSearchResult {
error?: string;
}
/** Search options */
export interface VectorSearchOptions {
topK?: number;
minScore?: number;
category?: VectorCategory;
}
/** Index status information */
export interface VectorIndexStatus {
success: boolean;
total_chunks: number;
@@ -126,7 +80,6 @@ export interface VectorIndexStatus {
error?: string;
}
/** Reindex result */
export interface ReindexResult {
success: boolean;
hnsw_count?: number;
@@ -134,344 +87,73 @@ export interface ReindexResult {
error?: string;
}
// =============================================================================
// Python Bridge
// =============================================================================
// ---------------------------------------------------------------------------
// No-op implementations
// ---------------------------------------------------------------------------
/**
* Check if the unified embedder is available (venv and script exist)
*/
export function isUnifiedEmbedderAvailable(): boolean {
if (!existsSync(VENV_PYTHON)) {
return false;
}
if (!existsSync(EMBEDDER_SCRIPT)) {
return false;
}
return true;
return false;
}
/**
* Run Python script with JSON stdin/stdout protocol.
*
* @param request - JSON request object to send via stdin
* @param timeout - Timeout in milliseconds (default: 5 minutes)
* @returns Parsed JSON response
*/
function runPython<T>(request: Record<string, unknown>, timeout: number = 300000): Promise<T> {
return new Promise((resolve, reject) => {
if (!isUnifiedEmbedderAvailable()) {
reject(
new Error(
'Unified embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
)
);
return;
}
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT], {
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
timeout,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout.trim()) {
try {
resolve(JSON.parse(stdout.trim()) as T);
} catch {
reject(new Error(`Failed to parse Python output: ${stdout.substring(0, 500)}`));
}
} else {
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
}
});
child.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
reject(new Error('Python script timed out'));
} else {
reject(new Error(`Failed to spawn Python: ${err.message}`));
}
});
// Write JSON request to stdin and close
const jsonInput = JSON.stringify(request);
child.stdin.write(jsonInput);
child.stdin.end();
});
}
// =============================================================================
// Content Chunking
// =============================================================================
/**
* Chunk content into smaller pieces for embedding.
* Uses paragraph-first, sentence-fallback strategy with overlap.
*
* Matches the chunking logic in core-memory-store.ts:
* - CHUNK_SIZE = 1500 characters
* - OVERLAP = 200 characters
* - Split by paragraph boundaries (\n\n) first
* - Fall back to sentence boundaries (. ) for oversized paragraphs
*
* @param content - Text content to chunk
* @returns Array of chunk strings
*/
export function chunkContent(content: string): string[] {
const chunks: string[] = [];
// Split by paragraph boundaries first
const paragraphs = content.split(/\n\n+/);
let currentChunk = '';
for (const paragraph of paragraphs) {
// If adding this paragraph would exceed chunk size
if (currentChunk.length + paragraph.length > CHUNK_SIZE && currentChunk.length > 0) {
chunks.push(currentChunk.trim());
// Start new chunk with overlap
const overlapText = currentChunk.slice(-OVERLAP);
currentChunk = overlapText + '\n\n' + paragraph;
} else {
currentChunk += (currentChunk ? '\n\n' : '') + paragraph;
}
}
// Add remaining chunk
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
// If chunks are still too large, split by sentences
const finalChunks: string[] = [];
for (const chunk of chunks) {
if (chunk.length <= CHUNK_SIZE) {
finalChunks.push(chunk);
} else {
// Split by sentence boundaries
const sentences = chunk.split(/\. +/);
let sentenceChunk = '';
for (const sentence of sentences) {
const sentenceWithPeriod = sentence + '. ';
if (
sentenceChunk.length + sentenceWithPeriod.length > CHUNK_SIZE &&
sentenceChunk.length > 0
) {
finalChunks.push(sentenceChunk.trim());
const overlapText = sentenceChunk.slice(-OVERLAP);
sentenceChunk = overlapText + sentenceWithPeriod;
} else {
sentenceChunk += sentenceWithPeriod;
}
}
if (sentenceChunk.trim()) {
finalChunks.push(sentenceChunk.trim());
}
}
}
return finalChunks.length > 0 ? finalChunks : [content];
// Minimal chunking for backward compat - just return the content as-is
if (!content.trim()) return [];
return [content];
}
// =============================================================================
// UnifiedVectorIndex Class
// =============================================================================
/**
* Unified vector index backed by CodexLens VectorStore (HNSW).
*
* Provides content chunking, embedding, storage, and search for all
* memory content types through a single interface.
*/
export class UnifiedVectorIndex {
private storePath: string;
constructor(_projectPath: string) {}
/**
* Create a UnifiedVectorIndex for a project.
*
* @param projectPath - Project root path (used to resolve storage location)
*/
constructor(projectPath: string) {
const paths = StoragePaths.project(projectPath);
this.storePath = paths.unifiedVectors.root;
ensureStorageDir(this.storePath);
}
/**
* Index content by chunking, embedding, and storing in VectorStore.
*
* @param content - Text content to index
* @param metadata - Metadata for all chunks (source_id, source_type, category)
* @returns Embed result
*/
async indexContent(
content: string,
metadata: ChunkMetadata
_content: string,
_metadata: ChunkMetadata
): Promise<EmbedResult> {
if (!content.trim()) {
return {
success: true,
chunks_processed: 0,
chunks_failed: 0,
elapsed_time: 0,
};
}
// Chunk content
const textChunks = chunkContent(content);
// Build chunk objects for Python
const chunks: VectorChunk[] = textChunks.map((text, index) => ({
content: text,
source_id: metadata.source_id,
source_type: metadata.source_type,
category: metadata.category,
chunk_index: metadata.chunk_index != null ? metadata.chunk_index + index : index,
metadata: { ...metadata },
}));
try {
const result = await runPython<EmbedResult>({
operation: 'embed',
store_path: this.storePath,
chunks,
batch_size: 8,
});
return result;
} catch (err) {
return {
success: false,
chunks_processed: 0,
chunks_failed: textChunks.length,
elapsed_time: 0,
error: (err as Error).message,
};
}
return {
success: false,
chunks_processed: 0,
chunks_failed: 0,
elapsed_time: 0,
error: V1_REMOVED,
};
}
/**
* Search the vector index using semantic similarity.
*
* @param query - Natural language search query
* @param options - Search options (topK, minScore, category)
* @returns Search results sorted by relevance
*/
async search(
query: string,
options: VectorSearchOptions = {}
_query: string,
_options: VectorSearchOptions = {}
): Promise<VectorSearchResult> {
const { topK = 10, minScore = 0.3, category } = options;
try {
const result = await runPython<VectorSearchResult>({
operation: 'search',
store_path: this.storePath,
query,
top_k: topK,
min_score: minScore,
category: category || null,
});
return result;
} catch (err) {
return {
success: false,
matches: [],
error: (err as Error).message,
};
}
return {
success: false,
matches: [],
error: V1_REMOVED,
};
}
/**
* Search the vector index using a pre-computed embedding vector.
* Bypasses text embedding, directly querying HNSW with a raw vector.
*
* @param vector - Pre-computed embedding vector (array of floats)
* @param options - Search options (topK, minScore, category)
* @returns Search results sorted by relevance
*/
async searchByVector(
vector: number[],
options: VectorSearchOptions = {}
_vector: number[],
_options: VectorSearchOptions = {}
): Promise<VectorSearchResult> {
const { topK = 10, minScore = 0.3, category } = options;
try {
const result = await runPython<VectorSearchResult>({
operation: 'search_by_vector',
store_path: this.storePath,
vector,
top_k: topK,
min_score: minScore,
category: category || null,
});
return result;
} catch (err) {
return {
success: false,
matches: [],
error: (err as Error).message,
};
}
return {
success: false,
matches: [],
error: V1_REMOVED,
};
}
/**
* Rebuild the HNSW index from scratch.
*
* @returns Reindex result
*/
async reindexAll(): Promise<ReindexResult> {
try {
const result = await runPython<ReindexResult>({
operation: 'reindex',
store_path: this.storePath,
});
return result;
} catch (err) {
return {
success: false,
error: (err as Error).message,
};
}
return {
success: false,
error: V1_REMOVED,
};
}
/**
* Get the current status of the vector index.
*
* @returns Index status including chunk counts, HNSW availability, dimension
*/
async getStatus(): Promise<VectorIndexStatus> {
try {
const result = await runPython<VectorIndexStatus>({
operation: 'status',
store_path: this.storePath,
});
return result;
} catch (err) {
return {
success: false,
total_chunks: 0,
hnsw_available: false,
hnsw_count: 0,
dimension: 0,
error: (err as Error).message,
};
}
return {
success: false,
total_chunks: 0,
hnsw_available: false,
hnsw_count: 0,
dimension: 0,
error: V1_REMOVED,
};
}
}

View File

@@ -1,405 +0,0 @@
/**
* CodexLens LSP Tool - Provides LSP-like code intelligence via CodexLens Python API
*
* Features:
* - symbol_search: Search symbols across workspace
* - find_definition: Go to symbol definition
* - find_references: Find all symbol references
* - get_hover: Get hover information for symbols
*/
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn } from 'child_process';
import { join } from 'path';
import { getProjectRoot } from '../utils/path-validator.js';
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
// CodexLens venv configuration
const CODEXLENS_VENV = getCodexLensHiddenPython();
// Define Zod schema for validation
const ParamsSchema = z.object({
action: z.enum(['symbol_search', 'find_definition', 'find_references', 'get_hover']),
project_root: z.string().optional().describe('Project root directory (auto-detected if not provided)'),
symbol_name: z.string().describe('Symbol name to search/query'),
symbol_kind: z.string().optional().describe('Symbol kind filter (class, function, method, etc.)'),
file_context: z.string().optional().describe('Current file path for proximity ranking'),
limit: z.number().default(50).describe('Maximum number of results to return'),
kind_filter: z.array(z.string()).optional().describe('List of symbol kinds to filter (for symbol_search)'),
file_pattern: z.string().optional().describe('Glob pattern to filter files (for symbol_search)'),
});
type Params = z.infer<typeof ParamsSchema>;
/**
* Result types
*/
interface SymbolInfo {
name: string;
kind: string;
file_path: string;
range: {
start_line: number;
end_line: number;
};
score?: number;
}
interface DefinitionResult {
name: string;
kind: string;
file_path: string;
range: {
start_line: number;
end_line: number;
};
}
interface ReferenceResult {
file_path: string;
line: number;
column: number;
}
interface HoverInfo {
name: string;
kind: string;
signature: string;
file_path: string;
start_line: number;
}
type LSPResult = {
success: boolean;
results?: SymbolInfo[] | DefinitionResult[] | ReferenceResult[] | HoverInfo;
error?: string;
action: string;
metadata?: Record<string, unknown>;
};
/**
* Execute CodexLens Python API call
*/
async function executeCodexLensAPI(
apiFunction: string,
args: Record<string, unknown>,
timeout: number = 30000
): Promise<LSPResult> {
return new Promise((resolve) => {
// Build Python script to call API function
const pythonScript = `
import json
import sys
from dataclasses import is_dataclass, asdict
from codexlens.api import ${apiFunction}
def to_serializable(obj):
"""Recursively convert dataclasses to dicts for JSON serialization."""
if obj is None:
return None
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if isinstance(obj, list):
return [to_serializable(item) for item in obj]
if isinstance(obj, dict):
return {key: to_serializable(value) for key, value in obj.items()}
if isinstance(obj, tuple):
return tuple(to_serializable(item) for item in obj)
return obj
try:
args = ${JSON.stringify(args)}
result = ${apiFunction}(**args)
# Convert result to JSON-serializable format
output = to_serializable(result)
print(json.dumps({"success": True, "result": output}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}), file=sys.stderr)
sys.exit(1)
`;
const child = spawn(CODEXLENS_VENV, ['-c', pythonScript], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
try {
const errorData = JSON.parse(stderr);
resolve({
success: false,
error: errorData.error || 'Unknown error',
action: apiFunction,
});
} catch {
resolve({
success: false,
error: stderr || `Process exited with code ${code}`,
action: apiFunction,
});
}
return;
}
try {
const data = JSON.parse(stdout);
resolve({
success: data.success,
results: data.result,
action: apiFunction,
});
} catch (err) {
resolve({
success: false,
error: `Failed to parse output: ${(err as Error).message}`,
action: apiFunction,
});
}
});
child.on('error', (err) => {
resolve({
success: false,
error: `Failed to execute: ${err.message}`,
action: apiFunction,
});
});
});
}
/**
* Handler: symbol_search
*/
async function handleSymbolSearch(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
query: params.symbol_name,
limit: params.limit,
};
if (params.kind_filter) {
args.kind_filter = params.kind_filter;
}
if (params.file_pattern) {
args.file_pattern = params.file_pattern;
}
return executeCodexLensAPI('workspace_symbols', args);
}
/**
* Handler: find_definition
*/
async function handleFindDefinition(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
symbol_name: params.symbol_name,
limit: params.limit,
};
if (params.symbol_kind) {
args.symbol_kind = params.symbol_kind;
}
if (params.file_context) {
args.file_context = params.file_context;
}
return executeCodexLensAPI('find_definition', args);
}
/**
* Handler: find_references
*/
async function handleFindReferences(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
symbol_name: params.symbol_name,
limit: params.limit,
};
if (params.symbol_kind) {
args.symbol_kind = params.symbol_kind;
}
return executeCodexLensAPI('find_references', args);
}
/**
* Handler: get_hover
*/
async function handleGetHover(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
symbol_name: params.symbol_name,
};
if (params.file_context) {
args.file_path = params.file_context;
}
return executeCodexLensAPI('get_hover', args);
}
/**
* Main handler function
*/
export async function handler(params: Record<string, unknown>): Promise<ToolResult<LSPResult>> {
try {
// Validate parameters
const validatedParams = ParamsSchema.parse(params);
// Route to appropriate handler based on action
let result: LSPResult;
switch (validatedParams.action) {
case 'symbol_search':
result = await handleSymbolSearch(validatedParams);
break;
case 'find_definition':
result = await handleFindDefinition(validatedParams);
break;
case 'find_references':
result = await handleFindReferences(validatedParams);
break;
case 'get_hover':
result = await handleGetHover(validatedParams);
break;
default:
return {
success: false,
error: `Unknown action: ${(validatedParams as any).action}`,
result: null as any,
};
}
if (!result.success) {
return {
success: false,
error: result.error || 'Unknown error',
result: null as any,
};
}
return {
success: true,
result,
};
} catch (err) {
if (err instanceof z.ZodError) {
return {
success: false,
error: `Parameter validation failed: ${err.issues.map((e: z.ZodIssue) => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
result: null as any,
};
}
return {
success: false,
error: `Execution failed: ${(err as Error).message}`,
result: null as any,
};
}
}
/**
* Tool schema for MCP
*/
export const schema: ToolSchema = {
name: 'codex_lens_lsp',
description: `LSP-like code intelligence tool powered by CodexLens indexing.
**Actions:**
- symbol_search: Search for symbols across the workspace
- find_definition: Find the definition of a symbol
- find_references: Find all references to a symbol
- get_hover: Get hover information for a symbol
**Usage Examples:**
Search symbols:
codex_lens_lsp(action="symbol_search", symbol_name="MyClass")
codex_lens_lsp(action="symbol_search", symbol_name="auth", kind_filter=["function", "method"])
codex_lens_lsp(action="symbol_search", symbol_name="User", file_pattern="*.py")
Find definition:
codex_lens_lsp(action="find_definition", symbol_name="authenticate")
codex_lens_lsp(action="find_definition", symbol_name="User", symbol_kind="class")
Find references:
codex_lens_lsp(action="find_references", symbol_name="login")
Get hover info:
codex_lens_lsp(action="get_hover", symbol_name="processPayment")
**Requirements:**
- CodexLens must be installed and indexed: run smart_search(action="init") first
- Python environment with codex-lens package available`,
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['symbol_search', 'find_definition', 'find_references', 'get_hover'],
description: 'LSP action to perform',
},
symbol_name: {
type: 'string',
description: 'Symbol name to search/query (required)',
},
project_root: {
type: 'string',
description: 'Project root directory (auto-detected if not provided)',
},
symbol_kind: {
type: 'string',
description: 'Symbol kind filter: class, function, method, variable, etc. (optional)',
},
file_context: {
type: 'string',
description: 'Current file path for proximity ranking (optional)',
},
limit: {
type: 'number',
description: 'Maximum number of results to return (default: 50)',
default: 50,
},
kind_filter: {
type: 'array',
items: { type: 'string' },
description: 'List of symbol kinds to include (for symbol_search)',
},
file_pattern: {
type: 'string',
description: 'Glob pattern to filter files (for symbol_search)',
},
},
required: ['action', 'symbol_name'],
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ import * as cliExecutorMod from './cli-executor.js';
import * as smartSearchMod from './smart-search.js';
import { executeInitWithProgress } from './smart-search.js';
// codex_lens removed - functionality integrated into smart_search
import * as codexLensLspMod from './codex-lens-lsp.js';
// codex_lens_lsp removed - v1 LSP bridge removed
import * as readFileMod from './read-file.js';
import * as readManyFilesMod from './read-many-files.js';
import * as readOutlineMod from './read-outline.js';
@@ -365,7 +365,7 @@ registerTool(toLegacyTool(sessionManagerMod));
registerTool(toLegacyTool(cliExecutorMod));
registerTool(toLegacyTool(smartSearchMod));
// codex_lens removed - functionality integrated into smart_search
registerTool(toLegacyTool(codexLensLspMod));
// codex_lens_lsp removed - v1 LSP bridge removed
registerTool(toLegacyTool(readFileMod));
registerTool(toLegacyTool(readManyFilesMod));
registerTool(toLegacyTool(readOutlineMod));

View File

@@ -1,64 +1,23 @@
/**
* LiteLLM Client - Bridge between CCW and ccw-litellm Python package
* Provides LLM chat and embedding capabilities via spawned Python process
* LiteLLM Client - STUB (v1 Python bridge removed)
*
* Features:
* - Chat completions with multiple models
* - Text embeddings generation
* - Configuration management
* - JSON protocol communication
* The Python ccw-litellm bridge has been removed. This module provides
* no-op stubs so that existing consumers compile without errors.
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { getCodexLensPython, getCodexLensHiddenPython, getCodexLensVenvDir } from '../utils/codexlens-path.js';
const V1_REMOVED = 'LiteLLM Python bridge has been removed (v1 cleanup).';
export interface LiteLLMConfig {
pythonPath?: string; // Default: CodexLens venv Python
configPath?: string; // Configuration file path
timeout?: number; // Default 60000ms
pythonPath?: string;
configPath?: string;
timeout?: number;
}
// Platform-specific constants for CodexLens venv
const IS_WINDOWS = process.platform === 'win32';
const CODEXLENS_VENV = getCodexLensVenvDir();
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'pythonw.exe' : 'python';
/**
* Get the Python path from CodexLens venv
* Falls back to system 'python' if venv doesn't exist
* @returns Path to Python executable
*/
export function getCodexLensVenvPython(): string {
const venvPython = join(CODEXLENS_VENV, VENV_BIN_DIR, PYTHON_EXECUTABLE);
if (existsSync(venvPython)) {
return venvPython;
}
const hiddenPython = getCodexLensHiddenPython();
if (existsSync(hiddenPython)) {
return hiddenPython;
}
// Fallback to system Python if venv not available
return 'python';
}
/**
* Get the Python path from CodexLens venv using centralized path utility
* Falls back to system 'python' if venv doesn't exist
* @returns Path to Python executable
*/
export function getCodexLensPythonPath(): string {
const codexLensPython = getCodexLensHiddenPython();
if (existsSync(codexLensPython)) {
return codexLensPython;
}
const fallbackPython = getCodexLensPython();
if (existsSync(fallbackPython)) {
return fallbackPython;
}
// Fallback to system Python if venv not available
return 'python';
}
@@ -90,179 +49,35 @@ export interface LiteLLMStatus {
}
export class LiteLLMClient {
private pythonPath: string;
private configPath?: string;
private timeout: number;
constructor(_config: LiteLLMConfig = {}) {}
constructor(config: LiteLLMConfig = {}) {
this.pythonPath = config.pythonPath || getCodexLensVenvPython();
this.configPath = config.configPath;
this.timeout = config.timeout || 60000;
}
/**
* Execute Python ccw-litellm command
*/
private async executePython(args: string[], options: { timeout?: number } = {}): Promise<string> {
const timeout = options.timeout || this.timeout;
return new Promise((resolve, reject) => {
const proc = spawn(this.pythonPath, ['-m', 'ccw_litellm.cli', ...args], {
shell: false,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
});
let stdout = '';
let stderr = '';
let timedOut = false;
// Set up timeout
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill('SIGTERM');
reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout);
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to spawn Python process: ${error.message}`));
});
proc.on('close', (code) => {
clearTimeout(timeoutId);
if (timedOut) {
return; // Already rejected
}
if (code === 0) {
resolve(stdout.trim());
} else {
const errorMsg = stderr.trim() || `Process exited with code ${code}`;
reject(new Error(errorMsg));
}
});
});
}
/**
* Check if ccw-litellm is available
*/
async isAvailable(): Promise<boolean> {
try {
// Increased timeout to 15s for Python cold start
await this.executePython(['version'], { timeout: 15000 });
return true;
} catch {
return false;
}
return false;
}
/**
* Get status information
*/
async getStatus(): Promise<LiteLLMStatus> {
try {
// Increased timeout to 15s for Python cold start
const output = await this.executePython(['version'], { timeout: 15000 });
// Parse "ccw-litellm 0.1.0" format
const versionMatch = output.trim().match(/ccw-litellm\s+([\d.]+)/);
const version = versionMatch ? versionMatch[1] : output.trim();
return {
available: true,
version
};
} catch (error: any) {
return {
available: false,
error: error.message
};
}
return { available: false, error: V1_REMOVED };
}
/**
* Get current configuration
*/
async getConfig(): Promise<any> {
// config command outputs JSON by default, no --json flag needed
const output = await this.executePython(['config']);
return JSON.parse(output);
async getConfig(): Promise<unknown> {
return { error: V1_REMOVED };
}
/**
* Generate embeddings for texts
*/
async embed(texts: string[], model: string = 'default'): Promise<EmbedResponse> {
if (!texts || texts.length === 0) {
throw new Error('texts array cannot be empty');
}
const args = ['embed', '--model', model, '--output', 'json'];
// Add texts as arguments
for (const text of texts) {
args.push(text);
}
const output = await this.executePython(args, { timeout: this.timeout * 2 });
const vectors = JSON.parse(output);
return {
vectors,
dimensions: vectors[0]?.length || 0,
model
};
async embed(_texts: string[], _model?: string): Promise<EmbedResponse> {
throw new Error(V1_REMOVED);
}
/**
* Chat with LLM
*/
async chat(message: string, model: string = 'default'): Promise<string> {
if (!message) {
throw new Error('message cannot be empty');
}
const args = ['chat', '--model', model, message];
return this.executePython(args, { timeout: this.timeout * 2 });
async chat(_message: string, _model?: string): Promise<string> {
throw new Error(V1_REMOVED);
}
/**
* Multi-turn chat with messages array
*/
async chatMessages(messages: ChatMessage[], model: string = 'default'): Promise<ChatResponse> {
if (!messages || messages.length === 0) {
throw new Error('messages array cannot be empty');
}
// For now, just use the last user message
// TODO: Implement full message history support in ccw-litellm
const lastMessage = messages[messages.length - 1];
const content = await this.chat(lastMessage.content, model);
return {
content,
model,
usage: undefined // TODO: Add usage tracking
};
async chatMessages(_messages: ChatMessage[], _model?: string): Promise<ChatResponse> {
throw new Error(V1_REMOVED);
}
}
// Singleton instance
let _client: LiteLLMClient | null = null;
/**
* Get or create singleton LiteLLM client
*/
export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
if (!_client) {
_client = new LiteLLMClient(config);
@@ -270,29 +85,10 @@ export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
return _client;
}
/**
* Check if LiteLLM is available
*/
export async function checkLiteLLMAvailable(): Promise<boolean> {
try {
const client = getLiteLLMClient();
return await client.isAvailable();
} catch {
return false;
}
return false;
}
/**
* Get LiteLLM status
*/
export async function getLiteLLMStatus(): Promise<LiteLLMStatus> {
try {
const client = getLiteLLMClient();
return await client.getStatus();
} catch (error: any) {
return {
available: false,
error: error.message
};
}
return { available: false, error: V1_REMOVED };
}

View File

@@ -9,7 +9,6 @@
* 2. Default: ~/.codexlens
*/
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
@@ -26,56 +25,3 @@ export function getCodexLensDataDir(): string {
}
return join(homedir(), '.codexlens');
}
/**
* Get the CodexLens virtual environment path.
*
* @returns Path to CodexLens venv directory
*/
export function getCodexLensVenvDir(): string {
return join(getCodexLensDataDir(), 'venv');
}
/**
* Get the Python executable path in the CodexLens venv.
*
* @returns Path to python executable
*/
export function getCodexLensPython(): string {
const venvDir = getCodexLensVenvDir();
return process.platform === 'win32'
? join(venvDir, 'Scripts', 'python.exe')
: join(venvDir, 'bin', 'python');
}
/**
* Get the preferred Python executable for hidden/windowless CodexLens subprocesses.
* On Windows this prefers pythonw.exe when available to avoid transient console windows.
*
* @returns Path to the preferred hidden-subprocess Python executable
*/
export function getCodexLensHiddenPython(): string {
if (process.platform !== 'win32') {
return getCodexLensPython();
}
const venvDir = getCodexLensVenvDir();
const pythonwPath = join(venvDir, 'Scripts', 'pythonw.exe');
if (existsSync(pythonwPath)) {
return pythonwPath;
}
return getCodexLensPython();
}
/**
* Get the pip executable path in the CodexLens venv.
*
* @returns Path to pip executable
*/
export function getCodexLensPip(): string {
const venvDir = getCodexLensVenvDir();
return process.platform === 'win32'
? join(venvDir, 'Scripts', 'pip.exe')
: join(venvDir, 'bin', 'pip');
}

View File

@@ -1,327 +0,0 @@
/**
* Unified Package Discovery for local Python packages (codex-lens, ccw-litellm)
*
* Provides a single, transparent path discovery mechanism with:
* - Environment variable overrides (highest priority)
* - ~/.codexlens/config.json configuration
* - Extended search paths (npm global, PACKAGE_ROOT, siblings, etc.)
* - Full search result transparency for diagnostics
*/
import { existsSync, readFileSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { getCodexLensDataDir } from './codexlens-path.js';
import { EXEC_TIMEOUTS } from './exec-constants.js';
// Get directory of this module (src/utils/)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ========================================
// Types
// ========================================
/** Source that found the package path */
export type PackageSource =
| 'env' // Environment variable override
| 'config' // ~/.codexlens/config.json
| 'sibling' // Sibling directory to ccw project root
| 'npm-global' // npm global prefix
| 'cwd' // Current working directory
| 'cwd-parent' // Parent of current working directory
| 'homedir' // User home directory
| 'package-root'; // npm package internal path
/** A single search attempt result */
export interface SearchAttempt {
path: string;
source: PackageSource;
exists: boolean;
}
/** Result of package discovery */
export interface PackageDiscoveryResult {
/** Resolved package path, or null if not found */
path: string | null;
/** Source that found the package */
source: PackageSource | null;
/** All paths searched (for diagnostics) */
searchedPaths: SearchAttempt[];
/** Whether the found path is inside node_modules */
insideNodeModules: boolean;
}
/** Known local package names */
export type LocalPackageName = 'codex-lens' | 'ccw-litellm' | 'codexlens-search';
/** Environment variable mapping for each package */
const PACKAGE_ENV_VARS: Record<LocalPackageName, string> = {
'codex-lens': 'CODEXLENS_PACKAGE_PATH',
'ccw-litellm': 'CCW_LITELLM_PATH',
'codexlens-search': 'CODEXLENS_SEARCH_PATH',
};
/** Config key mapping for each package */
const PACKAGE_CONFIG_KEYS: Record<LocalPackageName, string> = {
'codex-lens': 'codexLensPath',
'ccw-litellm': 'ccwLitellmPath',
'codexlens-search': 'codexlensSearchPath',
};
// ========================================
// Helpers
// ========================================
/**
* Check if a path is inside node_modules
*/
export function isInsideNodeModules(pathToCheck: string): boolean {
const normalized = pathToCheck.replace(/\\/g, '/').toLowerCase();
return normalized.includes('/node_modules/');
}
/**
* Check if running in a development environment (not from node_modules)
*/
export function isDevEnvironment(): boolean {
// Yarn PnP detection
if ((process.versions as Record<string, unknown>).pnp) {
return false;
}
return !isInsideNodeModules(__dirname);
}
/**
* Read package paths from ~/.codexlens/config.json
*/
function readConfigPath(packageName: LocalPackageName): string | null {
try {
const configPath = join(getCodexLensDataDir(), 'config.json');
if (!existsSync(configPath)) return null;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const key = PACKAGE_CONFIG_KEYS[packageName];
const value = config?.packagePaths?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
return null;
}
}
/**
* Get npm global prefix directory
*/
let _npmGlobalPrefix: string | null | undefined;
function getNpmGlobalPrefix(): string | null {
if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix;
try {
const result = execSync('npm prefix -g', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
stdio: ['pipe', 'pipe', 'pipe'],
});
_npmGlobalPrefix = result.trim() || null;
} catch {
_npmGlobalPrefix = null;
}
return _npmGlobalPrefix;
}
/**
* Check if a directory contains a valid Python package (has pyproject.toml)
*/
function isValidPackageDir(dir: string): boolean {
return existsSync(join(dir, 'pyproject.toml'));
}
// ========================================
// Main Discovery Function
// ========================================
/**
* Find a local Python package path with unified search logic.
*
* Search priority:
* 1. Environment variable (CODEXLENS_PACKAGE_PATH / CCW_LITELLM_PATH)
* 2. ~/.codexlens/config.json packagePaths
* 3. Sibling directory to ccw project root (src/utils -> ../../..)
* 4. npm global prefix node_modules path
* 5. Current working directory
* 6. Parent of current working directory
* 7. Home directory
*
* Two-pass search: first pass skips node_modules paths, second pass allows them.
*
* @param packageName - Package to find ('codex-lens' or 'ccw-litellm')
* @returns Discovery result with path, source, and all searched paths
*/
export function findPackagePath(packageName: LocalPackageName): PackageDiscoveryResult {
const searched: SearchAttempt[] = [];
// Helper to check and record a path
const check = (path: string, source: PackageSource): boolean => {
const resolvedPath = resolve(path);
const exists = isValidPackageDir(resolvedPath);
searched.push({ path: resolvedPath, source, exists });
return exists;
};
// 1. Environment variable (highest priority, skip two-pass)
const envKey = PACKAGE_ENV_VARS[packageName];
const envPath = process.env[envKey];
if (envPath) {
if (check(envPath, 'env')) {
return {
path: resolve(envPath),
source: 'env',
searchedPaths: searched,
insideNodeModules: isInsideNodeModules(envPath),
};
}
// Env var set but path invalid — continue searching but warn
console.warn(`[PackageDiscovery] ${envKey}="${envPath}" set but pyproject.toml not found, continuing search...`);
}
// 2. Config file
const configPath = readConfigPath(packageName);
if (configPath) {
if (check(configPath, 'config')) {
return {
path: resolve(configPath),
source: 'config',
searchedPaths: searched,
insideNodeModules: isInsideNodeModules(configPath),
};
}
}
// Build candidate paths for two-pass search
const candidates: { path: string; source: PackageSource }[] = [];
// 3. Sibling directory to ccw project root
// __dirname = src/utils/ → project root = ../../..
// Also try one more level up for nested structures
const projectRoot = join(__dirname, '..', '..', '..');
candidates.push({ path: join(projectRoot, packageName), source: 'sibling' });
candidates.push({ path: join(projectRoot, '..', packageName), source: 'sibling' });
// 4. npm global prefix
const npmPrefix = getNpmGlobalPrefix();
if (npmPrefix) {
// npm global: prefix/node_modules/claude-code-workflow/<packageName>
candidates.push({
path: join(npmPrefix, 'node_modules', 'claude-code-workflow', packageName),
source: 'npm-global',
});
// npm global: prefix/lib/node_modules/claude-code-workflow/<packageName> (Linux/Mac)
candidates.push({
path: join(npmPrefix, 'lib', 'node_modules', 'claude-code-workflow', packageName),
source: 'npm-global',
});
// npm global sibling: prefix/node_modules/<packageName>
candidates.push({
path: join(npmPrefix, 'node_modules', packageName),
source: 'npm-global',
});
}
// 5. Current working directory
const cwd = process.cwd();
candidates.push({ path: join(cwd, packageName), source: 'cwd' });
// 6. Parent of cwd (common workspace layout)
const cwdParent = dirname(cwd);
if (cwdParent !== cwd) {
candidates.push({ path: join(cwdParent, packageName), source: 'cwd-parent' });
}
// 7. Home directory
candidates.push({ path: join(homedir(), packageName), source: 'homedir' });
// Two-pass search: prefer non-node_modules paths first
// First pass: skip node_modules
for (const candidate of candidates) {
const resolvedPath = resolve(candidate.path);
if (isInsideNodeModules(resolvedPath)) continue;
if (check(resolvedPath, candidate.source)) {
console.log(`[PackageDiscovery] Found ${packageName} at: ${resolvedPath} (source: ${candidate.source})`);
return {
path: resolvedPath,
source: candidate.source,
searchedPaths: searched,
insideNodeModules: false,
};
}
}
// Second pass: allow node_modules paths
for (const candidate of candidates) {
const resolvedPath = resolve(candidate.path);
if (!isInsideNodeModules(resolvedPath)) continue;
// Skip if already checked in first pass
if (searched.some(s => s.path === resolvedPath)) continue;
if (check(resolvedPath, candidate.source)) {
console.log(`[PackageDiscovery] Found ${packageName} in node_modules at: ${resolvedPath} (source: ${candidate.source})`);
return {
path: resolvedPath,
source: candidate.source,
searchedPaths: searched,
insideNodeModules: true,
};
}
}
// Not found
return {
path: null,
source: null,
searchedPaths: searched,
insideNodeModules: false,
};
}
/**
* Find codex-lens package path (convenience wrapper)
*/
export function findCodexLensPath(): PackageDiscoveryResult {
return findPackagePath('codex-lens');
}
/**
* Find ccw-litellm package path (convenience wrapper)
*/
export function findCcwLitellmPath(): PackageDiscoveryResult {
return findPackagePath('ccw-litellm');
}
/**
* Find codexlens-search (v2) package path (convenience wrapper)
*/
export function findCodexLensSearchPath(): PackageDiscoveryResult {
return findPackagePath('codexlens-search');
}
/**
* Format search results for error messages
*/
export function formatSearchResults(result: PackageDiscoveryResult, packageName: string): string {
const lines = [`Cannot find '${packageName}' package directory.\n`];
lines.push('Searched locations:');
for (const attempt of result.searchedPaths) {
const status = attempt.exists ? '✓' : '✗';
lines.push(` ${status} [${attempt.source}] ${attempt.path}`);
}
lines.push('');
lines.push('To fix this:');
const envKey = PACKAGE_ENV_VARS[packageName as LocalPackageName] || `${packageName.toUpperCase().replace(/-/g, '_')}_PATH`;
lines.push(` 1. Set environment variable: ${envKey}=/path/to/${packageName}`);
lines.push(` 2. Or add to ~/.codexlens/config.json: { "packagePaths": { "${PACKAGE_CONFIG_KEYS[packageName as LocalPackageName] || packageName}": "/path/to/${packageName}" } }`);
lines.push(` 3. Or ensure '${packageName}' directory exists as a sibling to the ccw project`);
return lines.join('\n');
}

View File

@@ -1,269 +0,0 @@
/**
* Python detection and version compatibility utilities
* Shared module for consistent Python discovery across the application
*/
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'child_process';
import { EXEC_TIMEOUTS } from './exec-constants.js';
export interface PythonCommandSpec {
command: string;
args: string[];
display: string;
}
type HiddenPythonProbeOptions = Omit<SpawnSyncOptionsWithStringEncoding, 'encoding'> & {
encoding?: BufferEncoding;
};
function isExecTimeoutError(error: unknown): boolean {
const err = error as { code?: unknown; errno?: unknown; message?: unknown } | null;
const code = err?.code ?? err?.errno;
if (code === 'ETIMEDOUT') return true;
const message = typeof err?.message === 'string' ? err.message : '';
return message.includes('ETIMEDOUT');
}
function quoteCommandPart(value: string): string {
if (!/[\s"]/.test(value)) {
return value;
}
return `"${value.replaceAll('"', '\\"')}"`;
}
function formatPythonCommandDisplay(command: string, args: string[]): string {
return [quoteCommandPart(command), ...args.map(quoteCommandPart)].join(' ');
}
function buildPythonCommandSpec(command: string, args: string[] = []): PythonCommandSpec {
return {
command,
args: [...args],
display: formatPythonCommandDisplay(command, args),
};
}
function tokenizeCommandSpec(raw: string): string[] {
const tokens: string[] = [];
const tokenPattern = /"((?:\\"|[^"])*)"|(\S+)/g;
for (const match of raw.matchAll(tokenPattern)) {
const quoted = match[1];
const plain = match[2];
if (quoted !== undefined) {
tokens.push(quoted.replaceAll('\\"', '"'));
} else if (plain !== undefined) {
tokens.push(plain);
}
}
return tokens;
}
export function parsePythonCommandSpec(raw: string): PythonCommandSpec {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error('Python command cannot be empty');
}
// Unquoted executable paths on Windows commonly contain spaces.
if (!trimmed.includes('"') && /[\\/]/.test(trimmed)) {
return buildPythonCommandSpec(trimmed);
}
const tokens = tokenizeCommandSpec(trimmed);
if (tokens.length === 0) {
return buildPythonCommandSpec(trimmed);
}
return buildPythonCommandSpec(tokens[0], tokens.slice(1));
}
function buildPythonProbeOptions(
overrides: HiddenPythonProbeOptions = {},
): SpawnSyncOptionsWithStringEncoding {
const { env, encoding, ...rest } = overrides;
return {
shell: false,
windowsHide: true,
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
...rest,
encoding: encoding ?? 'utf8',
};
}
export function probePythonCommandVersion(
pythonCommand: PythonCommandSpec,
runner: typeof spawnSync = spawnSync,
): string {
const result = runner(
pythonCommand.command,
[...pythonCommand.args, '--version'],
buildPythonProbeOptions(),
);
if (result.error) {
throw result.error;
}
const versionOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
if (result.status !== 0) {
throw new Error(versionOutput || `Python version probe exited with code ${String(result.status)}`);
}
return versionOutput;
}
/**
* Parse Python version string to major.minor numbers
* @param versionStr - Version string like "Python 3.11.5"
* @returns Object with major and minor version numbers, or null if parsing fails
*/
export function parsePythonVersion(versionStr: string): { major: number; minor: number } | null {
const match = versionStr.match(/Python\s+(\d+)\.(\d+)/);
if (match) {
return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
}
return null;
}
/**
* Check if Python version is compatible with onnxruntime (3.9-3.12)
* @param major - Major version number
* @param minor - Minor version number
* @returns true if compatible
*/
export function isPythonVersionCompatible(major: number, minor: number): boolean {
// onnxruntime currently supports Python 3.9-3.12
return major === 3 && minor >= 9 && minor <= 12;
}
/**
* Detect available Python 3 executable
* Supports CCW_PYTHON environment variable for custom Python path
* On Windows, uses py launcher to find compatible versions
* @returns Python executable command spec
*/
export function getSystemPythonCommand(runner: typeof spawnSync = spawnSync): PythonCommandSpec {
const customPython = process.env.CCW_PYTHON?.trim();
if (customPython) {
const customSpec = parsePythonCommandSpec(customPython);
try {
const version = probePythonCommandVersion(customSpec, runner);
if (version.includes('Python 3')) {
const parsed = parsePythonVersion(version);
if (parsed && !isPythonVersionCompatible(parsed.major, parsed.minor)) {
console.warn(
`[Python] Warning: CCW_PYTHON points to Python ${parsed.major}.${parsed.minor}, which may not be compatible with onnxruntime (requires 3.9-3.12)`,
);
}
return customSpec;
}
} catch (err: unknown) {
if (isExecTimeoutError(err)) {
console.warn(
`[Python] Warning: CCW_PYTHON version check timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms, falling back to system Python`,
);
} else {
console.warn(
`[Python] Warning: CCW_PYTHON="${customPython}" is not a valid Python executable, falling back to system Python`,
);
}
}
}
if (process.platform === 'win32') {
const compatibleVersions = ['3.12', '3.11', '3.10', '3.9'];
for (const ver of compatibleVersions) {
const launcherSpec = buildPythonCommandSpec('py', [`-${ver}`]);
try {
const version = probePythonCommandVersion(launcherSpec, runner);
if (version.includes(`Python ${ver}`)) {
console.log(`[Python] Found compatible Python ${ver} via py launcher`);
return launcherSpec;
}
} catch (err: unknown) {
if (isExecTimeoutError(err)) {
console.warn(
`[Python] Warning: py -${ver} version check timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms`,
);
}
}
}
}
const commands = process.platform === 'win32' ? ['python', 'py', 'python3'] : ['python3', 'python'];
let fallbackCmd: PythonCommandSpec | null = null;
let fallbackVersion: { major: number; minor: number } | null = null;
for (const cmd of commands) {
const pythonSpec = buildPythonCommandSpec(cmd);
try {
const version = probePythonCommandVersion(pythonSpec, runner);
if (version.includes('Python 3')) {
const parsed = parsePythonVersion(version);
if (parsed) {
if (isPythonVersionCompatible(parsed.major, parsed.minor)) {
return pythonSpec;
}
if (!fallbackCmd) {
fallbackCmd = pythonSpec;
fallbackVersion = parsed;
}
}
}
} catch (err: unknown) {
if (isExecTimeoutError(err)) {
console.warn(`[Python] Warning: ${cmd} --version timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms`);
}
}
}
if (fallbackCmd && fallbackVersion) {
console.warn(
`[Python] Warning: Only Python ${fallbackVersion.major}.${fallbackVersion.minor} found, which may not be compatible with onnxruntime (requires 3.9-3.12).`,
);
console.warn('[Python] Semantic search may fail with ImportError for onnxruntime.');
console.warn('[Python] To use a specific Python version, set CCW_PYTHON environment variable:');
console.warn(' Windows: set CCW_PYTHON=C:\\path\\to\\python.exe');
console.warn(' Unix: export CCW_PYTHON=/path/to/python3.11');
console.warn('[Python] Alternatively, use LiteLLM embedding backend which has no Python version restrictions.');
return fallbackCmd;
}
throw new Error(
'Python 3 not found. Please install Python 3.9-3.12 and ensure it is in PATH, or set CCW_PYTHON environment variable.',
);
}
/**
* Detect available Python 3 executable
* Supports CCW_PYTHON environment variable for custom Python path
* On Windows, uses py launcher to find compatible versions
* @returns Python executable command
*/
export function getSystemPython(): string {
return getSystemPythonCommand().display;
}
/**
* Get the Python command for pip operations (uses -m pip for reliability)
* @returns Array of command arguments for spawn
*/
export function getPipCommand(): { pythonCmd: string; pipArgs: string[] } {
const pythonCmd = getSystemPython();
return {
pythonCmd,
pipArgs: ['-m', 'pip'],
};
}
export const __testables = {
buildPythonCommandSpec,
buildPythonProbeOptions,
formatPythonCommandDisplay,
parsePythonCommandSpec,
probePythonCommandVersion,
};

View File

@@ -1,902 +0,0 @@
/**
* UV Package Manager Tool
* Provides unified UV (https://github.com/astral-sh/uv) tool management capabilities
*
* Features:
* - Cross-platform UV binary discovery and installation
* - Virtual environment creation and management
* - Python dependency installation with UV's fast resolver
* - Support for local project installs with extras
*/
import { spawn, spawnSync, type SpawnOptions, type SpawnSyncOptionsWithStringEncoding } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir, platform, arch } from 'os';
import { EXEC_TIMEOUTS } from './exec-constants.js';
import { getCodexLensDataDir, getCodexLensVenvDir } from './codexlens-path.js';
/**
* Configuration for UvManager
*/
export interface UvManagerConfig {
/** Path to the virtual environment directory */
venvPath: string;
/** Python version requirement (e.g., ">=3.10", "3.11") */
pythonVersion?: string;
}
/**
* Result of UV operations
*/
export interface UvInstallResult {
/** Whether the operation succeeded */
success: boolean;
/** Error message if operation failed */
error?: string;
/** Duration of the operation in milliseconds */
duration?: number;
}
/**
* UV binary search locations in priority order
*/
interface UvSearchLocation {
path: string;
description: string;
}
// Platform-specific constants
const IS_WINDOWS = platform() === 'win32';
const UV_BINARY_NAME = IS_WINDOWS ? 'uv.exe' : 'uv';
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python';
type HiddenUvSpawnSyncOptions = Omit<SpawnSyncOptionsWithStringEncoding, 'encoding'> & {
encoding?: BufferEncoding;
};
function buildUvSpawnOptions(overrides: SpawnOptions = {}): SpawnOptions {
const { env, ...rest } = overrides;
return {
shell: false,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
...rest,
};
}
function buildUvSpawnSyncOptions(
overrides: HiddenUvSpawnSyncOptions = {},
): SpawnSyncOptionsWithStringEncoding {
const { env, encoding, ...rest } = overrides;
return {
shell: false,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
...rest,
encoding: encoding ?? 'utf-8',
};
}
function findExecutableOnPath(executable: string, runner: typeof spawnSync = spawnSync): string | null {
const lookupCommand = IS_WINDOWS ? 'where' : 'which';
const result = runner(
lookupCommand,
[executable],
buildUvSpawnSyncOptions({
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
stdio: ['ignore', 'pipe', 'pipe'],
}),
);
if (result.error || result.status !== 0) {
return null;
}
const output = `${result.stdout ?? ''}`.trim();
if (!output) {
return null;
}
return output.split(/\r?\n/)[0] || null;
}
function hasWindowsPythonLauncherVersion(version: string, runner: typeof spawnSync = spawnSync): boolean {
const result = runner(
'py',
[`-${version}`, '--version'],
buildUvSpawnSyncOptions({
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
stdio: ['ignore', 'pipe', 'pipe'],
}),
);
if (result.error || result.status !== 0) {
return false;
}
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
return output.includes(`Python ${version}`);
}
/**
* Get the path to the UV binary
* Search order:
* 1. CCW_UV_PATH environment variable
* 2. Project vendor/uv/ directory
* 3. User local directories (~/.local/bin, ~/.cargo/bin)
* 4. System PATH
*
* @returns Path to the UV binary
*/
export function getUvBinaryPath(): string {
const searchLocations: UvSearchLocation[] = [];
// 1. Environment variable (highest priority)
const envPath = process.env.CCW_UV_PATH;
if (envPath) {
searchLocations.push({ path: envPath, description: 'CCW_UV_PATH environment variable' });
}
// 2. Project vendor directory
const vendorPaths = [
join(process.cwd(), 'vendor', 'uv', UV_BINARY_NAME),
join(dirname(process.cwd()), 'vendor', 'uv', UV_BINARY_NAME),
];
for (const vendorPath of vendorPaths) {
searchLocations.push({ path: vendorPath, description: 'Project vendor directory' });
}
// 3. User local directories
const home = homedir();
if (IS_WINDOWS) {
// Windows: AppData\Local\uv and .cargo\bin
searchLocations.push(
{ path: join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME), description: 'UV AppData' },
{ path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' },
{ path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' },
);
} else {
// Unix: ~/.local/bin and ~/.cargo/bin
searchLocations.push(
{ path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' },
{ path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' },
);
}
// Check each location
for (const location of searchLocations) {
if (existsSync(location.path)) {
return location.path;
}
}
// 4. Try system PATH using 'which' or 'where'
const foundPath = findExecutableOnPath('uv');
if (foundPath && existsSync(foundPath)) {
return foundPath;
}
// Return default path (may not exist)
if (IS_WINDOWS) {
return join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME);
}
return join(home, '.local', 'bin', UV_BINARY_NAME);
}
/**
* Check if UV is available and working
* @returns True if UV is installed and functional
*/
export async function isUvAvailable(): Promise<boolean> {
const uvPath = getUvBinaryPath();
if (!existsSync(uvPath)) {
return false;
}
return new Promise((resolve) => {
const child = spawn(uvPath, ['--version'], buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
}));
child.on('close', (code) => {
resolve(code === 0);
});
child.on('error', () => {
resolve(false);
});
});
}
/**
* Get UV version string
* @returns UV version or null if not available
*/
export async function getUvVersion(): Promise<string | null> {
const uvPath = getUvBinaryPath();
if (!existsSync(uvPath)) {
return null;
}
return new Promise((resolve) => {
const child = spawn(uvPath, ['--version'], buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
}));
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
// Parse "uv 0.4.0" -> "0.4.0"
const match = stdout.match(/uv\s+(\S+)/);
resolve(match ? match[1] : stdout.trim());
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
/**
* Download and install UV using the official installation script
* @returns True if installation succeeded
*/
export async function ensureUvInstalled(): Promise<boolean> {
// Check if already installed
if (await isUvAvailable()) {
return true;
}
console.log('[UV] Installing UV package manager...');
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
if (IS_WINDOWS) {
// Windows: Use PowerShell to run the install script
const installCmd = 'irm https://astral.sh/uv/install.ps1 | iex';
child = spawn('powershell', ['-ExecutionPolicy', 'ByPass', '-Command', installCmd], buildUvSpawnOptions({
stdio: ['pipe', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
} else {
// Unix: Use curl and sh
const installCmd = 'curl -LsSf https://astral.sh/uv/install.sh | sh';
child = spawn('sh', ['-c', installCmd], buildUvSpawnOptions({
stdio: ['pipe', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
}
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) console.log(`[UV] ${line}`);
});
child.stderr?.on('data', (data) => {
const line = data.toString().trim();
if (line) console.log(`[UV] ${line}`);
});
child.on('close', (code) => {
if (code === 0) {
console.log('[UV] UV installed successfully');
resolve(true);
} else {
console.error(`[UV] Installation failed with code ${code}`);
resolve(false);
}
});
child.on('error', (err) => {
console.error(`[UV] Installation failed: ${err.message}`);
resolve(false);
});
});
}
/**
* UvManager class for virtual environment and package management
*/
export class UvManager {
private readonly venvPath: string;
private readonly pythonVersion?: string;
/**
* Create a new UvManager instance
* @param config - Configuration options
*/
constructor(config: UvManagerConfig) {
this.venvPath = config.venvPath;
this.pythonVersion = config.pythonVersion;
}
/**
* Get the path to the Python executable inside the virtual environment
* @returns Path to the Python executable
*/
getVenvPython(): string {
return join(this.venvPath, VENV_BIN_DIR, PYTHON_EXECUTABLE);
}
/**
* Get the path to pip inside the virtual environment
* @returns Path to the pip executable
*/
getVenvPip(): string {
const pipName = IS_WINDOWS ? 'pip.exe' : 'pip';
return join(this.venvPath, VENV_BIN_DIR, pipName);
}
/**
* Check if the virtual environment exists and is valid
* @returns True if the venv exists and has a working Python
*/
isVenvValid(): boolean {
const pythonPath = this.getVenvPython();
return existsSync(pythonPath);
}
/**
* Create a virtual environment using UV
* @returns Installation result
*/
async createVenv(): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
if (!(await isUvAvailable())) {
const installed = await ensureUvInstalled();
if (!installed) {
return { success: false, error: 'Failed to install UV' };
}
}
const uvPath = getUvBinaryPath();
// Ensure parent directory exists
const parentDir = dirname(this.venvPath);
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true });
}
return new Promise((resolve) => {
const args = ['venv', this.venvPath];
// Add Python version constraint if specified
if (this.pythonVersion) {
args.push('--python', this.pythonVersion);
}
console.log(`[UV] Creating virtual environment at ${this.venvPath}`);
if (this.pythonVersion) {
console.log(`[UV] Python version: ${this.pythonVersion}`);
}
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Virtual environment created successfully (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Install packages from a local project with optional extras
* Uses `uv pip install` for standard installs, or `-e` for editable installs
* @param projectPath - Path to the project directory (must contain pyproject.toml or setup.py)
* @param extras - Optional array of extras to install (e.g., ['semantic', 'dev'])
* @param editable - Whether to install in editable mode (default: false for stability)
* @returns Installation result
*/
async installFromProject(projectPath: string, extras?: string[], editable = false): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
}
const uvPath = getUvBinaryPath();
// Build the install specifier
let installSpec = projectPath;
if (extras && extras.length > 0) {
installSpec = `${projectPath}[${extras.join(',')}]`;
}
return new Promise((resolve) => {
const args = editable
? ['pip', 'install', '-e', installSpec, '--python', this.getVenvPython()]
: ['pip', 'install', installSpec, '--python', this.getVenvPython()];
console.log(`[UV] Installing from project: ${installSpec} (editable: ${editable})`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
cwd: projectPath,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
const line = data.toString().trim();
if (line && !line.startsWith('Resolved') && !line.startsWith('Prepared') && !line.startsWith('Installed')) {
// Only log non-progress lines to stderr
console.log(`[UV] ${line}`);
}
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Project installation successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Install a list of packages
* @param packages - Array of package specifiers (e.g., ['numpy>=1.24', 'requests'])
* @returns Installation result
*/
async install(packages: string[]): Promise<UvInstallResult> {
const startTime = Date.now();
if (packages.length === 0) {
return { success: true, duration: 0 };
}
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'install', ...packages, '--python', this.getVenvPython()];
console.log(`[UV] Installing packages: ${packages.join(', ')}`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Package installation successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Uninstall packages
* @param packages - Array of package names to uninstall
* @returns Uninstall result
*/
async uninstall(packages: string[]): Promise<UvInstallResult> {
const startTime = Date.now();
if (packages.length === 0) {
return { success: true, duration: 0 };
}
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist.' };
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'uninstall', ...packages, '--python', this.getVenvPython()];
console.log(`[UV] Uninstalling packages: ${packages.join(', ')}`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Package uninstallation successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Sync dependencies from a requirements file or pyproject.toml
* Uses `uv pip sync` for deterministic installs
* @param requirementsPath - Path to requirements.txt or pyproject.toml
* @returns Sync result
*/
async sync(requirementsPath: string): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'sync', requirementsPath, '--python', this.getVenvPython()];
console.log(`[UV] Syncing dependencies from: ${requirementsPath}`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Sync successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* List installed packages in the virtual environment
* @returns List of installed packages or null on error
*/
async list(): Promise<{ name: string; version: string }[] | null> {
// Ensure UV is available
if (!(await isUvAvailable())) {
return null;
}
// Ensure venv exists
if (!this.isVenvValid()) {
return null;
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'list', '--format', 'json', '--python', this.getVenvPython()];
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
}));
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
try {
const packages = JSON.parse(stdout);
resolve(packages);
} catch {
resolve(null);
}
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
/**
* Check if a specific package is installed
* @param packageName - Name of the package to check
* @returns True if the package is installed
*/
async isPackageInstalled(packageName: string): Promise<boolean> {
const packages = await this.list();
if (!packages) {
return false;
}
const normalizedName = packageName.toLowerCase().replace(/-/g, '_');
return packages.some(
(pkg) => pkg.name.toLowerCase().replace(/-/g, '_') === normalizedName
);
}
/**
* Run a Python command in the virtual environment
* @param args - Arguments to pass to Python
* @param options - Spawn options
* @returns Result with stdout/stderr
*/
async runPython(
args: string[],
options: { timeout?: number; cwd?: string } = {}
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const pythonPath = this.getVenvPython();
if (!existsSync(pythonPath)) {
return { success: false, stdout: '', stderr: 'Virtual environment does not exist' };
}
return new Promise((resolve) => {
const child = spawn(pythonPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: options.timeout ?? EXEC_TIMEOUTS.PROCESS_SPAWN,
cwd: options.cwd,
}));
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim() });
});
child.on('error', (err) => {
resolve({ success: false, stdout: '', stderr: err.message });
});
});
}
/**
* Get Python version in the virtual environment
* @returns Python version string or null
*/
async getPythonVersion(): Promise<string | null> {
const result = await this.runPython(['--version']);
if (result.success) {
const match = result.stdout.match(/Python\s+(\S+)/);
return match ? match[1] : null;
}
return null;
}
/**
* Delete the virtual environment
* @returns True if deletion succeeded
*/
async deleteVenv(): Promise<boolean> {
if (!existsSync(this.venvPath)) {
return true;
}
try {
const fs = await import('fs');
fs.rmSync(this.venvPath, { recursive: true, force: true });
console.log(`[UV] Virtual environment deleted: ${this.venvPath}`);
return true;
} catch (err) {
console.error(`[UV] Failed to delete venv: ${(err as Error).message}`);
return false;
}
}
}
export function getPreferredCodexLensPythonSpec(): string {
const override = process.env.CCW_PYTHON?.trim();
if (override) {
return override;
}
if (!IS_WINDOWS) {
return '>=3.10,<3.13';
}
// Prefer 3.11/3.10 on Windows because current CodexLens semantic GPU extras
// depend on onnxruntime 1.15.x wheels, which are not consistently available for cp312.
const preferredVersions = ['3.11', '3.10', '3.12'];
for (const version of preferredVersions) {
if (hasWindowsPythonLauncherVersion(version)) {
return version;
}
}
return '>=3.10,<3.13';
}
/**
* Create a UvManager with default settings for CodexLens
* @param dataDir - Base data directory (defaults to ~/.codexlens)
* @returns Configured UvManager instance
*/
export function createCodexLensUvManager(dataDir?: string): UvManager {
const baseDir = dataDir ?? getCodexLensDataDir();
void baseDir;
return new UvManager({
venvPath: getCodexLensVenvDir(),
pythonVersion: getPreferredCodexLensPythonSpec(),
});
}
/**
* Quick bootstrap function: ensure UV is installed and create a venv
* @param venvPath - Path to the virtual environment
* @param pythonVersion - Optional Python version constraint
* @returns Installation result
*/
export async function bootstrapUvVenv(
venvPath: string,
pythonVersion?: string
): Promise<UvInstallResult> {
// Ensure UV is installed first
const uvInstalled = await ensureUvInstalled();
if (!uvInstalled) {
return { success: false, error: 'Failed to install UV' };
}
// Create the venv
const manager = new UvManager({ venvPath, pythonVersion });
return manager.createVenv();
}
export const __testables = {
buildUvSpawnOptions,
buildUvSpawnSyncOptions,
findExecutableOnPath,
hasWindowsPythonLauncherVersion,
};

View File

@@ -1,120 +0,0 @@
/**
* Regression test: CodexLens bootstrap should recover when UV bootstrap fails
* and the existing venv is missing pip (common with UV-created venvs).
*
* We simulate "UV available but broken" by pointing CCW_UV_PATH to the current Node
* executable. `node --version` exits 0 so isUvAvailable() returns true, but any
* `node pip install ...` calls fail, forcing bootstrapVenv() to fall back to pip.
*
* Before running bootstrapVenv(), we pre-create the venv and delete its pip entrypoint
* to mimic a venv that has Python but no pip executable. bootstrapVenv() should
* re-bootstrap pip (ensurepip) or recreate the venv, then succeed.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// repo root: <repo>/ccw/tests -> <repo>
const REPO_ROOT = join(__dirname, '..', '..');
function runNodeEvalModule(script, env) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
cwd: REPO_ROOT,
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('error', (err) => reject(err));
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
describe('CodexLens bootstrap pip repair', () => {
it('repairs missing pip in existing venv during pip fallback', { timeout: 10 * 60 * 1000 }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'codexlens-bootstrap-pip-missing-'));
try {
const script = `
import { execSync } from 'node:child_process';
import { existsSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { getSystemPython } from './ccw/dist/utils/python-utils.js';
import { bootstrapVenv } from './ccw/dist/tools/codex-lens.js';
const dataDir = process.env.CODEXLENS_DATA_DIR;
if (!dataDir) throw new Error('Missing CODEXLENS_DATA_DIR');
mkdirSync(dataDir, { recursive: true });
const venvDir = join(dataDir, 'venv');
// Create a venv up-front so UV bootstrap will skip venv creation and fail on install.
const pythonCmd = getSystemPython();
execSync(pythonCmd + ' -m venv "' + venvDir + '"', { stdio: 'inherit' });
// Simulate a "pip-less" venv by deleting the pip entrypoint.
const pipPath = process.platform === 'win32'
? join(venvDir, 'Scripts', 'pip.exe')
: join(venvDir, 'bin', 'pip');
if (existsSync(pipPath)) {
rmSync(pipPath, { force: true });
}
const result = await bootstrapVenv();
const pipRestored = existsSync(pipPath);
console.log('@@RESULT@@' + JSON.stringify({ result, pipRestored }));
`.trim();
const env = {
...process.env,
// Isolate test venv + dependencies from user/global CodexLens state.
CODEXLENS_DATA_DIR: dataDir,
// Make isUvAvailable() return true, but installFromProject() fail.
CCW_UV_PATH: process.execPath,
};
const { code, stdout, stderr } = await runNodeEvalModule(script, env);
assert.equal(code, 0, `bootstrapVenv child process failed:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`);
const marker = '@@RESULT@@';
const idx = stdout.lastIndexOf(marker);
assert.ok(idx !== -1, `Missing result marker in stdout:\n${stdout}`);
const jsonText = stdout.slice(idx + marker.length).trim();
const parsed = JSON.parse(jsonText);
assert.equal(parsed?.result?.success, true, `Expected success=true, got:\n${jsonText}`);
assert.equal(parsed?.pipRestored, true, `Expected pipRestored=true, got:\n${jsonText}`);
// Best-effort: confirm we exercised the missing-pip repair path.
assert.ok(
String(stderr).includes('pip not found at:') || String(stdout).includes('pip not found at:'),
`Expected missing-pip warning in output. STDERR:\n${stderr}\nSTDOUT:\n${stdout}`
);
} finally {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {
// Best effort cleanup; leave artifacts only if Windows locks prevent removal.
}
}
});
});

View File

@@ -1,372 +0,0 @@
/**
* Integration tests for CodexLens UV installation functionality.
*
* Notes:
* - Targets the runtime implementation shipped in `ccw/dist`.
* - Tests real package installation (fastembed, hnswlib, onnxruntime, ccw-litellm, codex-lens).
* - Verifies Python import success for installed packages.
* - Tests UV's dependency conflict auto-resolution capability.
* - Uses temporary directories with cleanup after tests.
*/
import { after, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const uvManagerUrl = new URL('../dist/utils/uv-manager.js', import.meta.url);
uvManagerUrl.searchParams.set('t', String(Date.now()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod: any;
// Test venv path with unique timestamp
const TEST_VENV_PATH = join(tmpdir(), `codexlens-install-test-${Date.now()}`);
// Track UV availability for conditional tests
let uvAvailable = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let manager: any;
describe('CodexLens UV Installation Tests', async () => {
mod = await import(uvManagerUrl.href);
before(async () => {
uvAvailable = await mod.isUvAvailable();
if (!uvAvailable) {
console.log('[Test] UV not available, attempting to install...');
uvAvailable = await mod.ensureUvInstalled();
}
if (uvAvailable) {
manager = new mod.UvManager({
venvPath: TEST_VENV_PATH,
pythonVersion: '>=3.10,<3.13', // onnxruntime compatibility range
});
console.log(`[Test] Created UvManager with venv path: ${TEST_VENV_PATH}`);
}
});
after(() => {
// Clean up test venv
if (existsSync(TEST_VENV_PATH)) {
console.log(`[Test] Cleaning up test venv: ${TEST_VENV_PATH}`);
try {
rmSync(TEST_VENV_PATH, { recursive: true, force: true });
} catch (err) {
console.log(`[Test] Failed to remove venv: ${(err as Error).message}`);
}
}
});
describe('Virtual Environment Setup', () => {
it('should create venv with correct Python version', async () => {
if (!uvAvailable) {
console.log('[Test] Skipping - UV not available');
return;
}
const result = await manager.createVenv();
console.log(`[Test] Create venv result:`, result);
assert.ok(result.success, `Venv creation failed: ${result.error}`);
// Verify Python version
const version = await manager.getPythonVersion();
console.log(`[Test] Python version: ${version}`);
const match = version?.match(/3\.(\d+)/);
assert.ok(match, 'Should be Python 3.x');
const minor = parseInt(match[1]);
assert.ok(minor >= 10 && minor < 13, `Python version should be 3.10-3.12, got 3.${minor}`);
});
});
describe('Semantic Search Dependencies (fastembed)', () => {
it('should install fastembed and hnswlib', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
console.log('[Test] Installing fastembed and hnswlib...');
const startTime = Date.now();
const result = await manager.install([
'numpy>=1.24',
'fastembed>=0.5',
'hnswlib>=0.8.0',
]);
const duration = Date.now() - startTime;
console.log(`[Test] Installation result:`, result);
console.log(`[Test] Installation took ${duration}ms`);
assert.ok(result.success, `fastembed installation failed: ${result.error}`);
});
it('should verify fastembed is importable', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython([
'-c',
'import fastembed; print(f"fastembed version: {fastembed.__version__}")',
]);
console.log(`[Test] fastembed import:`, result);
assert.ok(result.success, `fastembed import failed: ${result.stderr}`);
assert.ok(result.stdout.includes('fastembed version'), 'Should print fastembed version');
});
it('should verify hnswlib is importable', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython(['-c', 'import hnswlib; print("hnswlib imported successfully")']);
console.log(`[Test] hnswlib import:`, result);
assert.ok(result.success, `hnswlib import failed: ${result.stderr}`);
});
});
describe('ONNX Runtime Installation', () => {
it('should install onnxruntime (CPU)', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
console.log('[Test] Installing onnxruntime...');
const result = await manager.install(['onnxruntime>=1.18.0']);
console.log(`[Test] onnxruntime installation:`, result);
assert.ok(result.success, `onnxruntime installation failed: ${result.error}`);
});
it('should verify onnxruntime providers', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython([
'-c',
'import onnxruntime; print("Providers:", onnxruntime.get_available_providers())',
]);
console.log(`[Test] onnxruntime providers:`, result);
assert.ok(result.success, `onnxruntime import failed: ${result.stderr}`);
assert.ok(result.stdout.includes('CPUExecutionProvider'), 'Should have CPU provider');
});
});
describe('ccw-litellm Installation', () => {
it('should install ccw-litellm from local path', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
// Find local ccw-litellm package
const possiblePaths = [join(process.cwd(), 'ccw-litellm'), 'D:\\Claude_dms3\\ccw-litellm'];
let localPath: string | null = null;
for (const p of possiblePaths) {
if (existsSync(join(p, 'pyproject.toml'))) {
localPath = p;
break;
}
}
if (!localPath) {
console.log('[Test] ccw-litellm local path not found, installing from PyPI...');
const result = await manager.install(['ccw-litellm']);
console.log(`[Test] PyPI installation:`, result);
// PyPI may not have it published, skip
return;
}
console.log(`[Test] Installing ccw-litellm from: ${localPath}`);
const result = await manager.installFromProject(localPath);
console.log(`[Test] ccw-litellm installation:`, result);
assert.ok(result.success, `ccw-litellm installation failed: ${result.error}`);
});
it('should verify ccw-litellm is importable', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython([
'-c',
'import ccw_litellm; print(f"ccw-litellm version: {ccw_litellm.__version__}")',
]);
console.log(`[Test] ccw-litellm import:`, result);
// If installation failed (PyPI doesn't have it), skip validation
if (!result.success && result.stderr.includes('No module named')) {
console.log('[Test] ccw-litellm not installed, skipping import test');
return;
}
assert.ok(result.success, `ccw-litellm import failed: ${result.stderr}`);
});
});
describe('Full codex-lens Installation', () => {
it('should install codex-lens with semantic extras from local path', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
// Find local codex-lens package
const possiblePaths = [join(process.cwd(), 'codex-lens'), 'D:\\Claude_dms3\\codex-lens'];
let localPath: string | null = null;
for (const p of possiblePaths) {
if (existsSync(join(p, 'pyproject.toml'))) {
localPath = p;
break;
}
}
if (!localPath) {
console.log('[Test] codex-lens local path not found, skipping');
return;
}
console.log(`[Test] Installing codex-lens[semantic] from: ${localPath}`);
const startTime = Date.now();
const result = await manager.installFromProject(localPath, ['semantic']);
const duration = Date.now() - startTime;
console.log(`[Test] codex-lens installation:`, result);
console.log(`[Test] Installation took ${duration}ms`);
assert.ok(result.success, `codex-lens installation failed: ${result.error}`);
});
it('should verify codex-lens CLI is available', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython(['-m', 'codexlens', '--help']);
console.log(`[Test] codexlens CLI help output length: ${result.stdout.length}`);
// CLI may fail due to dependency issues, log but don't force failure
if (!result.success) {
console.log(`[Test] codexlens CLI failed: ${result.stderr}`);
}
});
});
describe('Dependency Conflict Resolution', () => {
it('should handle onnxruntime version conflicts automatically', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
// UV should auto-resolve conflicts between fastembed and onnxruntime
// Install onnxruntime first, then fastembed, verify no errors
console.log('[Test] Testing conflict resolution...');
// Check current onnxruntime version
const result = await manager.runPython(['-c', 'import onnxruntime; print(f"onnxruntime: {onnxruntime.__version__}")']);
console.log(`[Test] Current onnxruntime:`, result.stdout.trim());
// Reinstall fastembed, UV should handle dependencies
const installResult = await manager.install(['fastembed>=0.5']);
console.log(`[Test] Reinstall fastembed:`, installResult);
// Check onnxruntime again
const result2 = await manager.runPython(['-c', 'import onnxruntime; print(f"onnxruntime: {onnxruntime.__version__}")']);
console.log(`[Test] After reinstall onnxruntime:`, result2.stdout.trim());
assert.ok(result2.success, 'onnxruntime should still be importable after fastembed reinstall');
});
});
describe('Package List Verification', () => {
it('should list all installed packages', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const packages = await manager.list();
console.log(`[Test] Total installed packages: ${packages?.length ?? 0}`);
if (packages !== null) {
assert.ok(Array.isArray(packages), 'list() should return array');
// Check for expected packages
const packageNames = packages.map((p: { name: string }) => p.name.toLowerCase().replace(/-/g, '_'));
console.log(`[Test] Package names: ${packageNames.slice(0, 10).join(', ')}...`);
// Verify core packages are present
const hasNumpy = packageNames.includes('numpy');
const hasFastembed = packageNames.includes('fastembed');
const hasHnswlib = packageNames.includes('hnswlib');
console.log(`[Test] numpy: ${hasNumpy}, fastembed: ${hasFastembed}, hnswlib: ${hasHnswlib}`);
assert.ok(hasNumpy, 'numpy should be installed');
assert.ok(hasFastembed, 'fastembed should be installed');
assert.ok(hasHnswlib, 'hnswlib should be installed');
}
});
it('should check individual package installation status', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const numpyInstalled = await manager.isPackageInstalled('numpy');
const fastembedInstalled = await manager.isPackageInstalled('fastembed');
const nonexistentInstalled = await manager.isPackageInstalled('this-package-does-not-exist-12345');
console.log(`[Test] numpy installed: ${numpyInstalled}`);
console.log(`[Test] fastembed installed: ${fastembedInstalled}`);
console.log(`[Test] nonexistent installed: ${nonexistentInstalled}`);
assert.ok(numpyInstalled, 'numpy should be installed');
assert.ok(fastembedInstalled, 'fastembed should be installed');
assert.equal(nonexistentInstalled, false, 'nonexistent package should not be installed');
});
});
describe('CodexLens UV Manager Factory', () => {
it('should create CodexLens UV manager with default settings', () => {
const codexLensManager = mod.createCodexLensUvManager();
console.log(`[Test] CodexLens manager created`);
assert.ok(codexLensManager !== null, 'createCodexLensUvManager should return manager');
assert.ok(codexLensManager.getVenvPython, 'Manager should have getVenvPython method');
// Verify Python path is in default location
const pythonPath = codexLensManager.getVenvPython();
console.log(`[Test] Default CodexLens Python path: ${pythonPath}`);
assert.ok(pythonPath.includes('.codexlens'), 'Python path should be in .codexlens directory');
});
it('should create CodexLens UV manager with custom data dir', () => {
const customDir = join(tmpdir(), 'custom-codexlens-test');
const codexLensManager = mod.createCodexLensUvManager(customDir);
const pythonPath = codexLensManager.getVenvPython();
console.log(`[Test] Custom CodexLens manager Python path: ${pythonPath}`);
assert.ok(pythonPath.includes(customDir), 'Python path should use custom dir');
});
});
});

View File

@@ -1,66 +0,0 @@
import { after, afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { createRequire, syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('node:fs');
const originalExistsSync = fs.existsSync;
const originalCodexLensDataDir = process.env.CODEXLENS_DATA_DIR;
const tempDirs = [];
afterEach(() => {
fs.existsSync = originalExistsSync;
syncBuiltinESMExports();
if (originalCodexLensDataDir === undefined) {
delete process.env.CODEXLENS_DATA_DIR;
} else {
process.env.CODEXLENS_DATA_DIR = originalCodexLensDataDir;
}
});
after(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
describe('codexlens-path hidden python selection', () => {
it('prefers pythonw.exe for hidden Windows subprocesses when available', async () => {
if (process.platform !== 'win32') {
return;
}
const dataDir = mkdtempSync(join(tmpdir(), 'ccw-codexlens-hidden-python-'));
tempDirs.push(dataDir);
process.env.CODEXLENS_DATA_DIR = dataDir;
const expectedPythonw = join(dataDir, 'venv', 'Scripts', 'pythonw.exe');
fs.existsSync = (path) => String(path) === expectedPythonw;
syncBuiltinESMExports();
const moduleUrl = new URL(`../dist/utils/codexlens-path.js?t=${Date.now()}`, import.meta.url);
const mod = await import(moduleUrl.href);
assert.equal(mod.getCodexLensHiddenPython(), expectedPythonw);
});
it('falls back to python.exe when pythonw.exe is unavailable', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'ccw-codexlens-hidden-fallback-'));
tempDirs.push(dataDir);
process.env.CODEXLENS_DATA_DIR = dataDir;
fs.existsSync = () => false;
syncBuiltinESMExports();
const moduleUrl = new URL(`../dist/utils/codexlens-path.js?t=${Date.now()}`, import.meta.url);
const mod = await import(moduleUrl.href);
assert.equal(mod.getCodexLensHiddenPython(), mod.getCodexLensPython());
});
});

View File

@@ -1,121 +0,0 @@
import { afterEach, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
const uvManagerPath = new URL('../dist/utils/uv-manager.js', import.meta.url).href;
const pythonUtilsPath = new URL('../dist/utils/python-utils.js', import.meta.url).href;
describe('CodexLens UV python preference', async () => {
let mod;
let pythonUtils;
const originalPython = process.env.CCW_PYTHON;
before(async () => {
mod = await import(uvManagerPath);
pythonUtils = await import(pythonUtilsPath);
});
afterEach(() => {
if (originalPython === undefined) {
delete process.env.CCW_PYTHON;
return;
}
process.env.CCW_PYTHON = originalPython;
});
it('honors CCW_PYTHON override', () => {
process.env.CCW_PYTHON = 'C:/Custom/Python/python.exe';
assert.equal(mod.getPreferredCodexLensPythonSpec(), 'C:/Custom/Python/python.exe');
});
it('parses py launcher commands into spawn-safe command specs', () => {
const spec = pythonUtils.parsePythonCommandSpec('py -3.11');
assert.equal(spec.command, 'py');
assert.deepEqual(spec.args, ['-3.11']);
assert.equal(spec.display, 'py -3.11');
});
it('treats unquoted Windows-style executable paths as a single command', () => {
const spec = pythonUtils.parsePythonCommandSpec('C:/Program Files/Python311/python.exe');
assert.equal(spec.command, 'C:/Program Files/Python311/python.exe');
assert.deepEqual(spec.args, []);
assert.equal(spec.display, '"C:/Program Files/Python311/python.exe"');
});
it('probes Python launcher versions without opening a shell window', () => {
const probeCalls = [];
const version = pythonUtils.probePythonCommandVersion(
{ command: 'py', args: ['-3.11'], display: 'py -3.11' },
(command, args, options) => {
probeCalls.push({ command, args, options });
return { status: 0, stdout: '', stderr: 'Python 3.11.9\n' };
},
);
assert.equal(version, 'Python 3.11.9');
assert.equal(probeCalls.length, 1);
assert.equal(probeCalls[0].command, 'py');
assert.deepEqual(probeCalls[0].args, ['-3.11', '--version']);
assert.equal(probeCalls[0].options.shell, false);
assert.equal(probeCalls[0].options.windowsHide, true);
assert.equal(probeCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
});
it('looks up uv on PATH without spawning a visible shell window', () => {
const lookupCalls = [];
const found = mod.__testables.findExecutableOnPath('uv', (command, args, options) => {
lookupCalls.push({ command, args, options });
return { status: 0, stdout: 'C:/Tools/uv.exe\n', stderr: '' };
});
assert.equal(found, 'C:/Tools/uv.exe');
assert.equal(lookupCalls.length, 1);
assert.equal(lookupCalls[0].command, process.platform === 'win32' ? 'where' : 'which');
assert.deepEqual(lookupCalls[0].args, ['uv']);
assert.equal(lookupCalls[0].options.shell, false);
assert.equal(lookupCalls[0].options.windowsHide, true);
assert.equal(lookupCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
});
it('checks Windows launcher preferences with hidden subprocess options', () => {
const probeCalls = [];
const available = mod.__testables.hasWindowsPythonLauncherVersion('3.11', (command, args, options) => {
probeCalls.push({ command, args, options });
return { status: 0, stdout: '', stderr: 'Python 3.11.9\n' };
});
assert.equal(available, true);
assert.equal(probeCalls.length, 1);
assert.equal(probeCalls[0].command, 'py');
assert.deepEqual(probeCalls[0].args, ['-3.11', '--version']);
assert.equal(probeCalls[0].options.shell, false);
assert.equal(probeCalls[0].options.windowsHide, true);
assert.equal(probeCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
});
it('prefers Python 3.11 or 3.10 on Windows when available', () => {
if (process.platform !== 'win32') return;
delete process.env.CCW_PYTHON;
let installed = '';
try {
installed = execSync('py -0p', { encoding: 'utf-8' });
} catch {
return;
}
const has311 = installed.includes('-V:3.11');
const has310 = installed.includes('-V:3.10');
if (!has311 && !has310) {
return;
}
const preferred = mod.getPreferredCodexLensPythonSpec();
assert.ok(
preferred === '3.11' || preferred === '3.10',
`expected Windows preference to avoid 3.12 when 3.11/3.10 exists, got ${preferred}`,
);
});
});

View File

@@ -1,414 +0,0 @@
/**
* Unit tests for uv-manager utility module.
*
* Notes:
* - Targets the runtime implementation shipped in `ccw/dist`.
* - Tests UV binary detection, installation, and virtual environment management.
* - Gracefully handles cases where UV is not installed.
*/
import { after, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const uvManagerUrl = new URL('../dist/utils/uv-manager.js', import.meta.url);
uvManagerUrl.searchParams.set('t', String(Date.now()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod: any;
// Test venv path with unique timestamp
const TEST_VENV_PATH = join(tmpdir(), `uv-test-venv-${Date.now()}`);
// Track UV availability for conditional tests
let uvAvailable = false;
describe('UV Manager Tests', async () => {
mod = await import(uvManagerUrl.href);
// Cleanup after all tests
after(() => {
if (existsSync(TEST_VENV_PATH)) {
console.log(`[Cleanup] Removing test venv: ${TEST_VENV_PATH}`);
try {
rmSync(TEST_VENV_PATH, { recursive: true, force: true });
} catch (err) {
console.log(`[Cleanup] Failed to remove venv: ${(err as Error).message}`);
}
}
});
describe('UV Binary Detection', () => {
it('should check UV availability', async () => {
uvAvailable = await mod.isUvAvailable();
console.log(`[Test] UV available: ${uvAvailable}`);
assert.equal(typeof uvAvailable, 'boolean', 'isUvAvailable should return boolean');
});
it('should get UV version when available', async () => {
if (uvAvailable) {
const version = await mod.getUvVersion();
console.log(`[Test] UV version: ${version}`);
assert.ok(version !== null, 'getUvVersion should return version string');
assert.ok(version.length > 0, 'Version string should not be empty');
} else {
console.log('[Test] UV not installed, skipping version test');
const version = await mod.getUvVersion();
assert.equal(version, null, 'getUvVersion should return null when UV not available');
}
});
it('should get UV binary path', async () => {
const path = mod.getUvBinaryPath();
console.log(`[Test] UV path: ${path}`);
assert.equal(typeof path, 'string', 'getUvBinaryPath should return string');
assert.ok(path.length > 0, 'Path should not be empty');
if (uvAvailable) {
assert.ok(existsSync(path), 'UV binary should exist when UV is available');
}
});
});
describe('UV Installation', () => {
it('should ensure UV is installed', async () => {
const installed = await mod.ensureUvInstalled();
console.log(`[Test] UV ensured: ${installed}`);
assert.equal(typeof installed, 'boolean', 'ensureUvInstalled should return boolean');
// Update availability after potential installation
if (installed) {
uvAvailable = await mod.isUvAvailable();
assert.ok(uvAvailable, 'UV should be available after ensureUvInstalled returns true');
}
});
});
describe('UvManager Class', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let manager: any;
before(async () => {
// Ensure UV is available for class tests
if (!uvAvailable) {
console.log('[Test] UV not available, attempting installation...');
await mod.ensureUvInstalled();
uvAvailable = await mod.isUvAvailable();
}
manager = new mod.UvManager({
venvPath: TEST_VENV_PATH,
pythonVersion: '>=3.10',
});
console.log(`[Test] Created UvManager with venv path: ${TEST_VENV_PATH}`);
});
it('should get venv Python path', () => {
const pythonPath = manager.getVenvPython();
console.log(`[Test] Venv Python path: ${pythonPath}`);
assert.equal(typeof pythonPath, 'string', 'getVenvPython should return string');
assert.ok(pythonPath.includes(TEST_VENV_PATH), 'Python path should be inside venv');
});
it('should get venv pip path', () => {
const pipPath = manager.getVenvPip();
console.log(`[Test] Venv pip path: ${pipPath}`);
assert.equal(typeof pipPath, 'string', 'getVenvPip should return string');
assert.ok(pipPath.includes(TEST_VENV_PATH), 'Pip path should be inside venv');
});
it('should report venv as invalid before creation', () => {
const valid = manager.isVenvValid();
console.log(`[Test] Venv valid (before create): ${valid}`);
assert.equal(valid, false, 'Venv should not be valid before creation');
});
it('should create virtual environment', async () => {
if (!uvAvailable) {
console.log('[Test] Skipping venv creation - UV not available');
return;
}
const result = await manager.createVenv();
console.log(`[Test] Create venv result:`, result);
if (result.success) {
assert.ok(existsSync(TEST_VENV_PATH), 'Venv directory should exist');
assert.ok(result.duration !== undefined, 'Duration should be reported');
console.log(`[Test] Venv created in ${result.duration}ms`);
} else {
// May fail if Python is not installed
console.log(`[Test] Venv creation failed: ${result.error}`);
assert.equal(typeof result.error, 'string', 'Error should be a string');
}
});
it('should check if venv is valid after creation', () => {
const valid = manager.isVenvValid();
console.log(`[Test] Venv valid (after create): ${valid}`);
assert.equal(typeof valid, 'boolean', 'isVenvValid should return boolean');
});
it('should get Python version in venv', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping Python version check - venv not valid');
return;
}
const version = await manager.getPythonVersion();
console.log(`[Test] Python version: ${version}`);
assert.ok(version !== null, 'getPythonVersion should return version');
assert.ok(version.startsWith('3.'), 'Should be Python 3.x');
});
it('should list installed packages', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping package list - venv not valid');
return;
}
const packages = await manager.list();
console.log(`[Test] Installed packages count: ${packages?.length ?? 0}`);
if (packages !== null) {
assert.ok(Array.isArray(packages), 'list() should return array');
// UV creates minimal venvs without pip by default
console.log(`[Test] Packages in venv: ${packages.map((p: { name: string }) => p.name).join(', ') || '(empty)'}`);
}
});
it('should check if package is installed', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping package check - venv not valid');
return;
}
// First install a package, then check if it's installed
const installResult = await manager.install(['six']);
if (installResult.success) {
const installed = await manager.isPackageInstalled('six');
console.log(`[Test] six installed: ${installed}`);
assert.ok(installed, 'six should be installed after install');
// Clean up
await manager.uninstall(['six']);
} else {
console.log('[Test] Could not install test package, skipping check');
}
});
it('should install a simple package', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping package install - venv not valid');
return;
}
// Install a small, fast-installing package
const result = await manager.install(['pip-install-test']);
console.log(`[Test] Install result:`, result);
assert.equal(typeof result.success, 'boolean', 'success should be boolean');
if (result.success) {
console.log(`[Test] Package installed in ${result.duration}ms`);
} else {
console.log(`[Test] Package install failed: ${result.error}`);
}
});
it('should uninstall a package', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping uninstall - venv not valid');
return;
}
const result = await manager.uninstall(['pip-install-test']);
console.log(`[Test] Uninstall result:`, result);
assert.equal(typeof result.success, 'boolean', 'success should be boolean');
if (result.success) {
console.log(`[Test] Package uninstalled in ${result.duration}ms`);
} else {
console.log(`[Test] Package uninstall failed: ${result.error}`);
}
});
it('should handle empty package list for install', async () => {
const result = await manager.install([]);
console.log(`[Test] Empty install result:`, result);
assert.ok(result.success, 'Empty install should succeed');
assert.equal(result.duration, 0, 'Empty install should have 0 duration');
});
it('should handle empty package list for uninstall', async () => {
const result = await manager.uninstall([]);
console.log(`[Test] Empty uninstall result:`, result);
assert.ok(result.success, 'Empty uninstall should succeed');
assert.equal(result.duration, 0, 'Empty uninstall should have 0 duration');
});
it('should run Python command in venv', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping Python command - venv not valid');
return;
}
const result = await manager.runPython(['-c', 'print("hello from venv")']);
console.log(`[Test] Run Python result:`, result);
if (result.success) {
assert.ok(result.stdout.includes('hello from venv'), 'Output should contain expected text');
} else {
console.log(`[Test] Python command failed: ${result.stderr}`);
}
});
it('should delete virtual environment', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping delete - venv not valid');
return;
}
const result = await manager.deleteVenv();
console.log(`[Test] Delete venv result: ${result}`);
if (result) {
assert.ok(!existsSync(TEST_VENV_PATH), 'Venv directory should be deleted');
}
});
it('should handle deleteVenv when venv does not exist', async () => {
const result = await manager.deleteVenv();
console.log(`[Test] Delete non-existent venv result: ${result}`);
assert.ok(result, 'Deleting non-existent venv should succeed');
});
});
describe('Helper Functions', () => {
it('should create CodexLens UV manager with defaults', () => {
const codexLensManager = mod.createCodexLensUvManager();
console.log(`[Test] CodexLens manager created`);
assert.ok(codexLensManager !== null, 'createCodexLensUvManager should return manager');
assert.ok(codexLensManager.getVenvPython, 'Manager should have getVenvPython method');
});
it('should create CodexLens UV manager with custom data dir', () => {
const customDir = join(tmpdir(), 'custom-codexlens');
const codexLensManager = mod.createCodexLensUvManager(customDir);
const pythonPath = codexLensManager.getVenvPython();
console.log(`[Test] Custom CodexLens manager Python path: ${pythonPath}`);
assert.ok(pythonPath.includes(customDir), 'Python path should use custom dir');
});
it('should bootstrap UV venv', async () => {
if (!uvAvailable) {
console.log('[Test] Skipping bootstrap - UV not available');
return;
}
const bootstrapPath = join(tmpdir(), `uv-bootstrap-test-${Date.now()}`);
console.log(`[Test] Bootstrap venv path: ${bootstrapPath}`);
try {
const result = await mod.bootstrapUvVenv(bootstrapPath, '>=3.10');
console.log(`[Test] Bootstrap result:`, result);
assert.equal(typeof result.success, 'boolean', 'success should be boolean');
if (result.success) {
assert.ok(existsSync(bootstrapPath), 'Bootstrap venv should exist');
}
} finally {
// Cleanup bootstrap venv
if (existsSync(bootstrapPath)) {
rmSync(bootstrapPath, { recursive: true, force: true });
}
}
});
});
describe('Error Handling', () => {
it('should handle install when UV not available gracefully', async () => {
// Create manager pointing to non-existent venv
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.install(['some-package']);
console.log(`[Test] Install with invalid venv:`, result);
assert.equal(result.success, false, 'Install should fail with invalid venv');
assert.ok(result.error, 'Error message should be present');
});
it('should handle uninstall when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.uninstall(['some-package']);
console.log(`[Test] Uninstall with invalid venv:`, result);
assert.equal(result.success, false, 'Uninstall should fail with invalid venv');
assert.ok(result.error, 'Error message should be present');
});
it('should handle list when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const packages = await badManager.list();
console.log(`[Test] List with invalid venv: ${packages}`);
assert.equal(packages, null, 'list() should return null for invalid venv');
});
it('should handle isPackageInstalled when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const installed = await badManager.isPackageInstalled('pip');
console.log(`[Test] isPackageInstalled with invalid venv: ${installed}`);
assert.equal(installed, false, 'isPackageInstalled should return false for invalid venv');
});
it('should handle runPython when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.runPython(['--version']);
console.log(`[Test] runPython with invalid venv:`, result);
assert.equal(result.success, false, 'runPython should fail for invalid venv');
assert.ok(result.stderr.length > 0, 'Error message should be present');
});
it('should handle sync when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.sync('requirements.txt');
console.log(`[Test] sync with invalid venv:`, result);
assert.equal(result.success, false, 'sync should fail for invalid venv');
assert.ok(result.error, 'Error message should be present');
});
it('should handle installFromProject when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.installFromProject('/some/project');
console.log(`[Test] installFromProject with invalid venv:`, result);
assert.equal(result.success, false, 'installFromProject should fail for invalid venv');
assert.ok(result.error, 'Error message should be present');
});
});
});