第七章:UI组件实现
本章介绍 VSDB 的 UI 层实现,包括 TreeView 连接树和 Webview React 应用。
7.1 UI 层架构
graph TD
subgraph "UI 层"
A["TreeView"]
B["Webview Panels"]
end
subgraph "TreeView"
A1["treeProvider.ts"]
A2["treeItems.ts"]
A3["commands.ts"]
end
subgraph "Webview"
B1["sqlEditor.ts"]
B2["dataGrid.ts"]
B3["connectionForm.ts"]
B4["tableStructure.ts"]
end
A --> A1
A1 --> A2
A1 --> A3
B --> B1
B --> B2
B --> B3
B --> B4
7.2 TreeView 实现
TreeProvider 类
export class TreeProvider implements vscode.TreeDataProvider<TreeItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<TreeItem | undefined>();
onDidChangeTreeData = this._onDidChangeTreeData.event;
constructor(
private connectionManager: ConnectionManager,
private ipcManager: IpcManager,
private historyManager?: HistoryManager,
private bookmarkManager?: BookmarkManager,
) {}
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: TreeItem): vscode.TreeItem {
return element;
}
async getChildren(element?: TreeItem): Promise<TreeItem[]> {
if (!element) {
// 根节点:连接列表
return this.getConnectionNodes();
}
// 根据节点类型获取子节点
if (element instanceof ConnectionNode) {
return this.getDatabaseNodes(element);
}
if (element instanceof DatabaseNode) {
return this.getTableNodes(element);
}
if (element instanceof TableNode) {
return this.getColumnNodes(element);
}
return [];
}
}
树节点类型
// 连接节点
class ConnectionNode extends vscode.TreeItem {
constructor(
public connection: DbConnection,
public state: ConnectionState,
) {
super(connection.name, vscode.TreeItemCollapsibleState.Collapsed);
this.contextValue = `connection-${state.status}`;
this.iconPath = this.getIconPath(state.status);
this.tooltip = `${connection.type}://${connection.host}:${connection.port}`;
}
private getIconPath(status: string): vscode.ThemeIcon {
switch (status) {
case 'connected': return new vscode.ThemeIcon('database', new vscode.ThemeColor('charts.green'));
case 'connecting': return new vscode.ThemeIcon('loading~spin');
case 'error': return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red'));
default: return new vscode.ThemeIcon('database');
}
}
}
// 数据库节点
class DatabaseNode extends vscode.TreeItem {
constructor(
public connectionId: string,
public database: string,
) {
super(database, vscode.TreeItemCollapsibleState.Collapsed);
this.contextValue = 'database';
this.iconPath = new vscode.ThemeIcon('folder');
}
}
// 表节点
class TableNode extends vscode.TreeItem {
constructor(
public connectionId: string,
public database: string,
public table: string,
) {
super(table, vscode.TreeItemCollapsibleState.Collapsed);
this.contextValue = 'table';
this.iconPath = new vscode.ThemeIcon('table');
}
}
// 列节点
class ColumnNode extends vscode.TreeItem {
constructor(
public column: ColumnInfo,
) {
super(column.name, vscode.TreeItemCollapsibleState.None);
this.contextValue = 'column';
this.iconPath = new vscode.ThemeIcon('symbol-field');
this.description = `${column.type}${column.isPrimaryKey ? ' (PK)' : ''}`;
}
}
树节点展开逻辑
graph TD
A["点击连接节点"] --> B{"连接状态"}
B -->|"disconnected"| C["发送 connect 请求"]
C --> D["更新为 connecting"]
D --> E["收到响应"]
E -->|"success"| F["更新为 connected"]
E -->|"error"| G["更新为 error"]
F --> H["查询 databases"]
H --> I["返回 DatabaseNode[]"]
B -->|"connected"| H
自动连接实现
async getChildren(element?: TreeItem): Promise<TreeItem[]> {
if (element instanceof ConnectionNode) {
const conn = element.connection;
// 自动连接
if (element.state.status === 'disconnected') {
await this.ipcManager.sendRequest({
type: 'connect',
connectionId: conn.id,
payload: { config: conn },
});
// 刷新状态
this.refresh();
return []; // 等待刷新后展开
}
// 已连接,查询数据库列表
const response = await this.ipcManager.sendRequest({
type: 'schema',
connectionId: conn.id,
payload: { schemaType: 'databases' },
});
return response.data?.data.map(db =>
new DatabaseNode(conn.id, db.name)
) || [];
}
}
7.3 Webview 架构
Webview Panel 管理
export class SqlEditorPanel {
public static currentPanel: SqlEditorPanel | undefined;
private panel: vscode.WebviewPanel;
private extensionUri: vscode.Uri;
public static createOrShow(
extensionUri: vscode.Uri,
ipcManager: IpcManager,
connectionManager: ConnectionManager,
): SqlEditorPanel {
if (SqlEditorPanel.currentPanel) {
SqlEditorPanel.currentPanel.panel.reveal();
return SqlEditorPanel.currentPanel;
}
const panel = vscode.window.createWebviewPanel(
'vsdb.sqlEditor',
'VSDB SQL Editor',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
}
);
SqlEditorPanel.currentPanel = new SqlEditorPanel(
panel, extensionUri, ipcManager, connectionManager
);
return SqlEditorPanel.currentPanel;
}
}
Webview 与 React 通信
sequenceDiagram
participant VSCode as VSCode Extension
participant Webview as Webview Panel
participant React as React App
VSCode->>Webview: 初始化 HTML + 加载 React bundle
Webview->>React: 启动 React 应用
React->>VSCode: postMessage({ type: 'ready' })
VSCode->>React: postMessage({ type: 'connections', payload: [...] })
Note over React: 用户输入 SQL
React->>VSCode: postMessage({ type: 'executeQuery', payload: { sql } })
VSCode->>VSCode: ipcManager.sendRequest(query)
VSCode->>React: postMessage({ type: 'queryResult', payload: result })
React->>React: 渲染结果表格
VSCode 消息发送
private updateWebview(): void {
const connections = await this.connectionManager.listConnections();
this.panel.webview.postMessage({
type: 'connections',
payload: connections,
});
}
private handleWebviewMessage(message: any): void {
switch (message.type) {
case 'executeQuery':
this.executeQuery(message.payload);
break;
case 'saveQuery':
this.saveQuery(message.payload);
break;
case 'exportData':
this.exportData(message.payload);
break;
}
}
React 消息接收
// SqlEditor.tsx
useEffect(() => {
const handler = (event: MessageEvent) => {
const message = event.data;
switch (message.type) {
case 'connections':
setConnections(message.payload);
break;
case 'queryResult':
setQueryStatus('success');
addResultTab(message.payload);
break;
case 'queryError':
setQueryStatus('error');
addErrorTab(message.payload.message);
break;
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
const executeQuery = () => {
postMessage({
type: 'executeQuery',
payload: { sql, connectionId },
});
};
7.4 SQL Editor 组件
Monaco Editor 配置
<Editor
height="200px"
defaultLanguage="sql"
defaultValue={sql}
theme={monacoTheme}
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
tabSize: 2,
}}
onMount={handleEditorMount}
/>
SQL 自动补全
const handleEditorMount: OnMount = (editor, monaco) => {
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model, position) => {
const keywords = [
'SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE',
'JOIN', 'ON', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT',
// ... 更多关键字
];
const suggestions = keywords.map(kw => ({
label: kw,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: kw,
range,
}));
// 添加表名建议
for (const conn of connections) {
suggestions.push({
label: conn.name,
kind: monaco.languages.CompletionItemKind.Reference,
insertText: conn.name,
range,
}));
}
return { suggestions };
},
});
// Ctrl+Enter 执行快捷键
editor.addAction({
id: 'vsdb-execute-query',
label: 'Execute Query',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
run: () => executeQuery(),
});
};
7.5 DataGrid 组件
虚拟滚动
// 使用 react-window 处理大数据
import { FixedSizeList as List } from 'react-window';
const DataGrid: React.FC<{ rows: any[]; columns: string[] }> = ({ rows, columns }) => {
const Row = ({ index, style }) => (
<div style={style} className="grid-row">
{columns.map(col => (
<div key={col} className="grid-cell">
{rows[index][col]}
</div>
))}
</div>
);
return (
<List
height={400}
itemCount={rows.length}
itemSize={35}
width="100%"
>
{Row}
</List>
);
};
内联编辑
// EditManager 管理编辑状态
class EditManager {
private edits = new Map<string, any>(); // rowIndex -> modifiedData
private editMode: 'readonly' | 'inline' | 'form' = 'readonly';
startEdit(rowIndex: number): void {
this.editMode = 'inline';
}
setValue(rowIndex: number, column: string, value: any): void {
// 记录修改
this.edits.set(`${rowIndex}:${column}`, value);
}
getEdits(): Array<{ row: number; column: string; value: any }> {
return Array.from(this.edits.entries()).map(([key, value]) => {
const [row, col] = key.split(':');
return { row: parseInt(row), column: col, value };
});
}
commit(connectionId: string, database: string, table: string): Promise<void> {
// 生成 UPDATE SQL 并发送
for (const edit of this.getEdits()) {
const sql = this.buildUpdateSql(table, edit);
await postMessage({ type: 'executeUpdate', payload: { sql, connectionId } });
}
}
}
7.6 表结构面板
多 Tab 展示
interface TableStructureProps {
connectionId: string;
database: string;
table: string;
}
const TableStructure: React.FC<TableStructureProps> = ({ connectionId, database, table }) => {
const [activeTab, setActiveTab] = useState<'columns' | 'indexes' | 'constraints' | 'triggers' | 'ddl'>('columns');
const [data, setData] = useState<any>(null);
useEffect(() => {
postMessage({
type: 'getTableStructure',
payload: { connectionId, database, table, schemaType: activeTab },
});
const handler = (event: MessageEvent) => {
if (event.data.type === 'schemaResult') {
setData(event.data.payload);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [activeTab]);
return (
<div>
<TabBar active={activeTab} onChange={setActiveTab}>
<Tab id="columns">Columns</Tab>
<Tab id="indexes">Indexes</Tab>
<Tab id="constraints">Constraints</Tab>
<Tab id="triggers">Triggers</Tab>
<Tab id="ddl">DDL</Tab>
</TabBar>
<TabContent active={activeTab} data={data} />
</div>
);
};
7.7 连接表单
表单验证
interface ConnectionFormData {
name: string;
type: 'mysql' | 'postgresql';
host: string;
port: number;
username: string;
password: string;
database?: string;
scope: 'project' | 'global';
}
const ConnectionForm: React.FC = () => {
const [formData, setFormData] = useState<ConnectionFormData>({
name: '',
type: 'mysql',
host: 'localhost',
port: 3306,
username: '',
password: '',
database: '',
scope: 'project',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const errs: Record<string, string> = {};
if (!formData.name) errs.name = 'Name is required';
if (!formData.host) errs.host = 'Host is required';
if (!formData.username) errs.username = 'Username is required';
setErrors(errs);
return Object.keys(errs).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
postMessage({
type: 'saveConnection',
payload: formData,
});
};
// 自动填充默认端口
const handleTypeChange = (type: 'mysql' | 'postgresql') => {
setFormData({
...formData,
type,
port: type === 'mysql' ? 3306 : 5432,
});
};
return (
<form>
<Input label="Name" value={formData.name} error={errors.name} onChange={...} />
<Select label="Type" value={formData.type} onChange={handleTypeChange}>
<option value="mysql">MySQL</option>
<option value="postgresql">PostgreSQL</option>
</Select>
<Input label="Host" value={formData.host} error={errors.host} onChange={...} />
<Input label="Port" type="number" value={formData.port} onChange={...} />
<Input label="Username" value={formData.username} error={errors.username} onChange={...} />
<Input label="Password" type="password" value={formData.password} onChange={...} />
<Input label="Database" value={formData.database} onChange={...} />
<RadioGroup label="Scope" value={formData.scope} onChange={...}>
<Radio value="project">Project (shared)</Radio>
<Radio value="global">Global (private)</Radio>
</RadioGroup>
<Button onClick={handleTestConnection}>Test Connection</Button>
<Button onClick={handleSubmit}>Save</Button>
</form>
);
};
7.8 小结
本章介绍了 VSDB 的 UI 组件实现:
| 组件 | 类型 | 功能 |
|---|---|---|
| TreeProvider | VSCode TreeView | 连接树展示 |
| ConnectionNode | TreeItem | 连接节点 |
| SqlEditorPanel | Webview Panel | SQL 编辑器 |
| SqlEditor.tsx | React Component | Monaco 编辑器集成 |
| DataGrid.tsx | React Component | 数据网格 + 虚拟滚动 |
| ConnectionForm | React Component | 连接表单 |
| TableStructure | React Component | 表结构多 Tab |
关键设计:
- TreeView 自动连接:展开时自动发送 connect 请求
- Webview 双向通信:postMessage 实现消息传递
- 虚拟滚动:react-window 处理大数据集
- 内联编辑:EditManager 管理编辑状态
- Monaco 集成:SQL 语法高亮 + 自动补全
下一章将介绍数据操作功能。