feat(ui): 添加 IDE 组件(文件浏览器 + 代码编辑器)
- 新增 CodeEditor 组件,基于 CodeMirror 实现多标签代码编辑 - 新增 FileExplorer 组件,支持文件树展开/折叠和文件选择 - 新增 IDE 组件,整合文件浏览器和代码编辑器 - 新增 SessionPanel 组件,用于会话管理 - 添加文件写入 API(PUT /api/files/write) - 优化布局:IDE 始终显示,移除文件切换按钮 - 工作目录路径显示在文件浏览器标题栏,支持悬浮显示完整路径
This commit is contained in:
@@ -270,6 +270,20 @@ export async function readFile(path: string): Promise<FileReadResponse> {
|
||||
return request('GET', `/files/read?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
export interface FileWriteResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeFile(path: string, content: string): Promise<FileWriteResponse> {
|
||||
return request('PUT', '/files/write', { path, content });
|
||||
}
|
||||
|
||||
export async function getFileTree(path: string = '.', depth: number = 3): Promise<FileTreeResponse> {
|
||||
const params = new URLSearchParams({ path, depth: String(depth) });
|
||||
return request('GET', `/files/tree?${params}`);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* CodeEditor Component
|
||||
*
|
||||
* 基于 CodeMirror 的代码编辑器,支持多标签页和保存功能
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { X, Save, Circle } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { writeFile } from '../api/client.js';
|
||||
import { useTheme } from '../hooks/useTheme.js';
|
||||
|
||||
export interface EditorTab {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
tabs: EditorTab[];
|
||||
activeTabId: string | null;
|
||||
onTabChange: (tabId: string) => void;
|
||||
onTabClose: (tabId: string) => void;
|
||||
onContentChange: (tabId: string, content: string) => void;
|
||||
onSave?: (tabId: string, path: string, content: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 根据文件扩展名获取语言
|
||||
function getLanguageExtension(language: string) {
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
case 'typescript':
|
||||
case 'jsx':
|
||||
case 'tsx':
|
||||
return javascript({ jsx: true, typescript: language.includes('typescript') || language === 'tsx' });
|
||||
case 'python':
|
||||
return python();
|
||||
case 'json':
|
||||
return json();
|
||||
case 'html':
|
||||
return html();
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return css();
|
||||
case 'markdown':
|
||||
case 'md':
|
||||
return markdown();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 根据文件名获取语言类型
|
||||
export function getLanguageFromFilename(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const languageMap: Record<string, string> = {
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
py: 'python',
|
||||
json: 'json',
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
less: 'less',
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
};
|
||||
return languageMap[ext] || 'text';
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabChange,
|
||||
onTabClose,
|
||||
onContentChange,
|
||||
onSave,
|
||||
className,
|
||||
}: CodeEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTabId),
|
||||
[tabs, activeTabId]
|
||||
);
|
||||
|
||||
// 检查标签是否有未保存的更改
|
||||
const hasUnsavedChanges = useCallback(
|
||||
(tab: EditorTab) => tab.content !== tab.originalContent,
|
||||
[]
|
||||
);
|
||||
|
||||
// 保存文件
|
||||
const handleSave = useCallback(
|
||||
async (tab: EditorTab) => {
|
||||
if (!hasUnsavedChanges(tab)) return;
|
||||
|
||||
setSaving(tab.id);
|
||||
try {
|
||||
const result = await writeFile(tab.path, tab.content);
|
||||
if (result.success) {
|
||||
onSave?.(tab.id, tab.path, tab.content);
|
||||
toast.success(`Saved ${tab.name}`);
|
||||
} else {
|
||||
toast.error('Failed to save file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save file:', error);
|
||||
toast.error('Failed to save file');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
},
|
||||
[hasUnsavedChanges, onSave]
|
||||
);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (activeTab && hasUnsavedChanges(activeTab)) {
|
||||
handleSave(activeTab);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeTab, hasUnsavedChanges, handleSave]);
|
||||
|
||||
// 编辑器扩展
|
||||
const extensions = useMemo(() => {
|
||||
if (!activeTab) return [];
|
||||
return [getLanguageExtension(activeTab.language)].flat();
|
||||
}, [activeTab]);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center h-full bg-surface-base text-fg-muted', className)}>
|
||||
<p>No files open</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
|
||||
{/* Tab Bar */}
|
||||
<div className="flex items-center border-b border-line bg-surface-subtle overflow-x-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isModified = hasUnsavedChanges(tab);
|
||||
const isSaving = saving === tab.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={tab.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 border-r border-line cursor-pointer group min-w-0',
|
||||
'hover:bg-surface-muted transition-colors',
|
||||
isActive && 'bg-surface-base border-b-2 border-b-primary-500'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
{/* 修改指示器 */}
|
||||
{isModified && !isSaving && (
|
||||
<Circle size={8} className="fill-orange-400 text-orange-400 flex-shrink-0" />
|
||||
)}
|
||||
{isSaving && (
|
||||
<div className="w-3 h-3 border-2 border-primary-500 border-t-transparent rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* 文件名 */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm truncate max-w-[150px]',
|
||||
isActive ? 'text-fg' : 'text-fg-muted'
|
||||
)}
|
||||
title={tab.path}
|
||||
>
|
||||
{tab.name}
|
||||
</span>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-surface-emphasis transition-colors flex-shrink-0',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
isActive && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<X size={14} className="text-fg-muted" />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
{activeTab && hasUnsavedChanges(activeTab) && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => handleSave(activeTab)}
|
||||
disabled={saving === activeTab.id}
|
||||
className="ml-auto mr-2 p-1.5 rounded hover:bg-surface-muted transition-colors"
|
||||
title="Save (Cmd+S)"
|
||||
>
|
||||
<Save size={16} className="text-fg-muted" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab && (
|
||||
<CodeMirror
|
||||
value={activeTab.content}
|
||||
height="100%"
|
||||
theme={resolvedTheme === 'dark' ? oneDark : undefined}
|
||||
extensions={extensions}
|
||||
onChange={(value) => onContentChange(activeTab.id, value)}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightSpecialChars: true,
|
||||
history: true,
|
||||
foldGutter: true,
|
||||
drawSelection: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
syntaxHighlighting: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
defaultKeymap: true,
|
||||
searchKeymap: true,
|
||||
historyKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: true,
|
||||
lintKeymap: true,
|
||||
}}
|
||||
className="h-full text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* FileExplorer Component
|
||||
*
|
||||
* 文件树组件,支持展开/折叠和文件选择
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronRight, ChevronDown, File, Folder, FolderOpen, RefreshCw } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { getFileTree, type FileTreeNode } from '../api/client.js';
|
||||
|
||||
interface FileExplorerProps {
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
className?: string;
|
||||
/** 工作目录路径,显示在标题栏 */
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
// 文件图标颜色映射
|
||||
const fileIconColors: Record<string, string> = {
|
||||
ts: 'text-blue-400',
|
||||
tsx: 'text-blue-400',
|
||||
js: 'text-yellow-300',
|
||||
jsx: 'text-yellow-300',
|
||||
json: 'text-yellow-500',
|
||||
md: 'text-fg-muted',
|
||||
css: 'text-pink-400',
|
||||
scss: 'text-pink-400',
|
||||
html: 'text-orange-400',
|
||||
py: 'text-green-400',
|
||||
go: 'text-cyan-400',
|
||||
rs: 'text-orange-500',
|
||||
java: 'text-red-400',
|
||||
c: 'text-blue-500',
|
||||
cpp: 'text-blue-500',
|
||||
h: 'text-purple-400',
|
||||
};
|
||||
|
||||
function getFileIconColor(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||
return fileIconColors[ext] || 'text-fg-muted';
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
expandedPaths: Set<string>;
|
||||
onToggleExpand: (path: string) => void;
|
||||
}
|
||||
|
||||
function TreeNode({ node, depth, onFileSelect, expandedPaths, onToggleExpand }: TreeNodeProps) {
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isDirectory = node.type === 'directory';
|
||||
const hasChildren = isDirectory && node.children && node.children.length > 0;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDirectory) {
|
||||
onToggleExpand(node.path);
|
||||
} else {
|
||||
onFileSelect?.(node.path, node.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-1 px-2 cursor-pointer rounded',
|
||||
'hover:bg-surface-muted transition-colors',
|
||||
'text-sm'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 展开/折叠图标 */}
|
||||
{isDirectory ? (
|
||||
<span className="w-4 h-4 flex items-center justify-center text-fg-muted">
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
{/* 文件/文件夹图标 */}
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen size={16} className="text-yellow-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder size={16} className="text-yellow-400 flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<File size={16} className={cn('flex-shrink-0', getFileIconColor(node.name))} />
|
||||
)}
|
||||
|
||||
{/* 文件名 */}
|
||||
<span className="truncate text-fg">{node.name}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* 子节点 */}
|
||||
<AnimatePresence>
|
||||
{isDirectory && isExpanded && node.children && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onFileSelect={onFileSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileExplorer({ onFileSelect, className, workingDirectory }: FileExplorerProps) {
|
||||
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
|
||||
// 加载文件树
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getFileTree('.', 4);
|
||||
if (result.success) {
|
||||
setTree(result.data.tree);
|
||||
// 默认展开第一层
|
||||
const firstLevel = new Set(
|
||||
result.data.tree
|
||||
.filter((n) => n.type === 'directory')
|
||||
.map((n) => n.path)
|
||||
);
|
||||
setExpandedPaths(firstLevel);
|
||||
} else {
|
||||
setError('Failed to load file tree');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load file tree');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
const handleToggleExpand = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center h-full', className)}>
|
||||
<div className="w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center h-full gap-3 p-4', className)}>
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={loadTree}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-surface-muted hover:bg-surface-emphasis rounded text-sm text-fg-secondary transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-surface-subtle', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 group relative">
|
||||
<Folder size={14} className="flex-shrink-0 text-fg-muted" />
|
||||
<span className="text-sm font-mono text-fg-muted truncate">
|
||||
{workingDirectory || 'Files'}
|
||||
</span>
|
||||
{/* Tooltip - 悬浮时显示完整路径 */}
|
||||
{workingDirectory && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 hidden group-hover:block">
|
||||
<div className="bg-surface-emphasis border border-line rounded-md shadow-lg px-3 py-2 max-w-[400px]">
|
||||
<p className="text-xs font-mono text-fg break-all whitespace-pre-wrap">
|
||||
{workingDirectory}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTree}
|
||||
className="p-1 hover:bg-surface-muted rounded transition-colors flex-shrink-0"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className="text-fg-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{tree.length === 0 ? (
|
||||
<p className="text-fg-muted text-sm text-center py-4">No files found</p>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
onFileSelect={onFileSelect}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* IDE Component
|
||||
*
|
||||
* 整合文件浏览器和代码编辑器的 IDE 组件
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { readFile, getWorkingDirectory } from '../api/client.js';
|
||||
import { FileExplorer } from './FileExplorer.js';
|
||||
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
|
||||
|
||||
interface IDEProps {
|
||||
className?: string;
|
||||
/** 文件浏览器宽度 */
|
||||
sidebarWidth?: number;
|
||||
}
|
||||
|
||||
export function IDE({ className, sidebarWidth = 256 }: IDEProps) {
|
||||
const [tabs, setTabs] = useState<EditorTab[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [workingDirectory, setWorkingDirectory] = useState<string>('');
|
||||
|
||||
// 获取工作目录
|
||||
useEffect(() => {
|
||||
getWorkingDirectory()
|
||||
.then((result) => {
|
||||
if (result?.data?.workingDirectory) {
|
||||
setWorkingDirectory(result.data.workingDirectory);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 忽略错误
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 打开文件
|
||||
const handleFileSelect = useCallback(async (path: string, name: string) => {
|
||||
// 检查是否已打开
|
||||
const existingTab = tabs.find((tab) => tab.path === path);
|
||||
if (existingTab) {
|
||||
setActiveTabId(existingTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
try {
|
||||
const result = await readFile(path);
|
||||
if (result.success && result.data.encoding === 'utf-8') {
|
||||
const newTab: EditorTab = {
|
||||
id: `tab-${Date.now()}`,
|
||||
path,
|
||||
name,
|
||||
content: result.data.content,
|
||||
originalContent: result.data.content,
|
||||
language: getLanguageFromFilename(name),
|
||||
};
|
||||
setTabs((prev) => [...prev, newTab]);
|
||||
setActiveTabId(newTab.id);
|
||||
} else if (result.data.encoding === 'base64') {
|
||||
toast.error('Cannot edit binary files');
|
||||
} else {
|
||||
toast.error('Failed to open file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open file:', error);
|
||||
toast.error('Failed to open file');
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
// 切换标签
|
||||
const handleTabChange = useCallback((tabId: string) => {
|
||||
setActiveTabId(tabId);
|
||||
}, []);
|
||||
|
||||
// 关闭标签
|
||||
const handleTabClose = useCallback((tabId: string) => {
|
||||
const tabIndex = tabs.findIndex((tab) => tab.id === tabId);
|
||||
const tab = tabs[tabIndex];
|
||||
|
||||
// 检查是否有未保存的更改
|
||||
if (tab && tab.content !== tab.originalContent) {
|
||||
const confirmed = window.confirm(`${tab.name} has unsaved changes. Close anyway?`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
||||
|
||||
// 如果关闭的是当前标签,切换到相邻标签
|
||||
if (activeTabId === tabId) {
|
||||
if (tabs.length > 1) {
|
||||
const newIndex = tabIndex === 0 ? 1 : tabIndex - 1;
|
||||
setActiveTabId(tabs[newIndex].id);
|
||||
} else {
|
||||
setActiveTabId(null);
|
||||
}
|
||||
}
|
||||
}, [tabs, activeTabId]);
|
||||
|
||||
// 内容更改
|
||||
const handleContentChange = useCallback((tabId: string, content: string) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, content } : tab
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 保存后更新原始内容
|
||||
const handleSave = useCallback((tabId: string, _path: string, content: string) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, originalContent: content } : tab
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full', className)}>
|
||||
{/* 文件浏览器 */}
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-line"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} />
|
||||
</div>
|
||||
|
||||
{/* 代码编辑器 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeEditor
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
onTabClose={handleTabClose}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* SessionPanel Component
|
||||
*
|
||||
* 会话管理面板:列出、创建、切换、删除会话
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, forwardRef } from 'react';
|
||||
import { X, Plus, MessageSquare, Trash2, MessageCircle } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '../utils/cn';
|
||||
import { modalOverlay, modalContent, smoothTransition, fadeInUp } from '../utils/animations';
|
||||
import { Button } from '../primitives/Button';
|
||||
import { SessionSkeleton } from './Skeleton';
|
||||
import { listSessions, createSession, deleteSession, type Session } from '../api/client.js';
|
||||
|
||||
interface SessionPanelProps {
|
||||
onClose: () => void;
|
||||
/** 当前选中的会话 ID */
|
||||
currentSessionId: string | null;
|
||||
/** 选择会话回调 */
|
||||
onSelectSession: (id: string) => void;
|
||||
/** 创建会话回调 */
|
||||
onCreateSession: (session: Session) => void;
|
||||
/** 会话标题更新事件(从 WebSocket 接收) */
|
||||
sessionTitleUpdate?: { sessionId: string; name: string } | null;
|
||||
/** 是否启用响应式布局 */
|
||||
responsive?: boolean;
|
||||
}
|
||||
|
||||
export function SessionPanel({
|
||||
onClose,
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
onCreateSession,
|
||||
sessionTitleUpdate,
|
||||
responsive = false,
|
||||
}: SessionPanelProps) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载会话列表
|
||||
const loadSessions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data } = await listSessions();
|
||||
setSessions(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load sessions:', error);
|
||||
toast.error('Failed to load sessions');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 创建新会话
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const { data } = await createSession();
|
||||
setSessions((prev) => [data, ...prev]);
|
||||
onCreateSession(data);
|
||||
toast.success('Session created');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
toast.error('Failed to create session');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除会话
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
toast.success('Session deleted');
|
||||
if (currentSessionId === id) {
|
||||
const remaining = sessions.filter((s) => s.id !== id);
|
||||
if (remaining.length > 0) {
|
||||
onSelectSession(remaining[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
toast.error('Failed to delete session');
|
||||
}
|
||||
};
|
||||
|
||||
// 选择会话
|
||||
const handleSelectSession = (id: string) => {
|
||||
onSelectSession(id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// 处理会话标题更新
|
||||
useEffect(() => {
|
||||
if (sessionTitleUpdate) {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === sessionTitleUpdate.sessionId ? { ...s, name: sessionTitleUpdate.name } : s
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [sessionTitleUpdate]);
|
||||
|
||||
// 会话列表项
|
||||
const SessionItem = forwardRef<HTMLDivElement, { session: Session }>(({ session }, ref) => (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layout
|
||||
variants={fadeInUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-lg cursor-pointer group',
|
||||
'hover:bg-surface-muted transition-colors',
|
||||
'active:bg-surface-emphasis',
|
||||
currentSessionId === session.id && 'bg-primary-500/20 border border-primary-500/30'
|
||||
)}
|
||||
>
|
||||
<MessageSquare size={18} className="text-fg-muted flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate text-fg">
|
||||
{session.name || `Chat ${session.id.slice(0, 8)}`}
|
||||
</div>
|
||||
<div className="text-xs text-fg-subtle">{session.messageCount} messages</div>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => handleDelete(session.id, e)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-red-500/20 rounded transition-all"
|
||||
aria-label="Delete session"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-400" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
));
|
||||
|
||||
// 空状态
|
||||
const EmptyState = () => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={smoothTransition}
|
||||
className="p-8 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-surface-muted/50 flex items-center justify-center">
|
||||
<MessageCircle size={32} className="text-fg-subtle" />
|
||||
</div>
|
||||
<p className="text-fg-muted mb-4">No conversations yet</p>
|
||||
<Button onClick={handleCreate} variant="default" size="sm">
|
||||
<Plus size={16} />
|
||||
Create your first chat
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
// 加载状态
|
||||
const LoadingState = () => (
|
||||
<div className="p-4 space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<SessionSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 面板内容
|
||||
const panelContent = (
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className={cn(
|
||||
'bg-surface-base border border-line rounded-xl shadow-2xl flex flex-col overflow-hidden',
|
||||
responsive
|
||||
? 'fixed inset-4 md:inset-auto md:fixed md:right-4 md:top-4 md:bottom-4 md:w-96'
|
||||
: 'w-96 max-h-[80vh]'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-line">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={20} className="text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-fg">Sessions</h2>
|
||||
{!isLoading && (
|
||||
<span className="text-xs text-fg-muted bg-surface-muted px-2 py-0.5 rounded-full">
|
||||
{sessions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCreate} variant="default" size="sm">
|
||||
<Plus size={16} />
|
||||
New
|
||||
</Button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-surface-muted rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-fg-muted" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<LoadingState />
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-line text-center">
|
||||
<span className="text-xs text-fg-subtle">
|
||||
Click a session to switch, or create a new one
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={onClose}
|
||||
>
|
||||
{panelContent}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
getWorkingDirectory,
|
||||
listFiles,
|
||||
readFile,
|
||||
writeFile,
|
||||
getFileTree,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
@@ -113,6 +114,7 @@ export type {
|
||||
FileInfo,
|
||||
FileListResponse,
|
||||
FileReadResponse,
|
||||
FileWriteResponse,
|
||||
FileTreeNode,
|
||||
FileTreeResponse,
|
||||
ServerConfig,
|
||||
@@ -245,6 +247,10 @@ export { AskUserQuestion } from './components/AskUserQuestion.js';
|
||||
export { LSPPanel } from './components/LSPPanel.js';
|
||||
export { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
|
||||
export { DiagnosticsIndicator, DiagnosticsIndicatorCompact } from './components/DiagnosticsIndicator.js';
|
||||
export { SessionPanel } from './components/SessionPanel.js';
|
||||
export { FileExplorer } from './components/FileExplorer.js';
|
||||
export { CodeEditor, getLanguageFromFilename, type EditorTab } from './components/CodeEditor.js';
|
||||
export { IDE } from './components/IDE.js';
|
||||
|
||||
// Toast function (re-export from sonner)
|
||||
export { toast } from 'sonner';
|
||||
|
||||
Reference in New Issue
Block a user