Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第七章: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 组件实现:

组件类型功能
TreeProviderVSCode TreeView连接树展示
ConnectionNodeTreeItem连接节点
SqlEditorPanelWebview PanelSQL 编辑器
SqlEditor.tsxReact ComponentMonaco 编辑器集成
DataGrid.tsxReact Component数据网格 + 虚拟滚动
ConnectionFormReact Component连接表单
TableStructureReact Component表结构多 Tab

关键设计:

  • TreeView 自动连接:展开时自动发送 connect 请求
  • Webview 双向通信:postMessage 实现消息传递
  • 虚拟滚动:react-window 处理大数据集
  • 内联编辑:EditManager 管理编辑状态
  • Monaco 集成:SQL 语法高亮 + 自动补全

下一章将介绍数据操作功能。