feat(ui): 添加底部状态栏和优化代码编辑器空状态
- 新增 StatusBar 组件,显示 Git 分支、诊断信息和连接状态 - 添加 Git API 端点 (GET /api/files/git) 获取分支和 dirty 状态 - 优化 CodeEditor 空状态,添加图标和引导提示 - 修复 Chat 页面高度问题 (h-screen -> h-full)
This commit is contained in:
@@ -7,8 +7,12 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises';
|
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
import { join, resolve, basename, extname, dirname } from 'node:path';
|
import { join, resolve, basename, extname, dirname } from 'node:path';
|
||||||
|
import { exec } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { searchFiles as coreSearchFiles, type FileIndexEntry } from '@ai-assistant/core';
|
import { searchFiles as coreSearchFiles, type FileIndexEntry } from '@ai-assistant/core';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
const filesRouter = new Hono();
|
const filesRouter = new Hono();
|
||||||
|
|
||||||
// 工作目录 (默认为当前目录)
|
// 工作目录 (默认为当前目录)
|
||||||
@@ -444,4 +448,35 @@ filesRouter.put('/write', async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GET /api/files/git - 获取 Git 信息
|
||||||
|
// ============================================================================
|
||||||
|
filesRouter.get('/git', async (c) => {
|
||||||
|
try {
|
||||||
|
// 获取当前分支
|
||||||
|
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
cwd: workingDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否有未提交的更改
|
||||||
|
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||||
|
cwd: workingDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
branch: branch.trim(),
|
||||||
|
dirty: status.trim().length > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 可能不是 Git 仓库
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export { filesRouter };
|
export { filesRouter };
|
||||||
|
|||||||
@@ -289,6 +289,16 @@ export async function getFileTree(path: string = '.', depth: number = 3): Promis
|
|||||||
return request('GET', `/files/tree?${params}`);
|
return request('GET', `/files/tree?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Git Info
|
||||||
|
export interface GitInfo {
|
||||||
|
branch: string;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitInfo(): Promise<{ success: boolean; data: GitInfo | null }> {
|
||||||
|
return request('GET', '/files/git');
|
||||||
|
}
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
export async function getConfig(): Promise<{ success: boolean; data: ServerConfig }> {
|
export async function getConfig(): Promise<{ success: boolean; data: ServerConfig }> {
|
||||||
return request('GET', '/config');
|
return request('GET', '/config');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { html } from '@codemirror/lang-html';
|
|||||||
import { css } from '@codemirror/lang-css';
|
import { css } from '@codemirror/lang-css';
|
||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { X, Save, Circle } from 'lucide-react';
|
import { X, Save, Circle, FileCode, MousePointerClick } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '../utils/cn.js';
|
import { cn } from '../utils/cn.js';
|
||||||
@@ -156,8 +156,25 @@ export function CodeEditor({
|
|||||||
|
|
||||||
if (tabs.length === 0) {
|
if (tabs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-center h-full bg-surface-base text-fg-muted', className)}>
|
<div className={cn('flex flex-col items-center justify-center h-full bg-surface-base', className)}>
|
||||||
<p>No files open</p>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="text-center max-w-xs"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-surface-subtle flex items-center justify-center">
|
||||||
|
<FileCode size={32} className="text-fg-subtle" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-fg font-medium mb-2">No files open</h3>
|
||||||
|
<p className="text-fg-muted text-sm mb-4">
|
||||||
|
Select a file from the explorer to view and edit its contents
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-fg-subtle">
|
||||||
|
<MousePointerClick size={14} />
|
||||||
|
<span>Click a file to open</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* StatusBar Component
|
||||||
|
*
|
||||||
|
* 底部状态栏,显示 Git 分支、诊断信息、连接状态等
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { GitBranch, AlertTriangle, AlertCircle, Wifi, WifiOff, RefreshCw, CheckCircle } from 'lucide-react';
|
||||||
|
import { cn } from '../utils/cn.js';
|
||||||
|
import { getLSPDiagnostics, getGitInfo, type DiagnosticsSummary, type GitInfo } from '../api/client.js';
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
className?: string;
|
||||||
|
/** 是否连接到服务器 */
|
||||||
|
isConnected?: boolean;
|
||||||
|
/** 点击诊断信息回调 */
|
||||||
|
onDiagnosticsClick?: () => void;
|
||||||
|
/** 刷新间隔 (ms) */
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar({
|
||||||
|
className,
|
||||||
|
isConnected = true,
|
||||||
|
onDiagnosticsClick,
|
||||||
|
refreshInterval = 30000,
|
||||||
|
}: StatusBarProps) {
|
||||||
|
const [diagnostics, setDiagnostics] = useState<DiagnosticsSummary | null>(null);
|
||||||
|
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 加载诊断信息和 Git 信息
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [diagResult, gitResult] = await Promise.all([
|
||||||
|
getLSPDiagnostics(),
|
||||||
|
getGitInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (diagResult.success && diagResult.data.summary) {
|
||||||
|
setDiagnostics(diagResult.data.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gitResult.success && gitResult.data) {
|
||||||
|
setGitInfo(gitResult.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略错误
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始加载和定时刷新
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, refreshInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadData, refreshInterval]);
|
||||||
|
|
||||||
|
const errorCount = diagnostics?.totalErrors ?? 0;
|
||||||
|
const warningCount = diagnostics?.totalWarnings ?? 0;
|
||||||
|
const hasIssues = errorCount > 0 || warningCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between h-6 px-2 text-xs',
|
||||||
|
'bg-surface-emphasis border-t border-line',
|
||||||
|
'text-fg-muted select-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 左侧 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Git 分支 */}
|
||||||
|
{gitInfo && (
|
||||||
|
<div className="flex items-center gap-1 hover:text-fg-secondary cursor-default">
|
||||||
|
<GitBranch size={12} />
|
||||||
|
<span>{gitInfo.branch}</span>
|
||||||
|
{gitInfo.dirty && <span className="text-orange-400">*</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 刷新按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-0.5 hover:text-fg-secondary transition-colors"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={cn(loading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 诊断信息 */}
|
||||||
|
<button
|
||||||
|
onClick={onDiagnosticsClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 hover:text-fg-secondary transition-colors',
|
||||||
|
hasIssues && 'text-fg-secondary'
|
||||||
|
)}
|
||||||
|
title="View diagnostics"
|
||||||
|
>
|
||||||
|
{errorCount > 0 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-red-400">
|
||||||
|
<AlertCircle size={12} />
|
||||||
|
<span>{errorCount}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{warningCount > 0 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-yellow-400">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{warningCount}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!hasIssues && (
|
||||||
|
<span className="flex items-center gap-0.5 text-green-400">
|
||||||
|
<CheckCircle size={12} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 连接状态 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1',
|
||||||
|
isConnected ? 'text-green-400' : 'text-red-400'
|
||||||
|
)}
|
||||||
|
title={isConnected ? 'Connected to server' : 'Disconnected from server'}
|
||||||
|
>
|
||||||
|
{isConnected ? <Wifi size={12} /> : <WifiOff size={12} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export {
|
|||||||
readFile,
|
readFile,
|
||||||
writeFile,
|
writeFile,
|
||||||
getFileTree,
|
getFileTree,
|
||||||
|
getGitInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
// Commands API
|
// Commands API
|
||||||
@@ -117,6 +118,7 @@ export type {
|
|||||||
FileWriteResponse,
|
FileWriteResponse,
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
FileTreeResponse,
|
FileTreeResponse,
|
||||||
|
GitInfo,
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
// Command types
|
// Command types
|
||||||
CommandInfo,
|
CommandInfo,
|
||||||
@@ -251,6 +253,7 @@ export { SessionPanel } from './components/SessionPanel.js';
|
|||||||
export { FileExplorer } from './components/FileExplorer.js';
|
export { FileExplorer } from './components/FileExplorer.js';
|
||||||
export { CodeEditor, getLanguageFromFilename, type EditorTab } from './components/CodeEditor.js';
|
export { CodeEditor, getLanguageFromFilename, type EditorTab } from './components/CodeEditor.js';
|
||||||
export { IDE } from './components/IDE.js';
|
export { IDE } from './components/IDE.js';
|
||||||
|
export { StatusBar } from './components/StatusBar.js';
|
||||||
|
|
||||||
// Toast function (re-export from sonner)
|
// Toast function (re-export from sonner)
|
||||||
export { toast } from 'sonner';
|
export { toast } from 'sonner';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
LSPPanel,
|
LSPPanel,
|
||||||
DiagnosticsPanel,
|
DiagnosticsPanel,
|
||||||
SessionPanel,
|
SessionPanel,
|
||||||
|
StatusBar,
|
||||||
Toaster,
|
Toaster,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
listSessions,
|
listSessions,
|
||||||
@@ -111,9 +112,9 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<div className="h-screen flex bg-surface-base">
|
<div className="h-screen flex flex-col bg-surface-base">
|
||||||
{/* 主内容区域:左侧文件浏览器 + 右侧对话框 */}
|
{/* 主内容区域:左侧文件浏览器 + 右侧对话框 */}
|
||||||
<div className="flex-1 flex min-w-0">
|
<div className="flex-1 flex min-w-0 overflow-hidden">
|
||||||
{/* 左侧:IDE(文件浏览器 + 代码编辑器) */}
|
{/* 左侧:IDE(文件浏览器 + 代码编辑器) */}
|
||||||
<div className="hidden md:flex flex-col border-r border-line w-[50%] lg:w-[60%]">
|
<div className="hidden md:flex flex-col border-r border-line w-[50%] lg:w-[60%]">
|
||||||
<IDE />
|
<IDE />
|
||||||
@@ -147,6 +148,9 @@ export function App() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 底部状态栏 */}
|
||||||
|
<StatusBar onDiagnosticsClick={() => setShowDiagnostics(true)} />
|
||||||
|
|
||||||
{/* 命令面板 */}
|
{/* 命令面板 */}
|
||||||
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function ChatPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-screen">
|
<div className="flex-1 flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* 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 justify-between px-4 md:px-6 py-3 border-b border-line bg-surface-subtle">
|
||||||
<h1 className="text-lg font-medium text-fg">Chat</h1>
|
<h1 className="text-lg font-medium text-fg">Chat</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user