feat(ui): 添加 IDE 组件(文件浏览器 + 代码编辑器)

- 新增 CodeEditor 组件,基于 CodeMirror 实现多标签代码编辑
- 新增 FileExplorer 组件,支持文件树展开/折叠和文件选择
- 新增 IDE 组件,整合文件浏览器和代码编辑器
- 新增 SessionPanel 组件,用于会话管理
- 添加文件写入 API(PUT /api/files/write)
- 优化布局:IDE 始终显示,移除文件切换按钮
- 工作目录路径显示在文件浏览器标题栏,支持悬浮显示完整路径
This commit is contained in:
2025-12-17 16:55:22 +08:00
parent ddec356117
commit 250d2cb4b5
11 changed files with 1376 additions and 113 deletions
+47 -1
View File
@@ -5,7 +5,7 @@
*/
import { Hono } from 'hono';
import { readdir, stat, readFile } from 'node:fs/promises';
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, resolve, basename, extname, dirname } from 'node:path';
import { searchFiles as coreSearchFiles, type FileIndexEntry } from '@ai-assistant/core';
@@ -398,4 +398,50 @@ filesRouter.get('/search', async (c) => {
}
});
// ============================================================================
// PUT /api/files/write - 写入文件内容
// ============================================================================
filesRouter.put('/write', async (c) => {
try {
const body = await c.req.json();
const { path: requestedPath, content } = body;
if (!requestedPath) {
return c.json({ success: false, error: 'Path is required' }, 400);
}
if (typeof content !== 'string') {
return c.json({ success: false, error: 'Content must be a string' }, 400);
}
const absolutePath = safePath(requestedPath);
if (!absolutePath) {
return c.json({ success: false, error: 'Access denied: path outside working directory' }, 403);
}
// 确保父目录存在
const parentDir = dirname(absolutePath);
await mkdir(parentDir, { recursive: true });
// 写入文件
await writeFile(absolutePath, content, 'utf-8');
const stats = await stat(absolutePath);
const name = basename(absolutePath);
return c.json({
success: true,
data: {
path: requestedPath,
name,
size: stats.size,
modified: stats.mtime.toISOString(),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return c.json({ success: false, error: message }, 500);
}
});
export { filesRouter };
+8
View File
@@ -22,6 +22,13 @@
"react-dom": "^18.0.0"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
@@ -29,6 +36,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
+14
View File
@@ -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}`);
+286
View File
@@ -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>
);
}
+253
View File
@@ -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>
);
}
+142
View File
@@ -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>
);
}
+258
View File
@@ -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>
);
}
+6
View File
@@ -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';
+25 -81
View File
@@ -6,8 +6,7 @@
import { useState, useEffect, useCallback } from 'react';
import {
Sidebar,
FileBrowser,
IDE,
CommandPanel,
MCPPanel,
HooksPanel,
@@ -16,11 +15,11 @@ import {
ProvidersPanel,
LSPPanel,
DiagnosticsPanel,
SessionPanel,
Toaster,
ThemeProvider,
listSessions,
createSession,
getWorkingDirectory,
type Session,
} from '@ai-assistant/ui';
import { ChatPage } from './pages/Chat';
@@ -28,7 +27,6 @@ import { ChatPage } from './pages/Chat';
export function App() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState(true);
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showCommands, setShowCommands] = useState(false);
const [showMCP, setShowMCP] = useState(false);
const [showHooks, setShowHooks] = useState(false);
@@ -37,28 +35,18 @@ export function App() {
const [showProviders, setShowProviders] = useState(false);
const [showLSP, setShowLSP] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showSessions, setShowSessions] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
const [workingDirectory, setWorkingDirectory] = useState<string>('');
// 初始化:加载会话和工作目录
// 初始化:加载会话
useEffect(() => {
const HAS_SESSIONS_KEY = 'ai-assistant-has-sessions';
async function init() {
try {
// 并行获取会话和工作目录
const [sessionsResult, workdirResult] = await Promise.all([
listSessions(),
getWorkingDirectory().catch(() => null),
]);
const sessionsResult = await listSessions();
const { data: sessions } = sessionsResult;
// 设置工作目录
if (workdirResult?.data?.workingDirectory) {
setWorkingDirectory(workdirResult.data.workingDirectory);
}
if (sessions.length > 0) {
// 有会话,选择最近的
setCurrentSessionId(sessions[0].id);
@@ -124,18 +112,15 @@ export function App() {
return (
<ThemeProvider>
<div className="h-screen flex bg-surface-base">
<Sidebar
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
responsive
sessionTitleUpdate={sessionTitleUpdate}
/>
{/* 主内容区域 */}
{/* 主内容区域:左侧文件浏览器 + 右侧对话框 */}
<div className="flex-1 flex min-w-0">
{/* 聊天区域 */}
<div className={`flex-1 min-w-0 ${showFileBrowser ? 'hidden md:block md:w-1/2' : 'w-full'}`}>
{/* 左侧:IDE(文件浏览器 + 代码编辑器) */}
<div className="hidden md:flex flex-col border-r border-line w-[50%] lg:w-[60%]">
<IDE />
</div>
{/* 右侧:聊天区域 */}
<div className="flex-1 min-w-0">
{currentSessionId ? (
<ChatPage
key={currentSessionId}
@@ -143,8 +128,6 @@ export function App() {
onSessionNotFound={handleSessionNotFound}
onSessionUpdated={handleSessionUpdated}
responsive
showFileBrowser={showFileBrowser}
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
onOpenCommands={() => setShowCommands(true)}
onOpenMCP={() => setShowMCP(true)}
onOpenHooks={() => setShowHooks(true)}
@@ -153,7 +136,7 @@ export function App() {
onOpenProviders={() => setShowProviders(true)}
onOpenLSP={() => setShowLSP(true)}
onOpenDiagnostics={() => setShowDiagnostics(true)}
workingDirectory={workingDirectory}
onOpenSessions={() => setShowSessions(true)}
/>
) : (
<div className="flex-1 flex items-center justify-center h-full">
@@ -162,41 +145,6 @@ export function App() {
)}
</div>
{/* 文件浏览器 - 桌面端侧边栏,移动端全屏覆盖 */}
{showFileBrowser && (
<>
{/* 移动端: 全屏覆盖 */}
<div className="fixed inset-0 z-50 bg-surface-base md:hidden">
<div className="flex items-center justify-between p-3 border-b border-line">
<span className="text-lg font-semibold text-fg">Files</span>
<button
onClick={() => setShowFileBrowser(false)}
className="p-2 rounded-lg bg-surface-muted text-fg-muted hover:bg-surface-emphasis"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="h-[calc(100%-56px)]">
<FileBrowser
onFileSelect={(path, _content) => {
console.log('Selected file:', path);
}}
/>
</div>
</div>
{/* 桌面端: 侧边栏 */}
<div className="hidden md:block w-1/2 border-l border-line">
<FileBrowser
onFileSelect={(path, _content) => {
console.log('Selected file:', path);
}}
/>
</div>
</>
)}
</div>
{/* 命令面板 */}
@@ -241,21 +189,17 @@ export function App() {
/>
)}
{/* 移动端底部文件按钮 */}
<button
onClick={() => setShowFileBrowser(true)}
className="fixed bottom-20 right-4 z-30 p-3 rounded-full bg-surface-muted text-fg-muted hover:bg-surface-emphasis active:bg-surface-emphasis shadow-lg md:hidden"
title="Browse Files"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
</button>
{/* Sessions 面板 */}
{showSessions && (
<SessionPanel
onClose={() => setShowSessions(false)}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onCreateSession={handleCreateSession}
sessionTitleUpdate={sessionTitleUpdate}
responsive
/>
)}
{/* Toast 通知 */}
<Toaster />
+11 -31
View File
@@ -3,7 +3,7 @@
*/
import { useEffect, useRef } from 'react';
import { WifiOff, MessageSquare, FolderOpen, Terminal, Plug, Zap, Bot, History, Server, Folder } from 'lucide-react';
import { WifiOff, MessageSquare, Terminal, Plug, Zap, Bot, History, Server, MessagesSquare } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import {
@@ -23,8 +23,6 @@ interface ChatPageProps {
onSessionUpdated?: (sessionId: string, name: string) => void;
responsive?: boolean;
// 工具栏按钮
showFileBrowser?: boolean;
onToggleFileBrowser?: () => void;
onOpenCommands?: () => void;
onOpenMCP?: () => void;
onOpenHooks?: () => void;
@@ -33,8 +31,7 @@ interface ChatPageProps {
onOpenProviders?: () => void;
onOpenLSP?: () => void;
onOpenDiagnostics?: () => void;
// Working Directory
workingDirectory?: string;
onOpenSessions?: () => void;
}
export function ChatPage({
@@ -42,8 +39,6 @@ export function ChatPage({
onSessionNotFound,
onSessionUpdated,
responsive = false,
showFileBrowser,
onToggleFileBrowser,
onOpenCommands,
onOpenMCP,
onOpenHooks,
@@ -52,7 +47,7 @@ export function ChatPage({
onOpenProviders,
onOpenLSP,
onOpenDiagnostics,
workingDirectory,
onOpenSessions,
}: ChatPageProps) {
const {
messages,
@@ -162,18 +157,7 @@ export function ChatPage({
<div className="flex-1 flex flex-col h-screen">
{/* Header */}
<div className="flex items-center justify-between px-4 md:px-6 py-3 border-b border-line bg-surface-subtle">
<div className="flex items-center gap-3 min-w-0">
<h1 className="text-lg font-medium text-fg flex-shrink-0">Chat</h1>
{/* Working Directory */}
{workingDirectory && (
<div className="flex items-center gap-1.5 text-sm text-fg-muted min-w-0">
<Folder size={14} className="flex-shrink-0 text-fg-subtle" />
<span className="truncate font-mono text-xs" title={workingDirectory}>
{workingDirectory}
</span>
</div>
)}
</div>
<h1 className="text-lg font-medium text-fg">Chat</h1>
<div className="flex items-center gap-3 flex-shrink-0">
{/* 上下文使用情况 - 紧凑模式 */}
{sessionId && (
@@ -189,7 +173,7 @@ export function ChatPage({
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders || onOpenLSP || onOpenDiagnostics) && (
{(onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders || onOpenLSP || onOpenDiagnostics || onOpenSessions) && (
<div className="flex items-center gap-1.5 border-l border-line-muted pl-3">
{/* LSP 诊断指示器 */}
{(onOpenLSP || onOpenDiagnostics) && (
@@ -278,20 +262,16 @@ export function ChatPage({
</motion.button>
)}
{/* 文件浏览器按钮 - 仅桌面端显示 */}
{onToggleFileBrowser && (
{/* Sessions 按钮 */}
{onOpenSessions && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onToggleFileBrowser}
className={`hidden md:block p-1.5 rounded-lg transition-colors ${
showFileBrowser
? 'text-blue-400 bg-blue-500/20'
: 'text-fg-muted hover:text-fg-secondary hover:bg-surface-muted'
}`}
title={showFileBrowser ? 'Hide Files' : 'Show Files'}
onClick={onOpenSessions}
className="p-1.5 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-muted transition-colors"
title="Sessions"
>
<FolderOpen size={20} />
<MessagesSquare size={20} />
</motion.button>
)}
</div>
+326
View File
@@ -234,6 +234,27 @@ importers:
packages/ui:
dependencies:
'@codemirror/lang-css':
specifier: ^6.3.1
version: 6.3.1
'@codemirror/lang-html':
specifier: ^6.4.11
version: 6.4.11
'@codemirror/lang-javascript':
specifier: ^6.2.4
version: 6.2.4
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lang-python':
specifier: ^6.2.1
version: 6.2.1
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -255,6 +276,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@uiw/react-codemirror':
specifier: ^4.25.4
version: 4.25.4(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.4)(codemirror@6.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -953,6 +977,48 @@ packages:
resolution: {integrity: sha512-8XqW8xGn++Eqqbz3e9wKuK7mxryeRjs4LOHLxbh2lwKeSbuNR4NFifDZT4KzvjU6HMOPbiNTsWpniK5EJfTWkg==}
engines: {node: '>=18'}
'@codemirror/autocomplete@6.20.0':
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
'@codemirror/commands@6.10.1':
resolution: {integrity: sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
'@codemirror/lint@6.9.2':
resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==}
'@codemirror/search@6.5.11':
resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
'@codemirror/state@6.5.2':
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.39.4':
resolution: {integrity: sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
@@ -1452,6 +1518,36 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@lezer/common@1.4.0':
resolution: {integrity: sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.12':
resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.5':
resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==}
'@lezer/markdown@1.6.1':
resolution: {integrity: sha512-72ah+Sml7lD8Wn7lnz9vwYmZBo9aQT+I2gjK/0epI+gjdwUbWw3MJ/ZBGEqG1UfrIauRqH37/c5mVHXeCTGXtA==}
'@lezer/python@1.1.18':
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -2217,6 +2313,28 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@uiw/codemirror-extensions-basic-setup@4.25.4':
resolution: {integrity: sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==}
peerDependencies:
'@codemirror/autocomplete': '>=6.0.0'
'@codemirror/commands': '>=6.0.0'
'@codemirror/language': '>=6.0.0'
'@codemirror/lint': '>=6.0.0'
'@codemirror/search': '>=6.0.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
'@uiw/react-codemirror@4.25.4':
resolution: {integrity: sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==}
peerDependencies:
'@babel/runtime': '>=7.11.0'
'@codemirror/state': '>=6.0.0'
'@codemirror/theme-one-dark': '>=6.0.0'
'@codemirror/view': '>=6.0.0'
codemirror: '>=6.0.0'
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -2559,6 +2677,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2608,6 +2729,9 @@ packages:
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -4302,6 +4426,9 @@ packages:
resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==}
engines: {node: '>=10'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -4818,6 +4945,9 @@ packages:
vscode-languageserver-types@3.17.5:
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@@ -5776,6 +5906,112 @@ snapshots:
dependencies:
fontkit: 2.0.4
'@codemirror/autocomplete@6.20.0':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/common': 1.4.0
'@codemirror/commands@6.10.1':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/common': 1.4.0
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@lezer/common': 1.4.0
'@lezer/css': 1.3.0
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/common': 1.4.0
'@lezer/css': 1.3.0
'@lezer/html': 1.3.12
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.11.3
'@codemirror/lint': 6.9.2
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/common': 1.4.0
'@lezer/javascript': 1.5.4
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.11.3
'@lezer/json': 1.0.3
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/common': 1.4.0
'@lezer/markdown': 1.6.1
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@lezer/common': 1.4.0
'@lezer/python': 1.1.18
'@codemirror/language@6.11.3':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.5
style-mod: 4.1.3
'@codemirror/lint@6.9.2':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
crelt: 1.0.6
'@codemirror/search@6.5.11':
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
crelt: 1.0.6
'@codemirror/state@6.5.2':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/highlight': 1.2.3
'@codemirror/view@6.39.4':
dependencies:
'@codemirror/state': 6.5.2
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@emnapi/runtime@1.7.1':
dependencies:
tslib: 2.8.1
@@ -6098,6 +6334,53 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': {}
'@lezer/common@1.4.0': {}
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.5
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.4.0
'@lezer/html@1.3.12':
dependencies:
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.5
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.5
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.5
'@lezer/lr@1.4.5':
dependencies:
'@lezer/common': 1.4.0
'@lezer/markdown@1.6.1':
dependencies:
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/python@1.1.18':
dependencies:
'@lezer/common': 1.4.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.5
'@marijn/find-cluster-break@1.0.2': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6851,6 +7134,33 @@ snapshots:
'@types/uuid@10.0.0': {}
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.1)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.39.4)':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1
'@codemirror/language': 6.11.3
'@codemirror/lint': 6.9.2
'@codemirror/search': 6.5.11
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.4)(codemirror@6.0.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.28.4
'@codemirror/commands': 6.10.1
'@codemirror/state': 6.5.2
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.39.4
'@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.1)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.39.4)
codemirror: 6.0.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@codemirror/autocomplete'
- '@codemirror/language'
- '@codemirror/lint'
- '@codemirror/search'
'@ungap/structured-clone@1.3.0': {}
'@vercel/oidc@3.0.5': {}
@@ -7310,6 +7620,16 @@ snapshots:
clsx@2.1.1: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1
'@codemirror/language': 6.11.3
'@codemirror/lint': 6.9.2
'@codemirror/search': 6.5.11
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -7344,6 +7664,8 @@ snapshots:
dependencies:
browserslist: 4.28.1
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -9514,6 +9836,8 @@ snapshots:
strip-comments@2.0.1: {}
style-mod@4.1.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -9944,6 +10268,8 @@ snapshots:
vscode-languageserver-types@3.17.5: {}
w3c-keyname@2.2.8: {}
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4