feat: 添加项目初始化选择功能,支持多项目选择和URL状态同步

This commit is contained in:
xiongxiao
2026-03-19 05:02:24 +08:00
committed by cnb
parent 2a26a3943f
commit a65e7b236d
4 changed files with 512 additions and 345 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon, DownloadIcon, ListTodoIcon, CheckSquareIcon } from 'lucide-react'; import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon, PlayCircleIcon, StopCircleIcon, FolderIcon, AlertCircleIcon, CircleOffIcon, DownloadIcon, ListTodoIcon, CheckSquareIcon } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -7,8 +7,189 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent } from '@/components/ui/card';
import { useCodeGraphStore } from '../store'; import { useCodeGraphStore } from '../store';
// 初始化项目弹窗组件
function ProjectInitDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { initProject, fetchProjectFiles } = useCodeGraphStore(
useShallow((s) => ({
initProject: s.initProject,
fetchProjectFiles: s.fetchProjectFiles,
})),
);
const [loading, setLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [rootPath, setRootPath] = useState('/workspace/projects');
// 加载文件列表
const loadFiles = async () => {
const data = await fetchProjectFiles(rootPath);
setFiles(data);
};
// 打开时加载数据
useEffect(() => {
if (open) {
loadFiles();
}
}, [open]);
// 切换选中状态
const toggleSelection = (path: string) => {
setSelectedPaths((prev) =>
prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path]
);
};
// 全选
const selectAll = () => setSelectedPaths([...files]);
// 取消全选
const deselectAll = () => setSelectedPaths([]);
// 确认初始化
const handleConfirm = async () => {
setLoading(true);
await initProject(selectedPaths.length > 0 ? selectedPaths : undefined);
setLoading(false);
setSelectedPaths([]);
onOpenChange(false);
};
// 取消
const handleCancel = () => {
setSelectedPaths([]);
onOpenChange(false);
};
const allSelected = files.length > 0 && selectedPaths.length === files.length;
const isIndeterminate = selectedPaths.length > 0 && selectedPaths.length < files.length;
return (
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className='sm:max-w-2xl max-h-[80vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
<DialogHeader className='text-center'>
<div className='flex justify-center mb-3'>
<div className='w-12 h-12 rounded-full flex items-center justify-center bg-indigo-500/20'>
<DownloadIcon className='w-6 h-6 text-indigo-400' />
</div>
</div>
<DialogTitle className='text-lg font-semibold'>
</DialogTitle>
<DialogDescription className='text-slate-400'>
</DialogDescription>
</DialogHeader>
{/* 路径输入 */}
<div className='px-6'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
<Input
value={rootPath}
onChange={(e) => setRootPath(e.target.value)}
placeholder='项目根目录'
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
</div>
<Button
size='sm'
onClick={loadFiles}
className='bg-slate-700 hover:bg-slate-600 shrink-0'>
</Button>
</div>
</div>
{/* 项目列表 */}
<div className='flex-1 overflow-hidden px-6 py-2'>
<Card className='bg-slate-800/50 border-white/10 h-full'>
<CardContent className='p-3 h-[280px] overflow-y-auto scrollbar'>
{files.length === 0 ? (
<div className='flex flex-col items-center justify-center h-full text-slate-500 text-sm gap-2'>
<FolderIcon className='w-8 h-8 text-slate-600' />
<span></span>
</div>
) : (
<div className='space-y-1'>
{files.map((path) => (
<div
key={path}
onClick={() => toggleSelection(path)}
className={`flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer transition-all ${selectedPaths.includes(path)
? 'bg-indigo-500/20 border border-indigo-500/50'
: 'hover:bg-white/5 border border-transparent'
}`}>
<Checkbox
checked={selectedPaths.includes(path)}
onCheckedChange={() => toggleSelection(path)}
className='border-slate-500 data-[checked]:bg-indigo-500 data-[checked]:border-indigo-500'
/>
<FolderIcon className='w-4 h-4 text-slate-400 shrink-0' />
<span className='text-sm text-slate-200 truncate'>{path}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* 操作按钮 */}
<div className='flex items-center justify-between px-6 pb-4'>
<div className='flex items-center gap-3'>
<Button
size='sm'
variant='outline'
onClick={isIndeterminate ? selectAll : allSelected ? deselectAll : selectAll}
disabled={files.length === 0}
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30'>
{allSelected ? '取消全选' : '全选'}
</Button>
<span className='text-xs text-slate-400'>
{selectedPaths.length}/{files.length}
</span>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={handleCancel}
disabled={loading}
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30'>
</Button>
<Button
onClick={handleConfirm}
disabled={loading}
className='bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
{loading ? (
<span className='flex items-center gap-2'>
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</span>
) : (
'确认初始化'
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function ProjectDialog() { export function ProjectDialog() {
const { const {
projectDialogOpen, projectDialogOpen,
@@ -19,7 +200,6 @@ export function ProjectDialog() {
addProject, addProject,
removeProject, removeProject,
toggleProjectStatus, toggleProjectStatus,
initProject,
} = useCodeGraphStore( } = useCodeGraphStore(
useShallow((s) => ({ useShallow((s) => ({
projectDialogOpen: s.projectDialogOpen, projectDialogOpen: s.projectDialogOpen,
@@ -30,7 +210,6 @@ export function ProjectDialog() {
addProject: s.addProject, addProject: s.addProject,
removeProject: s.removeProject, removeProject: s.removeProject,
toggleProjectStatus: s.toggleProjectStatus, toggleProjectStatus: s.toggleProjectStatus,
initProject: s.initProject,
})), })),
); );
@@ -53,9 +232,8 @@ export function ProjectDialog() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteProject, setPendingDeleteProject] = useState<{ path: string; name?: string } | null>(null); const [pendingDeleteProject, setPendingDeleteProject] = useState<{ path: string; name?: string } | null>(null);
// 初始化确认弹窗 // 初始化弹窗
const [initConfirmOpen, setInitConfirmOpen] = useState(false); const [initConfirmOpen, setInitConfirmOpen] = useState(false);
const [initLoading, setInitLoading] = useState(false);
// 多选模式 // 多选模式
const [multiSelectMode, setMultiSelectMode] = useState(false); const [multiSelectMode, setMultiSelectMode] = useState(false);
@@ -175,19 +353,6 @@ export function ProjectDialog() {
setPendingDeleteProject(null); setPendingDeleteProject(null);
}; };
// 确认初始化
const handleConfirmInit = async () => {
setInitLoading(true);
await initProject();
setInitLoading(false);
setInitConfirmOpen(false);
};
// 取消初始化
const handleCancelInit = () => {
setInitConfirmOpen(false);
};
return ( return (
<Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}> <Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}>
<DialogContent className='w-[calc(100%-2rem)] sm:max-w-lg max-h-[85vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl overflow-hidden'> <DialogContent className='w-[calc(100%-2rem)] sm:max-w-lg max-h-[85vh] flex flex-col bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl overflow-hidden'>
@@ -211,293 +376,290 @@ export function ProjectDialog() {
{/* 内容区域 - 可滚动 */} {/* 内容区域 - 可滚动 */}
<div className='flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent -mx-2 px-2'> <div className='flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent -mx-2 px-2'>
{/* 新增项目 */} {/* 新增项目 */}
{showAddProject && ( {showAddProject && (
<div className='relative space-y-3 rounded-xl bg-white/5 p-4 border border-white/10 shadow-lg backdrop-blur-sm'> <div className='relative space-y-3 rounded-xl bg-white/5 p-4 border border-white/10 shadow-lg backdrop-blur-sm'>
<div className='flex items-center gap-2 mb-1'> <div className='flex items-center gap-2 mb-1'>
<div className='w-1 h-3 rounded-full bg-gradient-to-b from-indigo-400 to-purple-500' /> <div className='w-1 h-3 rounded-full bg-gradient-to-b from-indigo-400 to-purple-500' />
<p className='text-sm font-medium text-slate-200'></p> <p className='text-sm font-medium text-slate-200'></p>
</div>
<div className='space-y-2'>
{/* 类型选择 */}
<div className='flex gap-2 mb-2'>
<button
type='button'
onClick={() => {
setProjectType('filepath');
if (!newPath.trim()) {
setNewPath('/workspace/projects');
}
}}
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${
projectType === 'filepath'
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
}`}>
</button>
<button
type='button'
onClick={() => {
setProjectType('cnb-repo');
setNewPath('');
}}
className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${
projectType === 'cnb-repo'
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
}`}>
CNB
</button>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400 flex items-center gap-1'>
<span>{projectType === 'filepath' ? '项目路径' : 'CNB 仓库'}</span>
<span className='text-red-400'>*</span>
</Label>
<div className='relative'>
<FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
<Input
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
placeholder={projectType === 'filepath' ? '/workspace/projects' : 'kevisual/cnb'}
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
/>
</div> </div>
</div> <div className='space-y-2'>
<div className='space-y-1.5'> {/* 类型选择 */}
<Label className='text-xs text-slate-400'></Label> <div className='flex gap-2 mb-2'>
<Input <button
value={newName} type='button'
onChange={(e) => setNewName(e.target.value)} onClick={() => {
placeholder='My Project' setProjectType('filepath');
className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all' if (!newPath.trim()) {
/> setNewPath('/workspace/projects');
</div> }
</div> }}
<Button className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${projectType === 'filepath'
size='sm' ? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
onClick={handleAdd} : 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
disabled={addLoading || !newPath.trim()} }`}>
className='w-full bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white text-sm h-9 mt-2 shadow-lg shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:shadow-indigo-500/40'>
<PlusIcon className='w-4 h-4 mr-1.5' /> </button>
{addLoading ? ( <button
<span className='flex items-center gap-2'> type='button'
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' /> onClick={() => {
setProjectType('cnb-repo');
</span> setNewPath('');
) : ( }}
'添加项目' className={`flex-1 text-xs py-1.5 px-3 rounded-lg border transition-all ${projectType === 'cnb-repo'
)} ? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
</Button> : 'bg-transparent border-white/10 text-slate-400 hover:border-white/20'
</div> }`}>
)} CNB
</button>
{/* 项目列表 */} </div>
<div className='relative'> <div className='space-y-1.5'>
<div className='flex items-center justify-between mb-3'> <Label className='text-xs text-slate-400 flex items-center gap-1'>
<div className='flex items-center gap-2'> <span>{projectType === 'filepath' ? '项目路径' : 'CNB 仓库'}</span>
<div className='w-1 h-3 rounded-full bg-gradient-to-b from-green-400 to-emerald-500' /> <span className='text-red-400'>*</span>
<p className='text-sm font-medium text-slate-200'></p> </Label>
<span className='text-xs text-slate-500 bg-slate-800/80 px-1.5 py-0.5 rounded-full'> <div className='relative'>
{projects.length} <FolderIcon className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500' />
</span> <Input
</div> value={newPath}
<div className='flex items-center gap-1'> onChange={(e) => setNewPath(e.target.value)}
<button placeholder={projectType === 'filepath' ? '/workspace/projects' : 'kevisual/cnb'}
onClick={() => setShowAddProject(!showAddProject)} className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm pl-10 pr-4 focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
className={`text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 ${showAddProject ? 'text-indigo-400 bg-white/5' : ''}`} />
title={showAddProject ? '隐藏添加' : '添加项目'}>
<PlusIcon className={`w-4 h-4 ${showAddProject ? 'rotate-90' : ''} transition-transform`} />
</button>
<button
onClick={loadProjects}
disabled={projectsLoading}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='刷新'>
<RefreshCwIcon className={`w-4 h-4 ${projectsLoading ? 'animate-spin' : ''}`} />
</button>
<Tooltip>
<TooltipTrigger>
<div
onClick={() => setInitConfirmOpen(true)}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'>
<DownloadIcon className={`w-4 h-4 ${initLoading ? 'animate-spin' : ''}`} />
</div> </div>
</TooltipTrigger> </div>
<TooltipContent> <div className='space-y-1.5'>
<p> workspace/projects </p> <Label className='text-xs text-slate-400'></Label>
</TooltipContent> <Input
</Tooltip> value={newName}
{multiSelectMode ? ( onChange={(e) => setNewName(e.target.value)}
<> placeholder='My Project'
<div className='w-px h-5 bg-white/20 mx-1' /> className='bg-slate-800/80 border-white/10 text-slate-100 placeholder:text-slate-500 h-9 text-sm focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all'
<Tooltip> />
<TooltipTrigger> </div>
<div </div>
onClick={selectAll} <Button
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5' size='sm'
title='全选'> onClick={handleAdd}
<CheckSquareIcon className='w-4 h-4' /> disabled={addLoading || !newPath.trim()}
</div> className='w-full bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white text-sm h-9 mt-2 shadow-lg shadow-indigo-500/25 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:shadow-indigo-500/40'>
</TooltipTrigger> <PlusIcon className='w-4 h-4 mr-1.5' />
<TooltipContent> {addLoading ? (
<p></p> <span className='flex items-center gap-2'>
</TooltipContent> <span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</Tooltip>
<Tooltip> </span>
<TooltipTrigger> ) : (
<div '添加项目'
onClick={deselectAll} )}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5' </Button>
title='取消全选'> </div>
<ListTodoIcon className='w-4 h-4' /> )}
</div>
</TooltipTrigger> {/* 项目列表 */}
<TooltipContent> <div className='relative'>
<p></p> <div className='flex items-center justify-between mb-3'>
</TooltipContent> <div className='flex items-center gap-2'>
</Tooltip> <div className='w-1 h-3 rounded-full bg-gradient-to-b from-green-400 to-emerald-500' />
<Tooltip> <p className='text-sm font-medium text-slate-200'></p>
<TooltipTrigger> <span className='text-xs text-slate-500 bg-slate-800/80 px-1.5 py-0.5 rounded-full'>
<button {projects.length}
onClick={handleStartAll} </span>
disabled={selectedProjects.length === 0} </div>
className='text-slate-500 hover:text-green-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50' <div className='flex items-center gap-1'>
title='全部启动'> <button
<PlayCircleIcon className='w-4 h-4' /> onClick={() => setShowAddProject(!showAddProject)}
</button> className={`text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 ${showAddProject ? 'text-indigo-400 bg-white/5' : ''}`}
</TooltipTrigger> title={showAddProject ? '隐藏添加' : '添加项目'}>
<TooltipContent> <PlusIcon className={`w-4 h-4 ${showAddProject ? 'rotate-90' : ''} transition-transform`} />
<p> ({selectedProjects.length})</p> </button>
</TooltipContent> <button
</Tooltip> onClick={loadProjects}
<Tooltip> disabled={projectsLoading}
<TooltipTrigger> className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
<button title='刷新'>
onClick={handleStopAll} <RefreshCwIcon className={`w-4 h-4 ${projectsLoading ? 'animate-spin' : ''}`} />
disabled={selectedProjects.length === 0} </button>
className='text-slate-500 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50' <Tooltip>
title='全部关闭'> <TooltipTrigger>
<StopCircleIcon className='w-4 h-4' /> <div
</button> onClick={() => setInitConfirmOpen(true)}
</TooltipTrigger> className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'>
<TooltipContent> <DownloadIcon className='w-4 h-4' />
<p> ({selectedProjects.length})</p> </div>
</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>
<p> workspace/projects </p>
</TooltipContent>
</Tooltip>
{multiSelectMode ? (
<>
<div className='w-px h-5 bg-white/20 mx-1' />
<Tooltip>
<TooltipTrigger>
<div
onClick={selectAll}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='全选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div
onClick={deselectAll}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='取消全选'>
<ListTodoIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<button
onClick={handleStartAll}
disabled={selectedProjects.length === 0}
className='text-slate-500 hover:text-green-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'
title='全部启动'>
<PlayCircleIcon className='w-4 h-4' />
</button>
</TooltipTrigger>
<TooltipContent>
<p> ({selectedProjects.length})</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<button
onClick={handleStopAll}
disabled={selectedProjects.length === 0}
className='text-slate-500 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-white/5 disabled:opacity-50'
title='全部关闭'>
<StopCircleIcon className='w-4 h-4' />
</button>
</TooltipTrigger>
<TooltipContent>
<p> ({selectedProjects.length})</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div
onClick={toggleMultiSelectMode}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='退出多选'>
<CheckSquareIcon className='w-4 h-4' />
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
</>
) : (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div <div
onClick={toggleMultiSelectMode} onClick={toggleMultiSelectMode}
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5' className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5'
title='退出多选'> title='多选'>
<CheckSquareIcon className='w-4 h-4' /> <CheckSquareIcon className='w-4 h-4' />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>退</p> <p></p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</> )}
) : ( </div>
<Tooltip> </div>
<TooltipTrigger>
<div {projectsLoading ? (
onClick={toggleMultiSelectMode} <div className='flex items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5'>
className='text-slate-500 hover:text-indigo-400 transition-colors p-1.5 rounded-lg hover:bg-white/5' <span className='flex items-center gap-2'>
title='多选'> <span className='w-4 h-4 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin' />
<CheckSquareIcon className='w-4 h-4' />
</span>
</div>
) : projects.length === 0 ? (
<div className='flex flex-col items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5 gap-2'>
<FolderIcon className='w-8 h-8 text-slate-600' />
<span></span>
</div>
) : (
<ul className='space-y-2 max-h-72 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent scrollbar'>
{projects.map((p) => (
<li
key={p.path}
className={`group flex items-center gap-3 rounded-xl bg-white/5 px-4 py-3 border border-white/5 hover:bg-white/10 hover:border-white/10 transition-all duration-200 ${multiSelectMode && selectedProjects.includes(p.path) ? 'border-indigo-500/50 bg-indigo-500/10' : ''}`}>
{/* 多选框 */}
{multiSelectMode && (
<button
onClick={() => toggleProjectSelection(p.path)}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${selectedProjects.includes(p.path)
? 'bg-indigo-500 border-indigo-500 text-white'
: 'border-slate-500 hover:border-indigo-400'
}`}>
{selectedProjects.includes(p.path) && <CheckSquareIcon className='w-3 h-3' />}
</button>
)}
{/* 项目图标 */}
<div className={`shrink-0 p-2 rounded-lg ${p.status === 'active' ? 'bg-green-500/20' : p.status === 'unlive' ? 'bg-orange-500/20' : 'bg-slate-700/50'}`}>
<FolderIcon className={`w-4 h-4 ${p.status === 'active' ? 'text-green-400' : p.status === 'unlive' ? 'text-orange-400' : 'text-slate-400'}`} />
</div> </div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
{projectsLoading ? ( <div className='flex-1 min-w-0'>
<div className='flex items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5'> <p className='text-sm font-medium text-slate-200 truncate'>{p.name ?? p.path.split('/').pop()}</p>
<span className='flex items-center gap-2'> <p className='text-xs text-slate-500 truncate'>{p.path}</p>
<span className='w-4 h-4 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin' /> </div>
</span>
</div>
) : projects.length === 0 ? (
<div className='flex flex-col items-center justify-center h-24 text-slate-500 text-sm bg-white/5 rounded-xl border border-white/5 gap-2'>
<FolderIcon className='w-8 h-8 text-slate-600' />
<span></span>
</div>
) : (
<ul className='space-y-2 max-h-72 overflow-y-auto pr-1 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent scrollbar'>
{projects.map((p) => (
<li
key={p.path}
className={`group flex items-center gap-3 rounded-xl bg-white/5 px-4 py-3 border border-white/5 hover:bg-white/10 hover:border-white/10 transition-all duration-200 ${multiSelectMode && selectedProjects.includes(p.path) ? 'border-indigo-500/50 bg-indigo-500/10' : ''}`}>
{/* 多选框 */}
{multiSelectMode && (
<button
onClick={() => toggleProjectSelection(p.path)}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
selectedProjects.includes(p.path)
? 'bg-indigo-500 border-indigo-500 text-white'
: 'border-slate-500 hover:border-indigo-400'
}`}>
{selectedProjects.includes(p.path) && <CheckSquareIcon className='w-3 h-3' />}
</button>
)}
{/* 项目图标 */}
<div className={`shrink-0 p-2 rounded-lg ${p.status === 'active' ? 'bg-green-500/20' : p.status === 'unlive' ? 'bg-orange-500/20' : 'bg-slate-700/50'}`}>
<FolderIcon className={`w-4 h-4 ${p.status === 'active' ? 'text-green-400' : p.status === 'unlive' ? 'text-orange-400' : 'text-slate-400'}`} />
</div>
<div className='flex-1 min-w-0'> {/* 状态切换按钮 */}
<p className='text-sm font-medium text-slate-200 truncate'>{p.name ?? p.path.split('/').pop()}</p> {p.status !== undefined && (
<p className='text-xs text-slate-500 truncate'>{p.path}</p> <button
</div> onClick={() => openStatusConfirm(p)}
className={`shrink-0 flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full transition-all duration-200 ${p.status === 'active'
{/* 状态切换按钮 */}
{p.status !== undefined && (
<button
onClick={() => openStatusConfirm(p)}
className={`shrink-0 flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full transition-all duration-200 ${p.status === 'active'
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30 hover:scale-105' ? 'bg-green-500/20 text-green-400 hover:bg-green-500/30 hover:scale-105'
: p.status === 'unlive' : p.status === 'unlive'
? 'bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 hover:scale-105' ? 'bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 hover:scale-105'
: 'bg-slate-700/50 text-slate-400 hover:bg-slate-600/50 hover:scale-105' : 'bg-slate-700/50 text-slate-400 hover:bg-slate-600/50 hover:scale-105'
}`}> }`}>
{p.status === 'active' ? ( {p.status === 'active' ? (
<> <>
<StopCircleIcon className='w-3 h-3' /> <StopCircleIcon className='w-3 h-3' />
<span></span> <span></span>
</> </>
) : p.status === 'unlive' ? ( ) : p.status === 'unlive' ? (
<> <>
<CircleOffIcon className='w-3 h-3' /> <CircleOffIcon className='w-3 h-3' />
<span></span> <span></span>
</> </>
) : ( ) : (
<> <>
<PlayCircleIcon className='w-3 h-3' /> <PlayCircleIcon className='w-3 h-3' />
<span></span> <span></span>
</> </>
)} )}
</button> </button>
)} )}
{/* 删除按钮 */} {/* 删除按钮 */}
<button <button
onClick={() => openDeleteConfirm(p)} onClick={() => openDeleteConfirm(p)}
className='shrink-0 text-slate-600 hover:text-red-400 transition-all duration-200 p-1.5 rounded-lg hover:bg-red-500/10 opacity-0 group-hover:opacity-100'> className='shrink-0 text-slate-600 hover:text-red-400 transition-all duration-200 p-1.5 rounded-lg hover:bg-red-500/10 opacity-0 group-hover:opacity-100'>
<Trash2Icon className='w-4 h-4' /> <Trash2Icon className='w-4 h-4' />
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -531,8 +693,8 @@ export function ProjectDialog() {
<Button <Button
onClick={handleConfirmStatusChange} onClick={handleConfirmStatusChange}
className={`flex-1 ${pendingStatusProject?.status === 'active' className={`flex-1 ${pendingStatusProject?.status === 'active'
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/25' ? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/25'
: 'bg-green-600 hover:bg-green-500 shadow-lg shadow-green-500/25' : 'bg-green-600 hover:bg-green-500 shadow-lg shadow-green-500/25'
}`}> }`}>
{pendingStatusProject?.status === 'active' ? '停止监听' : '开始监听'} {pendingStatusProject?.status === 'active' ? '停止监听' : '开始监听'}
</Button> </Button>
@@ -570,44 +732,8 @@ export function ProjectDialog() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 初始化确认弹窗 */} {/* 初始化弹窗 */}
<Dialog open={initConfirmOpen} onOpenChange={(open) => !open && handleCancelInit()}> <ProjectInitDialog open={initConfirmOpen} onOpenChange={setInitConfirmOpen} />
<DialogContent className='sm:max-w-md bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100 border border-white/10 shadow-2xl'>
<DialogHeader>
<div className='w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3 bg-indigo-500/20'>
<DownloadIcon className='w-6 h-6 text-indigo-400' />
</div>
<DialogTitle className='text-center text-lg font-semibold'>
</DialogTitle>
<DialogDescription className='text-center text-slate-400'>
workspace/projects
</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:justify-center'>
<Button
variant='outline'
onClick={handleCancelInit}
disabled={initLoading}
className='bg-transparent border-white/20 text-slate-300 hover:bg-white/10 hover:border-white/30 flex-1'>
</Button>
<Button
onClick={handleConfirmInit}
disabled={initLoading}
className='bg-indigo-600 hover:bg-indigo-500 text-white flex-1 shadow-lg shadow-indigo-500/25 disabled:opacity-50'>
{initLoading ? (
<span className='flex items-center gap-2'>
<span className='w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</span>
) : (
'确认初始化'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog> </Dialog>
); );
} }

View File

@@ -8,17 +8,16 @@ import { useBotHelperStore } from '../store/bot-helper';
interface ProjectPanelProps { interface ProjectPanelProps {
onProjectClick?: (projectPath: string, files: FileProjectData[]) => void; onProjectClick?: (projectPath: string, files: FileProjectData[]) => void;
onOpenCodePod?: (projectPath: string) => void;
onStopProject?: (projectPath: string) => void; onStopProject?: (projectPath: string) => void;
} }
export function ProjectPanel({ export function ProjectPanel({
onProjectClick, onProjectClick,
onOpenCodePod,
onStopProject, onStopProject,
}: ProjectPanelProps) { }: ProjectPanelProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 20, y: 100 }); const [position, setPosition] = useState({ x: 20, y: 100 });
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const dragOffset = useRef({ x: 0, y: 0 }); const dragOffset = useRef({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
@@ -83,16 +82,47 @@ export function ProjectPanel({
const handleProjectClick = useCallback( const handleProjectClick = useCallback(
(projectPath: string) => { (projectPath: string) => {
// 如果点击的是已选中的项目,则取消选中
if (selectedProject === projectPath) {
setSelectedProject(null);
window.location.hash = '';
useCodeGraphStore.getState().fetchProjects();
return;
}
// 从 projects 列表中查找对应项目的 repo
const project = projects.find((p) => p.path === projectPath); const project = projects.find((p) => p.path === projectPath);
const repo = project?.repo;
if (repo) {
// 设置 hash 并刷新
window.location.hash = `repo=${repo}`;
useCodeGraphStore.getState().fetchProjects();
}
setSelectedProject(projectPath);
if (project && onProjectClick) { if (project && onProjectClick) {
onProjectClick(projectPath, files); onProjectClick(projectPath, files);
} }
}, },
[projects, files, onProjectClick], [projects, files, onProjectClick, selectedProject],
); );
const [isLargeScreen, setIsLargeScreen] = useState(false); const [isLargeScreen, setIsLargeScreen] = useState(false);
// 初始化选中状态
useEffect(() => {
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const repo = hashParams.get('repo');
if (repo && projects.length > 0) {
const projectItem = projects.find((p) => p.repo === repo);
if (projectItem) {
setSelectedProject(projectItem.path);
}
}
}, [projects]);
useEffect(() => { useEffect(() => {
const checkScreen = () => { const checkScreen = () => {
setIsLargeScreen(window.innerWidth >= 1024); setIsLargeScreen(window.innerWidth >= 1024);
@@ -143,7 +173,11 @@ export function ProjectPanel({
return ( return (
<div <div
key={project.path} key={project.path}
className='flex items-center gap-2 px-3 py-2 text-left text-sm text-slate-300 hover:bg-white/5 hover:text-white transition-colors border-l-2 border-transparent hover:border-indigo-500 group' className={`flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors border-l-2 group ${
selectedProject === project.path
? 'bg-indigo-500/20 text-white border-indigo-500'
: 'text-slate-300 hover:bg-white/5 hover:text-white border-transparent hover:border-indigo-500'
}`}
> >
<button <button
onClick={() => handleProjectClick(project.path)} onClick={() => handleProjectClick(project.path)}

View File

@@ -97,19 +97,6 @@ export default function CodeGraphPage() {
<Code3DGraph files={files} className='h-full' onProjectFocus={projectFocus ?? undefined} /> <Code3DGraph files={files} className='h-full' onProjectFocus={projectFocus ?? undefined} />
<ProjectPanel <ProjectPanel
onProjectClick={handleProjectClick} onProjectClick={handleProjectClick}
onOpenCodePod={(projectPath) => {
setCodePodAttrs({
label: projectPath.split('/').pop() || projectPath,
size: 0,
color: '',
x: 0,
y: 0,
fullPath: projectPath,
projectPath,
kind: 'dir',
});
setCodePodOpen(true);
}}
onStopProject={(projectPath) => toggleProjectStatus(projectPath)} onStopProject={(projectPath) => toggleProjectStatus(projectPath)}
/> />
</> </>

View File

@@ -53,7 +53,9 @@ type State = {
addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise<boolean>; addProject: (filepath: string, name?: string, type?: 'filepath' | 'cnb-repo') => Promise<boolean>;
removeProject: (path: string) => Promise<void>; removeProject: (path: string) => Promise<void>;
toggleProjectStatus: (path: string) => Promise<void>; toggleProjectStatus: (path: string) => Promise<void>;
initProject: () => Promise<void>; initProject: (projectPaths?: string[]) => Promise<void>;
// 获取项目文件列表
fetchProjectFiles: (rootPath?: string) => Promise<string[]>;
// NodeInfo 弹窗 // NodeInfo 弹窗
nodeInfoOpen: boolean; nodeInfoOpen: boolean;
setNodeInfoOpen: (open: boolean) => void; setNodeInfoOpen: (open: boolean) => void;
@@ -69,6 +71,8 @@ type State = {
q?: string; // 可选的搜索关键词 q?: string; // 可选的搜索关键词
projectPath?: string; // 项目路径,必填 projectPath?: string; // 项目路径,必填
getContent?: boolean; // 是否获取文件内容,默认为 false getContent?: boolean; // 是否获取文件内容,默认为 false
repo?: string; // 仓库地址
projects?: string[]
}) => Promise<Result<{ list: FileProjectData[] }>>; }) => Promise<Result<{ list: FileProjectData[] }>>;
createQuestion: (opts: { question: string, projectPath?: string, filePath?: string, engine?: 'openclaw' | 'opencode', sessionId?: string }) => any; createQuestion: (opts: { question: string, projectPath?: string, filePath?: string, engine?: 'openclaw' | 'opencode', sessionId?: string }) => any;
saveFile: (filepath: string, content: string) => Promise<void>; saveFile: (filepath: string, content: string) => Promise<void>;
@@ -143,11 +147,11 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
return false; return false;
} }
}, },
initProject: async () => { initProject: async (projectPaths) => {
const loadingToast = toast.loading('初始化项目中...'); const loadingToast = toast.loading('初始化项目中...');
try { try {
const url = get().url || API_URL; const url = get().url || API_URL;
const res = await projectApi.project.init(undefined, { url }); const res = await projectApi.project.init({ projectPaths }, { url });
toast.dismiss(loadingToast); toast.dismiss(loadingToast);
if (res.code === 200) { if (res.code === 200) {
toast.success('项目初始化成功'); toast.success('项目初始化成功');
@@ -160,6 +164,15 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
toast.error('项目初始化失败'); toast.error('项目初始化失败');
} }
}, },
fetchProjectFiles: async (rootPath) => {
const url = get().url || API_URL;
const res = await projectApi['project']['project-files']({ rootPath }, { url });
if (res.code === 200) {
const data = res.data as { projectFiles?: string[]; list?: string[] };
return data?.projectFiles ?? data?.list ?? [];
}
return [];
},
removeProject: async (path) => { removeProject: async (path) => {
const loadingToast = toast.loading('移除项目中...'); const loadingToast = toast.loading('移除项目中...');
try { try {
@@ -241,7 +254,12 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
}, },
fetchProjects: async () => { fetchProjects: async () => {
get().loadProjects(); get().loadProjects();
const res = await get().getFiles(); // 从hash中获取repo参数 (#repo=kevisual/cnb 格式)
const hashParams = new URLSearchParams(window.location.hash.slice(1));
const repo = hashParams.get('repo');
const res = await get().getFiles({
repo: repo || undefined,
});
if (res.code === 200) { if (res.code === 200) {
set({ files: res.data!.list }); set({ files: res.data!.list });
} else { } else {
@@ -253,6 +271,8 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
q?: string; // 可选的搜索关键词 q?: string; // 可选的搜索关键词
projectPath?: string; // 项目路径,必填 projectPath?: string; // 项目路径,必填
getContent?: boolean; // 是否获取文件内容,默认为 false getContent?: boolean; // 是否获取文件内容,默认为 false
projects?: string[];
repo?: string; // 仓库地址
}) => { }) => {
const url = get().url const url = get().url
const res = await projectApi["project-search"].files({ const res = await projectApi["project-search"].files({