diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 636066c..cef4a1f 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -50,6 +50,9 @@ import type { CompressionResult, // File search types FileSearchResponse, + // LSP types + LSPServer, + DiagnosticsResponse, } from './types.js'; // Re-export types @@ -135,6 +138,13 @@ export type { QuestionOption, Question, QuestionMessagePart, + // LSP types + LSPServer, + FileDiagnostic, + DiagnosticsSummary, + SingleFileDiagnosticsResponse, + AllFilesDiagnosticsResponse, + DiagnosticsResponse, } from './types.js'; // API Configuration @@ -1003,3 +1013,89 @@ export async function searchFiles( }); return request('GET', `/files/search?${params}`); } + +// ============ LSP API ============ + +/** + * 获取所有语言服务器列表 + */ +export async function listLSPServers(): Promise<{ + success: boolean; + data: LSPServer[]; + error?: string; +}> { + return request('GET', '/lsp/servers'); +} + +/** + * 获取单个语言服务器详情 + */ +export async function getLSPServer(id: string): Promise<{ + success: boolean; + data?: LSPServer; + error?: string; +}> { + return request('GET', `/lsp/servers/${encodeURIComponent(id)}`); +} + +/** + * 安装语言服务器 + */ +export async function installLSPServer(id: string): Promise<{ + success: boolean; + data?: { message: string; server: LSPServer }; + error?: string; +}> { + return request('POST', `/lsp/servers/${encodeURIComponent(id)}/install`); +} + +/** + * 启动语言服务器 + * @param id 服务器 ID + * @param filePath 需要提供一个文件路径来触发对应语言的服务器 + */ +export async function startLSPServer( + id: string, + filePath: string +): Promise<{ + success: boolean; + data?: { message: string; isFirstStart: boolean; runningServers: string[] }; + error?: string; +}> { + return request('POST', `/lsp/servers/${encodeURIComponent(id)}/start`, { filePath }); +} + +/** + * 停止语言服务器 + */ +export async function stopLSPServer(id: string): Promise<{ + success: boolean; + data?: { message: string; runningServers: string[] }; + error?: string; +}> { + return request('POST', `/lsp/servers/${encodeURIComponent(id)}/stop`); +} + +/** + * 获取正在运行的语言服务器列表 + */ +export async function getRunningLSPServers(): Promise<{ + success: boolean; + data: string[]; + error?: string; +}> { + return request('GET', '/lsp/running'); +} + +/** + * 获取诊断信息 + * @param file 可选,指定文件路径获取单个文件的诊断 + */ +export async function getLSPDiagnostics(file?: string): Promise<{ + success: boolean; + data: DiagnosticsResponse; + error?: string; +}> { + const params = file ? `?file=${encodeURIComponent(file)}` : ''; + return request('GET', `/lsp/diagnostics${params}`); +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 89e5659..a4e88e8 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -1058,3 +1058,72 @@ export interface SubagentState { error?: string; } +// ============ LSP 相关 ============ + +/** 语言服务器信息 */ +export interface LSPServer { + /** 服务器 ID */ + id: string; + /** 显示名称 */ + displayName: string; + /** 支持的语言列表 */ + languages: string[]; + /** 是否已安装 */ + installed: boolean; + /** 是否正在运行 */ + running: boolean; + /** 安装命令 */ + installCommand?: string; +} + +/** 文件诊断信息 */ +export interface FileDiagnostic { + /** 行号(1-based) */ + line: number; + /** 列号(1-based) */ + column: number; + /** 结束行号 */ + endLine?: number; + /** 结束列号 */ + endColumn?: number; + /** 诊断消息 */ + message: string; + /** 严重程度 */ + severity: 'error' | 'warning' | 'info' | 'hint'; + /** 来源(如 typescript, eslint) */ + source?: string; + /** 错误代码 */ + code?: string | number; +} + +/** 诊断摘要 */ +export interface DiagnosticsSummary { + /** 总文件数 */ + totalFiles?: number; + /** 错误总数 */ + totalErrors: number; + /** 警告总数 */ + totalWarnings: number; +} + +/** 单文件诊断响应 */ +export interface SingleFileDiagnosticsResponse { + /** 文件路径 */ + file: string; + /** 诊断列表 */ + diagnostics: FileDiagnostic[]; + /** 摘要 */ + summary: DiagnosticsSummary; +} + +/** 所有文件诊断响应 */ +export interface AllFilesDiagnosticsResponse { + /** 所有文件的诊断信息 */ + files: Array<{ file: string; diagnostics: FileDiagnostic[] }>; + /** 摘要 */ + summary: DiagnosticsSummary; +} + +/** 诊断响应(联合类型) */ +export type DiagnosticsResponse = SingleFileDiagnosticsResponse | AllFilesDiagnosticsResponse; + diff --git a/packages/ui/src/components/DiagnosticsIndicator.tsx b/packages/ui/src/components/DiagnosticsIndicator.tsx new file mode 100644 index 0000000..6962e9b --- /dev/null +++ b/packages/ui/src/components/DiagnosticsIndicator.tsx @@ -0,0 +1,267 @@ +/** + * DiagnosticsIndicator Component + * + * Compact diagnostics status indicator for status bar + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + AlertCircle, + AlertTriangle, + Server, + Loader2, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '../utils/cn'; +import { Button } from '../primitives/Button'; +import { + getLSPDiagnostics, + getRunningLSPServers, + type DiagnosticsResponse, + type AllFilesDiagnosticsResponse, +} from '../api/client.js'; + +interface DiagnosticsIndicatorProps { + /** Callback when diagnostics section is clicked */ + onClickDiagnostics?: () => void; + /** Callback when LSP section is clicked */ + onClickLSP?: () => void; + /** Refresh interval in ms (default: 30000) */ + refreshInterval?: number; + /** Additional class names */ + className?: string; +} + +/** Check if diagnostics is all files response */ +function isAllFilesResponse(data: DiagnosticsResponse): data is AllFilesDiagnosticsResponse { + return 'files' in data; +} + +export function DiagnosticsIndicator({ + onClickDiagnostics, + onClickLSP, + refreshInterval = 30000, + className, +}: DiagnosticsIndicatorProps) { + // Data state + const [diagnostics, setDiagnostics] = useState<{ + errors: number; + warnings: number; + } | null>(null); + const [runningServers, setRunningServers] = useState(0); + + // UI state + const [loading, setLoading] = useState(true); + + // Load data + const loadData = useCallback(async () => { + try { + const [diagResult, serversResult] = await Promise.all([ + getLSPDiagnostics(), + getRunningLSPServers(), + ]); + + if (diagResult.success && isAllFilesResponse(diagResult.data)) { + setDiagnostics({ + errors: diagResult.data.summary.totalErrors, + warnings: diagResult.data.summary.totalWarnings, + }); + } + + if (serversResult.success) { + setRunningServers(serversResult.data.length); + } + } catch { + // Silently ignore errors + } finally { + setLoading(false); + } + }, []); + + // Initial load and refresh interval + useEffect(() => { + loadData(); + + const interval = setInterval(loadData, refreshInterval); + return () => clearInterval(interval); + }, [loadData, refreshInterval]); + + // No data yet + if (loading) { + return ( +
+ +
+ ); + } + + const hasErrors = diagnostics && diagnostics.errors > 0; + const hasWarnings = diagnostics && diagnostics.warnings > 0; + const hasIssues = hasErrors || hasWarnings; + + return ( +
+ {/* Diagnostics */} + {diagnostics && ( + + )} + + {/* LSP Status */} + +
+ ); +} + +/** + * Compact version for very limited space + */ +export function DiagnosticsIndicatorCompact({ + onClickDiagnostics, + onClickLSP, + refreshInterval = 30000, + className, +}: DiagnosticsIndicatorProps) { + // Data state + const [diagnostics, setDiagnostics] = useState<{ + errors: number; + warnings: number; + } | null>(null); + const [runningServers, setRunningServers] = useState(0); + + // UI state + const [loading, setLoading] = useState(true); + + // Load data + const loadData = useCallback(async () => { + try { + const [diagResult, serversResult] = await Promise.all([ + getLSPDiagnostics(), + getRunningLSPServers(), + ]); + + if (diagResult.success && isAllFilesResponse(diagResult.data)) { + setDiagnostics({ + errors: diagResult.data.summary.totalErrors, + warnings: diagResult.data.summary.totalWarnings, + }); + } + + if (serversResult.success) { + setRunningServers(serversResult.data.length); + } + } catch { + // Silently ignore errors + } finally { + setLoading(false); + } + }, []); + + // Initial load and refresh interval + useEffect(() => { + loadData(); + + const interval = setInterval(loadData, refreshInterval); + return () => clearInterval(interval); + }, [loadData, refreshInterval]); + + // No data yet + if (loading) { + return null; + } + + const hasErrors = diagnostics && diagnostics.errors > 0; + const hasWarnings = diagnostics && diagnostics.warnings > 0; + + return ( +
+ {/* Combined indicator */} + +
+ ); +} diff --git a/packages/ui/src/components/DiagnosticsPanel.tsx b/packages/ui/src/components/DiagnosticsPanel.tsx new file mode 100644 index 0000000..5ba8c51 --- /dev/null +++ b/packages/ui/src/components/DiagnosticsPanel.tsx @@ -0,0 +1,568 @@ +/** + * DiagnosticsPanel Component + * + * Display detailed diagnostics information from LSP servers + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + X, + RefreshCw, + AlertCircle, + AlertTriangle, + Info, + Lightbulb, + ChevronDown, + ChevronRight, + FileText, + Search, + Filter, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Input } from '../primitives/Input'; +import { Skeleton } from './Skeleton'; +import { + getLSPDiagnostics, + type FileDiagnostic, + type DiagnosticsResponse, + type AllFilesDiagnosticsResponse, +} from '../api/client.js'; + +interface DiagnosticsPanelProps { + onClose: () => void; + /** Callback when a file is clicked */ + onFileClick?: (file: string, line?: number) => void; + /** Enable responsive layout */ + responsive?: boolean; +} + +type SeverityFilter = 'all' | 'error' | 'warning' | 'info' | 'hint'; + +/** Check if diagnostics is all files response */ +function isAllFilesResponse(data: DiagnosticsResponse): data is AllFilesDiagnosticsResponse { + return 'files' in data; +} + +/** Get severity icon */ +function SeverityIcon({ severity, size = 14 }: { severity: FileDiagnostic['severity']; size?: number }) { + switch (severity) { + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + case 'hint': + return ; + default: + return ; + } +} + +/** Get severity color class */ +function getSeverityColor(severity: FileDiagnostic['severity']): string { + switch (severity) { + case 'error': + return 'text-red-400'; + case 'warning': + return 'text-amber-400'; + case 'info': + return 'text-blue-400'; + case 'hint': + return 'text-fg-muted'; + default: + return 'text-fg-subtle'; + } +} + +export function DiagnosticsPanel({ onClose, onFileClick, responsive = false }: DiagnosticsPanelProps) { + // Data state + const [files, setFiles] = useState>([]); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [summary, setSummary] = useState<{ + totalErrors: number; + totalWarnings: number; + totalFiles: number; + } | null>(null); + + // Filter state + const [searchQuery, setSearchQuery] = useState(''); + const [severityFilter, setSeverityFilter] = useState('all'); + const [showFilterMenu, setShowFilterMenu] = useState(false); + + // UI state + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + // Load diagnostics + const loadDiagnostics = useCallback(async (showToast = false) => { + try { + const result = await getLSPDiagnostics(); + if (result.success && isAllFilesResponse(result.data)) { + setFiles(result.data.files); + setSummary({ + totalErrors: result.data.summary.totalErrors, + totalWarnings: result.data.summary.totalWarnings, + totalFiles: result.data.summary.totalFiles || result.data.files.length, + }); + // Auto-expand files with errors + const filesWithErrors = result.data.files + .filter((f) => f.diagnostics.some((d) => d.severity === 'error')) + .map((f) => f.file); + setExpandedFiles(new Set(filesWithErrors)); + if (showToast) { + toast.success('Diagnostics refreshed'); + } + } else { + toast.error(result.error || 'Failed to load diagnostics'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load diagnostics'); + } + }, []); + + // Initial load + useEffect(() => { + setLoading(true); + loadDiagnostics().finally(() => setLoading(false)); + }, [loadDiagnostics]); + + // Refresh + const handleRefresh = async () => { + setRefreshing(true); + await loadDiagnostics(true); + setRefreshing(false); + }; + + // Toggle expanded + const toggleExpanded = (file: string) => { + const newExpanded = new Set(expandedFiles); + if (newExpanded.has(file)) { + newExpanded.delete(file); + } else { + newExpanded.add(file); + } + setExpandedFiles(newExpanded); + }; + + // Filtered files + const filteredFiles = useMemo(() => { + return files + .map((f) => ({ + ...f, + diagnostics: f.diagnostics.filter((d) => { + // Severity filter + if (severityFilter !== 'all' && d.severity !== severityFilter) { + return false; + } + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + d.message.toLowerCase().includes(query) || + f.file.toLowerCase().includes(query) || + (d.source && d.source.toLowerCase().includes(query)) + ); + } + return true; + }), + })) + .filter((f) => { + // File name search + if (searchQuery && !f.file.toLowerCase().includes(searchQuery.toLowerCase())) { + return f.diagnostics.length > 0; + } + return f.diagnostics.length > 0; + }); + }, [files, searchQuery, severityFilter]); + + // Filtered summary + const filteredSummary = useMemo(() => { + const errors = filteredFiles.reduce( + (sum, f) => sum + f.diagnostics.filter((d) => d.severity === 'error').length, + 0 + ); + const warnings = filteredFiles.reduce( + (sum, f) => sum + f.diagnostics.filter((d) => d.severity === 'warning').length, + 0 + ); + return { errors, warnings, files: filteredFiles.length }; + }, [filteredFiles]); + + // Loading skeleton + const LoadingSkeleton = () => ( +
+ {[1, 2, 3].map((i) => ( +
+
+ + + +
+
+ + +
+
+ ))} +
+ ); + + // File item component + const FileItem = ({ file, diagnostics }: { file: string; diagnostics: FileDiagnostic[] }) => { + const isExpanded = expandedFiles.has(file); + const errorCount = diagnostics.filter((d) => d.severity === 'error').length; + const warningCount = diagnostics.filter((d) => d.severity === 'warning').length; + + // Get file name from path + const fileName = file.split('/').pop() || file; + const filePath = file.split('/').slice(0, -1).join('/'); + + return ( + + {/* File Header */} +
toggleExpanded(file)} + > + {/* Expand Icon */} + + + {/* File Icon with severity */} + {errorCount > 0 ? ( + + ) : warningCount > 0 ? ( + + ) : ( + + )} + + {/* File Info */} +
{ + e.stopPropagation(); + onFileClick?.(file); + }} + > +
+ {fileName} + {filePath && ({filePath})} +
+
+ + {/* Counts */} +
+ {errorCount > 0 && ( + + + {errorCount} + + )} + {warningCount > 0 && ( + + + {warningCount} + + )} +
+
+ + {/* Diagnostics List */} + + {isExpanded && ( + +
+ {diagnostics.map((diagnostic, idx) => ( +
onFileClick?.(file, diagnostic.line)} + > + {/* Severity Icon */} + + + {/* Line/Column */} + + {diagnostic.line}:{diagnostic.column} + + + {/* Message */} + + {diagnostic.message} + + + {/* Source & Code */} +
+ {diagnostic.source && ( + {diagnostic.source} + )} + {diagnostic.code && ( + + {diagnostic.code} + + )} +
+
+ ))} +
+
+ )} +
+
+ ); + }; + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-3xl md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-3xl mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Diagnostics +

+ {summary && ( +

+ {summary.totalErrors} errors, {summary.totalWarnings} warnings in{' '} + {summary.totalFiles} files +

+ )} +
+
+ + +
+
+ + {/* Search & Filter */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search diagnostics..." + className="pl-9" + /> +
+ + {/* Filter */} +
+ + + {/* Filter Menu */} + + {showFilterMenu && ( + + {(['all', 'error', 'warning', 'info', 'hint'] as SeverityFilter[]).map( + (filter) => ( + + ) + )} + + )} + +
+
+ + {/* Filtered Summary */} + {(searchQuery || severityFilter !== 'all') && ( +
+ Showing: + {filteredSummary.errors} errors + {filteredSummary.warnings} warnings + {filteredSummary.files} files + {(searchQuery || severityFilter !== 'all') && ( + + )} +
+ )} + + {/* Diagnostics List */} +
+ {loading ? ( + + ) : filteredFiles.length === 0 ? ( +
+ {files.length === 0 ? ( + <> + +

No diagnostics available

+

+ Start a language server to see diagnostics +

+ + ) : ( + <> + +

No matching diagnostics

+ + + )} +
+ ) : ( + + {filteredFiles.map((f) => ( + + ))} + + )} +
+ + {/* Footer */} +
+ Click on a diagnostic to navigate to the source location +
+ + + + ); +} diff --git a/packages/ui/src/components/LSPPanel.tsx b/packages/ui/src/components/LSPPanel.tsx new file mode 100644 index 0000000..4dd4fdf --- /dev/null +++ b/packages/ui/src/components/LSPPanel.tsx @@ -0,0 +1,610 @@ +/** + * LSPPanel Component + * + * LSP server management panel: list servers, install/start/stop, view diagnostics + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + X, + RefreshCw, + Server, + Play, + Square, + Download, + ChevronDown, + ChevronRight, + AlertCircle, + AlertTriangle, + Info, + Loader2, + Code, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { cn } from '../utils/cn'; +import { modalOverlay, modalContent, smoothTransition } from '../utils/animations'; +import { Button } from '../primitives/Button'; +import { Input } from '../primitives/Input'; +import { Skeleton } from './Skeleton'; +import { + listLSPServers, + installLSPServer, + startLSPServer, + stopLSPServer, + getLSPDiagnostics, + type LSPServer, + type DiagnosticsResponse, + type AllFilesDiagnosticsResponse, +} from '../api/client.js'; + +interface LSPPanelProps { + onClose: () => void; + /** Callback to open diagnostics panel */ + onOpenDiagnostics?: () => void; + /** Enable responsive layout */ + responsive?: boolean; +} + +/** Check if diagnostics is all files response */ +function isAllFilesResponse(data: DiagnosticsResponse): data is AllFilesDiagnosticsResponse { + return 'files' in data; +} + +export function LSPPanel({ onClose, onOpenDiagnostics, responsive = false }: LSPPanelProps) { + // Data state + const [servers, setServers] = useState([]); + const [expandedServers, setExpandedServers] = useState>(new Set()); + const [diagnosticsSummary, setDiagnosticsSummary] = useState<{ + totalErrors: number; + totalWarnings: number; + totalFiles: number; + } | null>(null); + + // UI state + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [installingServer, setInstallingServer] = useState(null); + const [startingServer, setStartingServer] = useState(null); + const [stoppingServer, setStoppingServer] = useState(null); + + // Start server dialog + const [showStartDialog, setShowStartDialog] = useState(null); + const [startFilePath, setStartFilePath] = useState(''); + + // Load server list + const loadServers = useCallback(async (showToast = false) => { + try { + const result = await listLSPServers(); + if (result.success) { + setServers(result.data); + if (showToast) { + toast.success('Servers refreshed'); + } + } else { + toast.error(result.error || 'Failed to load servers'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load servers'); + } + }, []); + + // Load diagnostics summary + const loadDiagnosticsSummary = useCallback(async () => { + try { + const result = await getLSPDiagnostics(); + if (result.success && isAllFilesResponse(result.data)) { + setDiagnosticsSummary({ + totalErrors: result.data.summary.totalErrors, + totalWarnings: result.data.summary.totalWarnings, + totalFiles: result.data.summary.totalFiles || 0, + }); + } + } catch { + // Silently ignore diagnostics errors + } + }, []); + + // Initial load + useEffect(() => { + setLoading(true); + Promise.all([loadServers(), loadDiagnosticsSummary()]).finally(() => setLoading(false)); + }, [loadServers, loadDiagnosticsSummary]); + + // Refresh + const handleRefresh = async () => { + setRefreshing(true); + await Promise.all([loadServers(true), loadDiagnosticsSummary()]); + setRefreshing(false); + }; + + // Toggle expanded + const toggleExpanded = (id: string) => { + const newExpanded = new Set(expandedServers); + if (newExpanded.has(id)) { + newExpanded.delete(id); + } else { + newExpanded.add(id); + } + setExpandedServers(newExpanded); + }; + + // Install server + const handleInstall = async (id: string) => { + setInstallingServer(id); + try { + const result = await installLSPServer(id); + if (result.success) { + toast.success(result.data?.message || `Server ${id} installed`); + await loadServers(); + } else { + toast.error(result.error || 'Installation failed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Installation failed'); + } finally { + setInstallingServer(null); + } + }; + + // Start server + const handleStart = async (id: string) => { + if (!startFilePath) { + toast.error('Please provide a file path'); + return; + } + + setStartingServer(id); + try { + const result = await startLSPServer(id, startFilePath); + if (result.success) { + toast.success(result.data?.message || `Server ${id} started`); + setShowStartDialog(null); + setStartFilePath(''); + await loadServers(); + // Refresh diagnostics after a short delay + setTimeout(() => loadDiagnosticsSummary(), 1000); + } else { + toast.error(result.error || 'Start failed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Start failed'); + } finally { + setStartingServer(null); + } + }; + + // Stop server + const handleStop = async (id: string) => { + setStoppingServer(id); + try { + const result = await stopLSPServer(id); + if (result.success) { + toast.success(result.data?.message || `Server ${id} stopped`); + await loadServers(); + } else { + toast.error(result.error || 'Stop failed'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Stop failed'); + } finally { + setStoppingServer(null); + } + }; + + // Statistics + const runningServers = servers.filter((s) => s.running); + const installedServers = servers.filter((s) => s.installed && !s.running); + const notInstalledServers = servers.filter((s) => !s.installed); + + // Loading skeleton + const LoadingSkeleton = () => ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ); + + // Server item component + const ServerItem = ({ server }: { server: LSPServer }) => { + const isExpanded = expandedServers.has(server.id); + const isInstalling = installingServer === server.id; + const isStopping = stoppingServer === server.id; + + // Status color + const statusColor = server.running + ? 'text-green-400' + : server.installed + ? 'text-fg-muted' + : 'text-fg-subtle'; + + // Status icon + const StatusIcon = server.running ? ( + + ) : server.installed ? ( + + ) : ( + + ); + + return ( + + {/* Server Header */} +
toggleExpanded(server.id)} + > + {/* Expand Icon */} + + + {/* Status Indicator */} + {StatusIcon} + + {/* Icon */} + + + {/* Info */} +
+
+ {server.displayName} + ({server.id}) +
+
+ + {server.languages.join(', ')} +
+
+ + {/* Status & Actions */} +
e.stopPropagation()}> + {/* Status Badge */} + + {server.running ? 'Running' : server.installed ? 'Installed' : 'Not Installed'} + + + {/* Action Button */} + {server.running ? ( + + ) : server.installed ? ( + + ) : ( + + )} +
+
+ + {/* Expanded Content */} + + {isExpanded && ( + +
+ {/* Languages */} +
+ Languages:{' '} + {server.languages.join(', ')} +
+ + {/* Install Command */} + {server.installCommand && ( +
+ Install:{' '} + + {server.installCommand} + +
+ )} + + {/* Status Details */} +
+ Status:{' '} + + {server.running + ? 'Running' + : server.installed + ? 'Installed, not running' + : 'Not installed'} + +
+
+
+ )} +
+
+ ); + }; + + return ( + + + e.stopPropagation()} + className={cn( + 'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col', + responsive + ? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg' + : 'rounded-lg w-full max-w-2xl mx-4' + )} + > + {/* Header */} +
+ {responsive && ( +
+ )} +
+

+ + Language Servers +

+

+ {servers.length} servers ({runningServers.length} running) +

+
+
+ + +
+
+ + {/* Server List */} +
+ {loading ? ( + + ) : servers.length === 0 ? ( +
+ +

No language servers available

+
+ ) : ( + + {/* Running Servers */} + {runningServers.length > 0 && ( +
+

+ + Running ({runningServers.length}) +

+
+ {runningServers.map((server) => ( + + ))} +
+
+ )} + + {/* Installed Servers */} + {installedServers.length > 0 && ( +
+

+ + Installed ({installedServers.length}) +

+
+ {installedServers.map((server) => ( + + ))} +
+
+ )} + + {/* Not Installed Servers */} + {notInstalledServers.length > 0 && ( +
+

+ + Available ({notInstalledServers.length}) +

+
+ {notInstalledServers.map((server) => ( + + ))} +
+
+ )} +
+ )} +
+ + {/* Diagnostics Summary Footer */} +
+ {diagnosticsSummary ? ( +
+
+ Diagnostics: + + + {diagnosticsSummary.totalErrors} errors + + + + {diagnosticsSummary.totalWarnings} warnings + + + + {diagnosticsSummary.totalFiles} files + +
+ {onOpenDiagnostics && ( + + )} +
+ ) : ( +
No diagnostics available
+ )} +
+ + + {/* Start Server Dialog */} + + {showStartDialog && ( + setShowStartDialog(null)} + > + e.stopPropagation()} + className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4" + > +

Start Language Server

+

+ Provide a file path to start the language server for{' '} + {showStartDialog} +

+
+
+ + setStartFilePath(e.target.value)} + placeholder="src/index.ts" + onKeyDown={(e) => { + if (e.key === 'Enter' && showStartDialog) { + handleStart(showStartDialog); + } + }} + /> +

+ Enter a file path relative to the project root +

+
+
+
+ + +
+
+
+ )} +
+ + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b3d8ec9..3c10ea3 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -95,6 +95,14 @@ export { compressContext, // File Search API searchFiles, + // LSP API + listLSPServers, + getLSPServer, + installLSPServer, + startLSPServer, + stopLSPServer, + getRunningLSPServers, + getLSPDiagnostics, } from './api/client.js'; // Types @@ -180,6 +188,13 @@ export type { QuestionOption, Question, QuestionMessagePart, + // LSP types + LSPServer, + FileDiagnostic, + DiagnosticsSummary, + SingleFileDiagnosticsResponse, + AllFilesDiagnosticsResponse, + DiagnosticsResponse, } from './api/client.js'; // Primitives (shadcn/ui style) @@ -227,6 +242,9 @@ export { Markdown } from './components/Markdown.js'; export { CodeBlock, InlineCode } from './components/CodeBlock.js'; export { SubagentProgress, SubagentProgressCompact } from './components/SubagentProgress.js'; 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'; // Toast function (re-export from sonner) export { toast } from 'sonner'; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index a5d544b..c6a30fe 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -14,6 +14,8 @@ import { AgentsPanel, CheckpointPanel, ProvidersPanel, + LSPPanel, + DiagnosticsPanel, Toaster, ThemeProvider, listSessions, @@ -33,6 +35,8 @@ export function App() { const [showAgents, setShowAgents] = useState(false); const [showCheckpoints, setShowCheckpoints] = useState(false); const [showProviders, setShowProviders] = useState(false); + const [showLSP, setShowLSP] = useState(false); + const [showDiagnostics, setShowDiagnostics] = useState(false); const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null); const [workingDirectory, setWorkingDirectory] = useState(''); @@ -147,6 +151,8 @@ export function App() { onOpenAgents={() => setShowAgents(true)} onOpenCheckpoints={() => setShowCheckpoints(true)} onOpenProviders={() => setShowProviders(true)} + onOpenLSP={() => setShowLSP(true)} + onOpenDiagnostics={() => setShowDiagnostics(true)} workingDirectory={workingDirectory} /> ) : ( @@ -211,6 +217,30 @@ export function App() { {/* Providers 面板 */} {showProviders && setShowProviders(false)} responsive />} + {/* LSP 面板 */} + {showLSP && ( + setShowLSP(false)} + onOpenDiagnostics={() => { + setShowLSP(false); + setShowDiagnostics(true); + }} + responsive + /> + )} + + {/* Diagnostics 面板 */} + {showDiagnostics && ( + setShowDiagnostics(false)} + onFileClick={(file, line) => { + console.log('Navigate to:', file, line); + // TODO: Integrate with file browser or editor + }} + responsive + /> + )} + {/* 移动端底部文件按钮 */}