feat(ui): 实现 LSP UI 集成
- 添加 LSP 相关类型定义 (LSPServer, FileDiagnostic, DiagnosticsResponse) - 添加 LSP API 函数 (listLSPServers, installLSPServer, startLSPServer 等) - 创建 LSPPanel 组件: 语言服务器管理面板 - 创建 DiagnosticsPanel 组件: 诊断详情面板 (含搜索过滤) - 创建 DiagnosticsIndicator 组件: 状态栏指示器 - 集成到 Web 模块 (App.tsx, Chat.tsx)
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<number>(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 (
|
||||
<div className={cn('flex items-center gap-2 text-fg-subtle', className)}>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasErrors = diagnostics && diagnostics.errors > 0;
|
||||
const hasWarnings = diagnostics && diagnostics.warnings > 0;
|
||||
const hasIssues = hasErrors || hasWarnings;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{/* Diagnostics */}
|
||||
{diagnostics && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClickDiagnostics}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1 h-auto min-h-0',
|
||||
'hover:bg-surface-base/50 transition-colors'
|
||||
)}
|
||||
title="View Diagnostics"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{hasErrors && (
|
||||
<motion.span
|
||||
key="errors"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="flex items-center gap-1 text-red-400"
|
||||
>
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-xs font-medium">{diagnostics.errors}</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{hasWarnings && (
|
||||
<motion.span
|
||||
key="warnings"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="flex items-center gap-1 text-amber-400"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
<span className="text-xs font-medium">{diagnostics.warnings}</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!hasIssues && (
|
||||
<span className="flex items-center gap-1 text-green-400">
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-xs">0</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* LSP Status */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClickLSP}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 h-auto min-h-0',
|
||||
'hover:bg-surface-base/50 transition-colors',
|
||||
runningServers > 0 ? 'text-green-400' : 'text-fg-subtle'
|
||||
)}
|
||||
title={`${runningServers} Language Server${runningServers !== 1 ? 's' : ''} Running`}
|
||||
>
|
||||
<Server size={14} />
|
||||
{runningServers > 0 && <span className="text-xs font-medium">{runningServers}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number>(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 (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{/* Combined indicator */}
|
||||
<button
|
||||
onClick={onClickDiagnostics || onClickLSP}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
|
||||
'hover:bg-surface-base/50 transition-colors',
|
||||
hasErrors
|
||||
? 'text-red-400'
|
||||
: hasWarnings
|
||||
? 'text-amber-400'
|
||||
: runningServers > 0
|
||||
? 'text-green-400'
|
||||
: 'text-fg-subtle'
|
||||
)}
|
||||
title={`${diagnostics?.errors || 0} errors, ${diagnostics?.warnings || 0} warnings, ${runningServers} LSP servers`}
|
||||
>
|
||||
{hasErrors ? (
|
||||
<AlertCircle size={12} />
|
||||
) : hasWarnings ? (
|
||||
<AlertTriangle size={12} />
|
||||
) : (
|
||||
<Server size={12} />
|
||||
)}
|
||||
{(hasErrors || hasWarnings) && (
|
||||
<span className="font-medium">
|
||||
{(diagnostics?.errors || 0) + (diagnostics?.warnings || 0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <AlertCircle size={size} className="text-red-400" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle size={size} className="text-amber-400" />;
|
||||
case 'info':
|
||||
return <Info size={size} className="text-blue-400" />;
|
||||
case 'hint':
|
||||
return <Lightbulb size={size} className="text-fg-muted" />;
|
||||
default:
|
||||
return <Info size={size} className="text-fg-subtle" />;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<Array<{ file: string; diagnostics: FileDiagnostic[] }>>([]);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
const [summary, setSummary] = useState<{
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
totalFiles: number;
|
||||
} | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('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 = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</div>
|
||||
<div className="ml-6 space-y-1">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-surface-base/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* File Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3',
|
||||
'hover:bg-surface-base/80 transition-colors cursor-pointer'
|
||||
)}
|
||||
onClick={() => toggleExpanded(file)}
|
||||
>
|
||||
{/* Expand Icon */}
|
||||
<button className="text-fg-subtle hover:text-fg-secondary">
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{/* File Icon with severity */}
|
||||
{errorCount > 0 ? (
|
||||
<AlertCircle size={16} className="text-red-400" />
|
||||
) : warningCount > 0 ? (
|
||||
<AlertTriangle size={16} className="text-amber-400" />
|
||||
) : (
|
||||
<FileText size={16} className="text-fg-muted" />
|
||||
)}
|
||||
|
||||
{/* File Info */}
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileClick?.(file);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-fg-secondary truncate">{fileName}</span>
|
||||
{filePath && <span className="text-xs text-fg-subtle truncate">({filePath})</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Counts */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{errorCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-400">
|
||||
<AlertCircle size={12} />
|
||||
{errorCount}
|
||||
</span>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber-400">
|
||||
<AlertTriangle size={12} />
|
||||
{warningCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics List */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-line/50">
|
||||
{diagnostics.map((diagnostic, idx) => (
|
||||
<div
|
||||
key={`${diagnostic.line}-${diagnostic.column}-${idx}`}
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-4 py-2 text-sm',
|
||||
'hover:bg-surface-base/60 cursor-pointer transition-colors',
|
||||
idx < diagnostics.length - 1 && 'border-b border-line/30'
|
||||
)}
|
||||
onClick={() => onFileClick?.(file, diagnostic.line)}
|
||||
>
|
||||
{/* Severity Icon */}
|
||||
<SeverityIcon severity={diagnostic.severity} size={14} />
|
||||
|
||||
{/* Line/Column */}
|
||||
<span className="text-xs text-fg-subtle whitespace-nowrap font-mono">
|
||||
{diagnostic.line}:{diagnostic.column}
|
||||
</span>
|
||||
|
||||
{/* Message */}
|
||||
<span className={cn('flex-1', getSeverityColor(diagnostic.severity))}>
|
||||
{diagnostic.message}
|
||||
</span>
|
||||
|
||||
{/* Source & Code */}
|
||||
<div className="flex items-center gap-2 text-xs text-fg-subtle">
|
||||
{diagnostic.source && (
|
||||
<span className="bg-surface-subtle px-1 rounded">{diagnostic.source}</span>
|
||||
)}
|
||||
{diagnostic.code && (
|
||||
<span className="bg-surface-subtle px-1 rounded font-mono">
|
||||
{diagnostic.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={(e) => 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-line',
|
||||
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||
)}
|
||||
>
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<AlertCircle size={20} className="text-red-400" />
|
||||
Diagnostics
|
||||
</h2>
|
||||
{summary && (
|
||||
<p className="text-xs text-fg-subtle">
|
||||
{summary.totalErrors} errors, {summary.totalWarnings} warnings in{' '}
|
||||
{summary.totalFiles} files
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh"
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 border-b border-line',
|
||||
responsive ? 'px-4 py-3' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-subtle" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search diagnostics..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
severityFilter !== 'all' && 'text-primary-400'
|
||||
)}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span className="hidden sm:inline">
|
||||
{severityFilter === 'all' ? 'All' : severityFilter}
|
||||
</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
|
||||
{/* Filter Menu */}
|
||||
<AnimatePresence>
|
||||
{showFilterMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute right-0 top-full mt-1 bg-surface-base rounded-lg shadow-lg border border-line z-10 py-1 min-w-[120px]"
|
||||
>
|
||||
{(['all', 'error', 'warning', 'info', 'hint'] as SeverityFilter[]).map(
|
||||
(filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => {
|
||||
setSeverityFilter(filter);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-sm flex items-center gap-2',
|
||||
'hover:bg-surface-subtle transition-colors',
|
||||
severityFilter === filter && 'text-primary-400 bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
{filter === 'all' ? (
|
||||
<span className="w-4" />
|
||||
) : (
|
||||
<SeverityIcon severity={filter} size={14} />
|
||||
)}
|
||||
<span className="capitalize">{filter}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtered Summary */}
|
||||
{(searchQuery || severityFilter !== 'all') && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-4 text-xs text-fg-subtle border-b border-line',
|
||||
responsive ? 'px-4 py-2' : 'px-6 py-2'
|
||||
)}
|
||||
>
|
||||
<span>Showing:</span>
|
||||
<span className="text-red-400">{filteredSummary.errors} errors</span>
|
||||
<span className="text-amber-400">{filteredSummary.warnings} warnings</span>
|
||||
<span>{filteredSummary.files} files</span>
|
||||
{(searchQuery || severityFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSeverityFilter('all');
|
||||
}}
|
||||
className="text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnostics List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
|
||||
{files.length === 0 ? (
|
||||
<>
|
||||
<AlertCircle size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No diagnostics available</p>
|
||||
<p className="text-xs text-center mt-2">
|
||||
Start a language server to see diagnostics
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No matching diagnostics</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSeverityFilter('all');
|
||||
}}
|
||||
className="text-primary-400 hover:text-primary-300 mt-2"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
||||
>
|
||||
{filteredFiles.map((f) => (
|
||||
<FileItem key={f.file} file={f.file} diagnostics={f.diagnostics} />
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-line text-xs text-fg-subtle text-center',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
Click on a diagnostic to navigate to the source location
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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<LSPServer[]>([]);
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(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<string | null>(null);
|
||||
const [startingServer, setStartingServer] = useState<string | null>(null);
|
||||
const [stoppingServer, setStoppingServer] = useState<string | null>(null);
|
||||
|
||||
// Start server dialog
|
||||
const [showStartDialog, setShowStartDialog] = useState<string | null>(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 = () => (
|
||||
<div className="space-y-3 p-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 ? (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
) : server.installed ? (
|
||||
<span className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
) : (
|
||||
<span className="w-2 h-2 rounded-full bg-gray-700" />
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-surface-base/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Server Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3',
|
||||
'hover:bg-surface-base/80 transition-colors cursor-pointer'
|
||||
)}
|
||||
onClick={() => toggleExpanded(server.id)}
|
||||
>
|
||||
{/* Expand Icon */}
|
||||
<button className="text-fg-subtle hover:text-fg-secondary">
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Status Indicator */}
|
||||
{StatusIcon}
|
||||
|
||||
{/* Icon */}
|
||||
<Server size={16} className={statusColor} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-fg-secondary">{server.displayName}</span>
|
||||
<span className="text-xs text-fg-subtle">({server.id})</span>
|
||||
</div>
|
||||
<div className="text-xs text-fg-subtle flex items-center gap-2">
|
||||
<Code size={10} />
|
||||
<span>{server.languages.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & Actions */}
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
server.running
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: server.installed
|
||||
? 'bg-gray-500/20 text-gray-400'
|
||||
: 'bg-gray-700/20 text-gray-500'
|
||||
)}
|
||||
>
|
||||
{server.running ? 'Running' : server.installed ? 'Installed' : 'Not Installed'}
|
||||
</span>
|
||||
|
||||
{/* Action Button */}
|
||||
{server.running ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStop(server.id)}
|
||||
disabled={isStopping}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
title="Stop Server"
|
||||
>
|
||||
{isStopping ? <Loader2 size={14} className="animate-spin" /> : <Square size={14} />}
|
||||
</Button>
|
||||
) : server.installed ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStartDialog(server.id)}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
title="Start Server"
|
||||
>
|
||||
<Play size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleInstall(server.id)}
|
||||
disabled={isInstalling}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
title="Install Server"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-3 pt-1 border-t border-line/50 space-y-2">
|
||||
{/* Languages */}
|
||||
<div className="text-xs">
|
||||
<span className="text-fg-muted">Languages:</span>{' '}
|
||||
<span className="text-fg-secondary">{server.languages.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
{/* Install Command */}
|
||||
{server.installCommand && (
|
||||
<div className="text-xs">
|
||||
<span className="text-fg-muted">Install:</span>{' '}
|
||||
<code className="text-fg-secondary bg-surface-subtle px-1 rounded text-[10px]">
|
||||
{server.installCommand}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Details */}
|
||||
<div className="text-xs">
|
||||
<span className="text-fg-muted">Status:</span>{' '}
|
||||
<span className={statusColor}>
|
||||
{server.running
|
||||
? 'Running'
|
||||
: server.installed
|
||||
? 'Installed, not running'
|
||||
: 'Not installed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
variants={modalOverlay}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/50 flex z-50',
|
||||
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
variants={modalContent}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={smoothTransition}
|
||||
onClick={(e) => 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-line',
|
||||
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||
)}
|
||||
>
|
||||
{responsive && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
|
||||
)}
|
||||
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Server size={20} className="text-primary-400" />
|
||||
Language Servers
|
||||
</h2>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
{servers.length} servers ({runningServers.length} running)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh"
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<LoadingSkeleton />
|
||||
) : servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
|
||||
<Server size={48} className="mb-4 opacity-50" />
|
||||
<p className="text-center">No language servers available</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className={cn('space-y-4', responsive ? 'p-4' : 'p-4')}
|
||||
>
|
||||
{/* Running Servers */}
|
||||
{runningServers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Running ({runningServers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{runningServers.map((server) => (
|
||||
<ServerItem key={server.id} server={server} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installed Servers */}
|
||||
{installedServers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-500" />
|
||||
Installed ({installedServers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{installedServers.map((server) => (
|
||||
<ServerItem key={server.id} server={server} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not Installed Servers */}
|
||||
{notInstalledServers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-700" />
|
||||
Available ({notInstalledServers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{notInstalledServers.map((server) => (
|
||||
<ServerItem key={server.id} server={server} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnostics Summary Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-line',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
{diagnosticsSummary ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-fg-muted">Diagnostics:</span>
|
||||
<span className="flex items-center gap-1 text-red-400">
|
||||
<AlertCircle size={14} />
|
||||
{diagnosticsSummary.totalErrors} errors
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-amber-400">
|
||||
<AlertTriangle size={14} />
|
||||
{diagnosticsSummary.totalWarnings} warnings
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-fg-subtle">
|
||||
<Info size={14} />
|
||||
{diagnosticsSummary.totalFiles} files
|
||||
</span>
|
||||
</div>
|
||||
{onOpenDiagnostics && (
|
||||
<Button variant="ghost" size="sm" onClick={onOpenDiagnostics}>
|
||||
View All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-fg-subtle">No diagnostics available</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Start Server Dialog */}
|
||||
<AnimatePresence>
|
||||
{showStartDialog && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-60"
|
||||
onClick={() => setShowStartDialog(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">Start Language Server</h3>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Provide a file path to start the language server for{' '}
|
||||
<span className="text-fg-secondary font-medium">{showStartDialog}</span>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-fg-muted">File Path</label>
|
||||
<Input
|
||||
value={startFilePath}
|
||||
onChange={(e) => setStartFilePath(e.target.value)}
|
||||
placeholder="src/index.ts"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && showStartDialog) {
|
||||
handleStart(showStartDialog);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle mt-1">
|
||||
Enter a file path relative to the project root
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={() => setShowStartDialog(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => showStartDialog && handleStart(showStartDialog)}
|
||||
disabled={startingServer === showStartDialog || !startFilePath}
|
||||
>
|
||||
{startingServer === showStartDialog ? (
|
||||
<Loader2 size={14} className="animate-spin mr-2" />
|
||||
) : (
|
||||
<Play size={14} className="mr-2" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string>('');
|
||||
|
||||
@@ -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 && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
|
||||
|
||||
{/* LSP 面板 */}
|
||||
{showLSP && (
|
||||
<LSPPanel
|
||||
onClose={() => setShowLSP(false)}
|
||||
onOpenDiagnostics={() => {
|
||||
setShowLSP(false);
|
||||
setShowDiagnostics(true);
|
||||
}}
|
||||
responsive
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Diagnostics 面板 */}
|
||||
{showDiagnostics && (
|
||||
<DiagnosticsPanel
|
||||
onClose={() => setShowDiagnostics(false)}
|
||||
onFileClick={(file, line) => {
|
||||
console.log('Navigate to:', file, line);
|
||||
// TODO: Integrate with file browser or editor
|
||||
}}
|
||||
responsive
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 移动端底部文件按钮 */}
|
||||
<button
|
||||
onClick={() => setShowFileBrowser(true)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
PermissionDialog,
|
||||
ContextUsage,
|
||||
SubagentProgress,
|
||||
DiagnosticsIndicator,
|
||||
} from '@ai-assistant/ui';
|
||||
|
||||
interface ChatPageProps {
|
||||
@@ -30,6 +31,8 @@ interface ChatPageProps {
|
||||
onOpenAgents?: () => void;
|
||||
onOpenCheckpoints?: () => void;
|
||||
onOpenProviders?: () => void;
|
||||
onOpenLSP?: () => void;
|
||||
onOpenDiagnostics?: () => void;
|
||||
// Working Directory
|
||||
workingDirectory?: string;
|
||||
}
|
||||
@@ -47,6 +50,8 @@ export function ChatPage({
|
||||
onOpenAgents,
|
||||
onOpenCheckpoints,
|
||||
onOpenProviders,
|
||||
onOpenLSP,
|
||||
onOpenDiagnostics,
|
||||
workingDirectory,
|
||||
}: ChatPageProps) {
|
||||
const {
|
||||
@@ -184,8 +189,17 @@ export function ChatPage({
|
||||
<ConnectionStatus />
|
||||
|
||||
{/* 工具栏按钮 */}
|
||||
{(onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
|
||||
{(onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders || onOpenLSP || onOpenDiagnostics) && (
|
||||
<div className="flex items-center gap-1.5 border-l border-line-muted pl-3">
|
||||
{/* LSP 诊断指示器 */}
|
||||
{(onOpenLSP || onOpenDiagnostics) && (
|
||||
<DiagnosticsIndicator
|
||||
onClickDiagnostics={onOpenDiagnostics}
|
||||
onClickLSP={onOpenLSP}
|
||||
refreshInterval={30000}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Checkpoints 按钮 */}
|
||||
{onOpenCheckpoints && (
|
||||
<motion.button
|
||||
|
||||
Reference in New Issue
Block a user