Add tests and implement functionality for staged cascade search and LSP expansion

- Introduced a new JSON file for verbose output of the Codex Lens search results.
- Added unit tests for binary search functionality in `test_stage1_binary_search_uses_chunk_lines.py`.
- Implemented regression tests for staged cascade Stage 2 expansion depth in `test_staged_cascade_lsp_depth.py`.
- Created unit tests for staged cascade Stage 2 realtime LSP graph expansion in `test_staged_cascade_realtime_lsp.py`.
- Enhanced the ChainSearchEngine to respect configuration settings for staged LSP depth and improve search accuracy.
This commit is contained in:
catlog22
2026-02-08 21:54:42 +08:00
parent 166211dcd4
commit b9b2932f50
20 changed files with 1882 additions and 283 deletions

View File

@@ -1,12 +1,13 @@
// ========================================
// Workspace Selector Component
// ========================================
// Dropdown for selecting recent workspaces with folder browser and manual path input
// Dropdown for selecting recent workspaces with native folder picker and manual path input
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback } from 'react';
import { ChevronDown, X, FolderOpen, Check } from 'lucide-react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { selectFolder } from '@/lib/nativeDialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
@@ -69,7 +70,7 @@ function truncatePath(path: string, maxChars: number = 40): string {
* Workspace selector component
*
* Provides a dropdown menu for selecting from recent workspace paths,
* a manual path input dialog for entering custom paths, and delete buttons
* a native OS folder picker, a manual path input dialog, and delete buttons
* for removing paths from recent history.
*
* @example
@@ -86,15 +87,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
// UI state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isBrowseOpen, setIsBrowseOpen] = useState(false);
const [isManualOpen, setIsManualOpen] = useState(false);
const [manualPath, setManualPath] = useState('');
// Hidden file input for folder selection
const folderInputRef = useRef<HTMLInputElement>(null);
/**
* Handle path selection from dropdown
*/
const handleSelectPath = useCallback(
async (path: string) => {
await switchWorkspace(path);
@@ -103,77 +98,30 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
[switchWorkspace]
);
/**
* Handle remove path from recent history
*/
const handleRemovePath = useCallback(
async (e: React.MouseEvent, path: string) => {
e.stopPropagation(); // Prevent triggering selection
e.stopPropagation();
await removeRecentPath(path);
},
[removeRecentPath]
);
/**
* Handle open folder browser - trigger hidden file input click
*/
const handleBrowseFolder = useCallback(() => {
const handleBrowseFolder = useCallback(async () => {
setIsDropdownOpen(false);
// Trigger the hidden file input click
folderInputRef.current?.click();
}, []);
const selected = await selectFolder(projectPath || undefined);
if (selected) {
await switchWorkspace(selected);
}
}, [projectPath, switchWorkspace]);
/**
* Handle folder selection from file input
*/
const handleFolderSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
// Get the path from the first file
const firstFile = files[0];
// The webkitRelativePath contains the full path relative to the selected folder
// We need to get the parent directory path
const relativePath = firstFile.webkitRelativePath;
const folderPath = relativePath.substring(0, relativePath.indexOf('/'));
// In browser environment, we can't get the full absolute path
// We need to ask the user to confirm or use the folder name
// For now, open the manual dialog with the folder name as hint
setManualPath(folderPath);
setIsBrowseOpen(true);
}
// Reset input value to allow selecting the same folder again
e.target.value = '';
},
[]
);
/**
* Handle manual path submission
*/
const handleManualPathSubmit = useCallback(async () => {
const trimmedPath = manualPath.trim();
if (!trimmedPath) {
return; // TODO: Show validation error
}
if (!trimmedPath) return;
await switchWorkspace(trimmedPath);
setIsBrowseOpen(false);
setIsManualOpen(false);
setManualPath('');
}, [manualPath, switchWorkspace]);
/**
* Handle dialog cancel
*/
const handleDialogCancel = useCallback(() => {
setIsBrowseOpen(false);
setManualPath('');
}, []);
/**
* Handle keyboard events in dialog input
*/
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
@@ -259,7 +207,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
{recentPaths.length > 0 && <DropdownMenuSeparator />}
{/* Browse button to open folder selector */}
{/* Browse button to open native folder selector */}
<DropdownMenuItem
onClick={handleBrowseFolder}
className="cursor-pointer gap-2"
@@ -279,7 +227,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
<DropdownMenuItem
onClick={() => {
setIsDropdownOpen(false);
setIsBrowseOpen(true);
setIsManualOpen(true);
}}
className="cursor-pointer gap-2"
>
@@ -290,20 +238,8 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Hidden file input for folder selection */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<input
ref={folderInputRef}
type="file"
{...({ webkitdirectory: '', directory: '' } as any)}
style={{ display: 'none' }}
onChange={handleFolderSelect}
aria-hidden="true"
tabIndex={-1}
/>
{/* Manual path input dialog */}
<Dialog open={isBrowseOpen} onOpenChange={setIsBrowseOpen}>
<Dialog open={isManualOpen} onOpenChange={setIsManualOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -324,7 +260,10 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
<DialogFooter>
<Button
variant="outline"
onClick={handleDialogCancel}
onClick={() => {
setIsManualOpen(false);
setManualPath('');
}}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>

View File

@@ -0,0 +1,36 @@
/**
* Native OS dialog helpers
* Calls server-side endpoints that open system-native file/folder picker dialogs.
*/
export async function selectFolder(initialDir?: string): Promise<string | null> {
try {
const res = await fetch('/api/dialog/select-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initialDir }),
});
if (!res.ok) return null;
const data = await res.json();
if (data.cancelled) return null;
return data.path || null;
} catch {
return null;
}
}
export async function selectFile(initialDir?: string): Promise<string | null> {
try {
const res = await fetch('/api/dialog/select-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initialDir }),
});
if (!res.ok) return null;
const data = await res.json();
if (data.cancelled) return null;
return data.path || null;
} catch {
return null;
}
}

View File

@@ -59,7 +59,7 @@ export async function searchUnsplash(
export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> {
const headers: Record<string, string> = {
'Content-Type': file.type,
'X-Filename': file.name,
'X-Filename': encodeURIComponent(file.name),
};
const csrfToken = getCsrfToken();
if (csrfToken) {

View File

@@ -12,7 +12,12 @@
"dialog": {
"title": "Select Project Folder",
"placeholder": "Enter project path...",
"help": "The path to your project directory"
"help": "The path to your project directory",
"selectCurrent": "Select This Folder",
"parentDir": "Parent Directory",
"loading": "Loading...",
"emptyDir": "Empty directory",
"accessDenied": "Cannot access this directory"
}
},
"actions": {

View File

@@ -12,7 +12,12 @@
"dialog": {
"title": "选择项目文件夹",
"placeholder": "输入项目路径...",
"help": "您的项目目录路径"
"help": "您的项目目录路径",
"selectCurrent": "选择此文件夹",
"parentDir": "上级目录",
"loading": "加载中...",
"emptyDir": "空目录",
"accessDenied": "无法访问此目录"
}
},
"actions": {

View File

@@ -35,13 +35,6 @@ 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/Dialog';
import { ThemeSelector } from '@/components/shared/ThemeSelector';
import { useTheme } from '@/hooks';
import { toast } from 'sonner';
@@ -63,146 +56,43 @@ import {
useUpgradeCcwInstallation,
} from '@/hooks/useSystemSettings';
// ========== File Path Input with Browse Dialog ==========
interface BrowseItem {
name: string;
path: string;
isDirectory: boolean;
isFile: boolean;
}
// ========== File Path Input with Native File Picker ==========
interface FilePathInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
showHidden?: boolean;
}
function FilePathInput({ value, onChange, placeholder, showHidden = true }: FilePathInputProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [browseItems, setBrowseItems] = useState<BrowseItem[]>([]);
const [currentBrowsePath, setCurrentBrowsePath] = useState('');
const [parentPath, setParentPath] = useState('');
const [loading, setLoading] = useState(false);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
try {
const res = await fetch('/api/dialog/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: dirPath || '~', showHidden }),
});
if (!res.ok) return;
const data = await res.json();
setBrowseItems(data.items || []);
setCurrentBrowsePath(data.currentPath || '');
setParentPath(data.parentPath || '');
} catch {
// silently fail
} finally {
setLoading(false);
function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
const handleBrowse = async () => {
const { selectFile } = await import('@/lib/nativeDialog');
const initialDir = value ? value.replace(/[/\\][^/\\]*$/, '') : undefined;
const selected = await selectFile(initialDir);
if (selected) {
onChange(selected);
}
};
const handleOpen = () => {
setDialogOpen(true);
// If value is set, browse its parent directory; otherwise browse home
const startPath = value ? value.replace(/[/\\][^/\\]*$/, '') : undefined;
browseDirectory(startPath);
};
const handleSelectFile = (filePath: string) => {
onChange(filePath);
setDialogOpen(false);
};
return (
<>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 h-9"
onClick={handleOpen}
title="Browse"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5" />
Browse Files
</DialogTitle>
<DialogDescription className="font-mono text-xs truncate" title={currentBrowsePath}>
{currentBrowsePath}
</DialogDescription>
</DialogHeader>
<div className="border border-border rounded-lg overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-y-auto max-h-[350px]">
{/* Parent directory */}
{parentPath && parentPath !== currentBrowsePath && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/50 transition-colors text-left border-b border-border"
onClick={() => browseDirectory(parentPath)}
>
<Folder className="w-4 h-4 text-primary shrink-0" />
<span className="text-muted-foreground font-medium">..</span>
</button>
)}
{browseItems.map((item) => (
<button
key={item.path}
type="button"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/50 transition-colors text-left"
onClick={() => {
if (item.isDirectory) {
browseDirectory(item.path);
} else {
handleSelectFile(item.path);
}
}}
>
{item.isDirectory ? (
<Folder className="w-4 h-4 text-primary shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<span className={cn('truncate', item.isFile && 'text-foreground font-medium')}>
{item.name}
</span>
</button>
))}
{browseItems.length === 0 && (
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
Empty directory
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 h-9"
onClick={handleBrowse}
title="Browse"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
);
}