Theia 扩展:自定义视图——用 Lumino 与 ViewContribution 挂一个侧边栏面板

在 Theia 里,很多插件最终都会落到「在侧边栏/面板里多一块自己的 UI」:比如任务列表、日志窗口、资源树、诊断面板等等。
这一篇聚焦一件事:如何基于 Lumino 的 Widget 系统,再配合 Theia 的 ViewContribution 扩展点,在侧边栏挂出一个自己的视图,并和命令/菜单打通。

先用一个简单模型来记忆 Theia 里的视图系统:

  • Lumino Widget
    • 更底层的视图抽象,负责「这块矩形区域里渲染什么内容」;
    • Theia 在此之上封装出一系列 Widget 基类(包括支持 React 的版本)。
  • Theia ViewContribution
    • 一个扩展点接口,负责「把某个 Widget 挂到 ApplicationShell 的哪个位置」;
    • 控制视图的默认位置(左侧栏/右侧栏/底部面板)、id、可见性,以及与命令的联动。

可以简单理解为:

  • Widget 解决的是「画什么」;ViewContribution 解决的是「画在哪里、怎么打开」。

写一个自定义视图,通常需要两步:

  1. 写一个 Widget(或 ReactWidget)实现具体 UI;
  2. 写一个 ViewContribution,把这个 Widget 注册到某个视图容器里。

Theia 提供了多种 Widget 基类,这里可以先用更常见的 ReactWidget 来承载内容(即便你不用复杂 React,也方便后续扩展)。

示意结构(简化版):

 1import { injectable, postConstruct } from "inversify";
 2import { ReactWidget } from "@theia/core/lib/browser";
 3import * as React from "react";
 4
 5@injectable()
 6export class MySidePanelWidget extends ReactWidget {
 7  static readonly ID = "my-extension:side-panel";
 8  static readonly LABEL = "My Side Panel";
 9
10  @postConstruct()
11  protected init(): void {
12    this.id = MySidePanelWidget.ID;
13    this.title.label = MySidePanelWidget.LABEL;
14    this.title.closable = true;
15    this.update();
16  }
17
18  protected render(): React.ReactNode {
19    return <div>这里是自定义侧边栏内容</div>;
20  }
21}

要点:

  • 通过 static ID / LABEL 约定 Widget 的 id 和标签;
  • init@postConstruct)里设置 Widget 的基本属性;
  • render 里返回 React 节点(也可以用其他 Widget 类型自行处理 DOM)。

接下来,实现一个 ViewContribution 来声明:

  • 这个视图的 id 是什么;
  • 默认出现在哪个区域(例如左侧栏);
  • 是否默认可见;
  • 以及相关命令菜单。

示意代码(省略 import,只看结构):

 1@injectable()
 2export class MySidePanelContribution extends AbstractViewContribution<MySidePanelWidget> {
 3  constructor() {
 4    super({
 5      widgetId: MySidePanelWidget.ID,
 6      widgetName: MySidePanelWidget.LABEL,
 7      defaultWidgetOptions: {
 8        area: "left",
 9      },
10      toggleCommandId: "my-extension.toggleSidePanel",
11    });
12  }
13
14  // 可选:注册命令,让用户可以通过命令面板/快捷键打开视图
15  registerCommands(registry: CommandRegistry): void {
16    registry.registerCommand(
17      {
18        id: "my-extension.toggleSidePanel",
19        label: "切换 My Side Panel",
20      },
21      {
22        execute: async () => {
23          await this.openView({ reveal: true });
24        },
25      }
26    );
27  }
28}

AbstractViewContribution 已经帮你实现了很多样板逻辑,例如:

  • 通过 openView 打开或聚焦视图;
  • 根据 defaultWidgetOptions 把 Widget 挂到 ApplicationShell 的指定区域。

你需要做的是:

  • 在构造函数里给出视图配置;
  • 视情况重写 registerCommands / registerMenus / registerKeybindings 来挂命令和入口。

和命令/菜单一样,Widget 和 ViewContribution 也需要通过 DI 容器绑定。

frontend-module.ts 里,大致会是这样:

 1export default new ContainerModule((bind) => {
 2  bind(MySidePanelWidget).toSelf();
 3  bind(WidgetFactory)
 4    .toDynamicValue((ctx) => ({
 5      id: MySidePanelWidget.ID,
 6      createWidget: () => ctx.container.get(MySidePanelWidget),
 7    }))
 8    .inSingletonScope();
 9
10  bind(ViewContribution).to(MySidePanelContribution).inSingletonScope();
11  bind(CommandContribution).toService(MySidePanelContribution);
12});

要点:

  • 先把 Widget 绑定到容器,并通过 WidgetFactory 告诉框架如何创建它;
  • 再把 MySidePanelContribution 作为 ViewContribution 绑定进去;
  • 如果在贡献类里重用其命令注册逻辑,可以让 CommandContribution 指向同一个实例(toService)。

这样,应用启动后:

  • 框架会自动根据 ViewContribution 注册视图;
  • 当用户执行 toggle 命令时,通过 openView 打开或聚焦侧边栏面板。

自定义视图通常不会单独存在,它经常和命令/菜单/快捷键协同工作。
常见的几种联动模式包括:

  • 在 ViewContribution 里注册命令:
    • 比如「打开/关闭某个侧边栏面板」、「刷新视图数据」、「在视图中执行某个操作」;
  • 在 MenuContribution 里把这些命令挂到主菜单或视图区域右键菜单;
  • 在 KeybindingContribution 里给「打开视图」之类的命令配置快捷键。

这样,用户可以:

  • 通过命令面板/快捷键快速打开你的侧边栏;
  • 在视图内部通过按钮+命令结合,触发更多行为。

从架构角度看:

  • 命令系统依然是核心中枢
  • ViewContribution/Widget 提供了一块 UI 区域来承载命令触发结果。

如果已经熟悉 VS Code 视图扩展,可以用它来反向理解 Theia:

  • VS Code TreeView:
    • 通过 TreeDataProvider 提供数据结构;
    • UI 受 VS Code 控制。
  • VS Code Webview:
    • 完全自定义 HTML/CSS/JS,一块沙箱区域。

Theia 的 Widget + ViewContribution 更偏向于:

  • 给你一个完整的 UI 容器(Widget),你可以用 React/Lumino 任意搭布局;
  • 通过 ViewContribution 决定这个容器在 IDE 壳中的位置和打开方式。

可以粗略记成:

  • VS Code:在既有插槽里扩展视图;
  • Theia:你自己就是在拼装 IDE 的一块视图模块。

可以用一条简单的步骤来回顾这一篇:

  • 写一个 Widget(或 ReactWidget),负责「这块视图区域里长什么样」;
  • 写一个 ViewContribution,指定「它在哪个区域、用什么命令打开」;
  • 在前端模块里把两者通过 DI 容器绑定进去,让框架启动时自动发现并挂载。

掌握了这条路线之后,你可以很自然地把它推广到更多插件场景:
日志面板、任务状态视图、分析结果面板、工具面板……本质上都是在 IDE 壳里多挂了一个「领域专属的 Widget」。