前言

Theia 目前提供的开发文档中对于 API 的介绍不太详细,缺少可以直接执行的示例,新手在新功能开发中不太容易理解,本文将阅读源码过程的一些代码片段摘出来进行归纳总结,通过局部的代码片段窥探如何开发 Theia 扩展。

获取应用根路径

通用方法:

  1. environment.electron.isDevMode() ? process.cwd() : `${process.resourcesPath}/app`;

theia 里面:

  1. process.env.THEIA_APP_PROJECT_PATH

获取 node_modules 下模块的路径

  1. import { ApplicationPackage } from '@theia/core/shared/@theia/application-package';
  2. @inject(ApplicationPackage)
  3. protected readonly applicationPackage: ApplicationPackage;
  4. this.applicationPackage.resolveModulePath('@theia/core');

获取工程路径

前端:

  1. import { WorkspaceService } from '@theia/workspace/lib/browser';
  2. private getCurrentWorkspaceUri(): URI | undefined {
  3. return this.workspaceService.workspace?.resource;
  4. }

后端:

  1. import { WorkspaceServer } from '@theia/workspace/lib/common';
  2. private getProjectPath(): string | undefined {
  3. const projectPath = await this.workspaceServer.getMostRecentlyUsedWorkspace();
  4. if (projectPath) {
  5. return new URI(projectPath).path.toString();
  6. }
  7. }

打开文件选择框

类似于 Electron showOpenDialog,自动根据运行环境切换。

  1. import { FileDialogService } from '@theia/filesystem/lib/browser';
  2. @inject(FileDialogService)
  3. protected readonly fileDialogService: FileDialogService;
  4. const uri = await this.fileDialogService.showOpenDialog({
  5. title: '标题',
  6. canSelectFiles: true,
  7. canSelectFolders: false
  8. });

文件系统

前端部分推荐 FileService + EnvVariableServer,后端部分使用 Node.js APIs.

  1. // frontend
  2. const configDirUri = await this.envServer.getConfigDirUri();
  3. const userInfoConfigUri = new URI(configDirUri).resolve('userinfo.config.json');
  4. await this.fileService.write(userInfoConfigUri, JSON.stringify(userInfo));
  5. // backend
  6. const userInfoConfigPath = path.join(os.homedir(), '.mmp-studio', 'userinfo.config.json');
  7. fs.writeFileSync(userInfoConfigPath, JSON.stringify(userInfo));

获取后端接口信息

前端拓展获取服务接口信息:

import { Endpoint } from '@theia/core/lib/browser/endpoint';
// 获取 http://localhost:{port}/files
new Endpoint({ path: 'files' }).getRestUrl().toString();

Tips: IDE 前端启动过程中获取到后端服务 address 信息后,将端口设置在 location.search 上。

后端拓展获取端口信息:

@injectable()
export class SimulatorEndpoint implements BackendApplicationContribution {
        onStart(server: http.Server | https.Server) {
        console.log('address: ', server.address());
    }
}

监听视图的添加与隐藏

this.shell.onDidAddWidget((widget: Widget) => {
  console.log('add widget: ', widget.id);
});
this.shell.onDidRemoveWidget((widget: Widget) => {
    console.log('remove widget: ', widget.id);
});

自定义配置文件目录

settings.json, keymaps.json, recentworkspace.json 等配置文件的根目录默认地址是 ~/.theia,可以通过复写 EnvVariablesServer API 的 getConfigDirUri 方法进行自定义,最简单的方法是继承EnvVariablesServerImpl 子类,然后将其重新绑定到 backend 模块:

// your-env-variables-server.ts:

import { injectable } from 'inversify';
import { EnvVariablesServerImpl } from '@theia/core/lib/node/env-variables';

@injectable()
export class YourEnvVariableServer extends EnvVariablesServerImpl {
    async getConfigDirUri(): Promise<string> {
        return 'file:///path/to/your/desired/config/dir';
    }
}

// your-backend-application-module.ts:

import { ContainerModule } from 'inversify';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { YourEnvVariableServer } from './your-env-variables-server';

export default new ContainerModule((bind, unbind, isBound, rebind) => {
    rebind(EnvVariablesServer).to(YourEnvVariableServer).inSingletonScope();
});

v1.5.0 有更加简单的配置方式,通过配置 process.env.THEIA_CONFIG_DIR 字段。

前端工程的配置修改方式:

rebind(PreferenceConfigurations).to(class extends PreferenceConfigurations {
  getPaths(): string[] {
      return [CUSTOM_CONFIG_DIR, '.theia', '.vscode'];
    }
}).inSingletonScope();

自定义编辑器视图

Theia 自定义编辑器视图比 VS Code 更简单,有两种实现方式:

  • 方法一:实现 OpenHandler 接口的 canHandle 和 open 方法
  • 方法二:继承抽象类 WidgetOpenHandler,复写 createWidgetOptions 和 canHandle 方法关联 widget

方法一示例:https://github.com/TypeFox/theia-workshop/tree/exercise-3#exercise-3-implement-ui-schema-support-for-json-form-widget

bind(OpenHandler).to(ProjectConfigOpenHandler).inSingletonScope();
bind(WidgetFactory).toDynamicValue(ctx => ({
    id: ProjectConfigWidget.ID,
    createWidget: (options: ProjectConfigWidgetOptions) => {
    const child = ctx.container.createChild();
    child.bind(ProjectConfigWidgetOptions).toConstantValue(options);
    child.bind(ProjectConfigWidget).toSelf();
    return child.get(ProjectConfigWidget);
  }
})).inSingletonScope();
// project-config-open-handler.ts
@injectable()
export class ProjectConfigOpenHandler extends WidgetOpenHandler<ProjectConfigWidget> {
    readonly id = ProjectConfigWidget.ID;
    readonly label?: string = 'Preview';
    private defaultFileName: string = 'project.config.json';

    @inject(ApplicationShell)
    protected readonly shell: ApplicationShell;

    protected createWidgetOptions(uri: URI): Object {
        return {
          uri: uri.withoutFragment().toString()
        };
    }

    canHandle(uri: URI): number {
        if (uri.path.toString().includes(this.defaultFileName)) {
            return 1000;
        }
        return 0;
    }
}

面板中 iframe、WebView 无法响应鼠标点击事件

import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker';

@inject(ApplicationShellMouseTracker)
protected readonly mouseTracker: ApplicationShellMouseTracker;

// Style from core
const TRANSPARENT_OVERLAY_STYLE = 'theia-transparent-overlay';

@postConstruct()
protected init(): void {
  this.frame = this.createWebView();
  this.transparentOverlay = this.createTransparentOverlay();
  this.node.appendChild(this.frame);
  this.node.appendChild(this.transparentOverlay);
    this.toDispose.push(this.mouseTracker.onMousedown(e => {
        if (this.frame.style.display !== 'none') {
            this.transparentOverlay.style.display = 'block';
        }
    }));
    this.toDispose.push(this.mouseTracker.onMouseup(e => {
        if (this.frame.style.display !== 'none') {
            this.transparentOverlay.style.display = 'none';
        }
    }));
}

createWebView(): Electron.WebviewTag {
  const webview = document.createElement('webview') as Electron.WebviewTag;
  ...
  return webview;
}

createTransparentOverlay() {
  const transparentOverlay = document.createElement('div');
  transparentOverlay.classList.add(TRANSPARENT_OVERLAY_STYLE);
  transparentOverlay.style.display = 'none';
  return transparentOverlay;
}

检查编辑器是否可以保存

import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';

@inject(EditorManager)
protected readonly editorManager: EditorManager;

/**
 * 检查编辑器是否可以保存
 */
checkEditorSaveable() {
  let isDirty = false;
  const trackedEditors: EditorWidget[] = this.editorManager.all;
  for (let widget of trackedEditors) {
    isDirty = widget.saveable.dirty;
    if (isDirty) break;
  }
  return isDirty;
}

父进程 kill 后自动退出子进程

Theia 源码中有这么一个方法 checkParentAlive,在每个 fork 的子进程里面都会引用,作用就是定时检测父进程是否存活,不存活就自动退出子进程。

/**
 * Exit the current process if the parent process is not alive.
 * Relevant only for some OS, like Windows
 */
export function checkParentAlive(): void {
    if (process.env[THEIA_PARENT_PID]) {
        const parentPid = Number(process.env[THEIA_PARENT_PID]);

        if (typeof parentPid === 'number' && !isNaN(parentPid)) {
            setInterval(() => {
                try {
                    // throws an exception if the main process doesn't exist anymore.
                    process.kill(parentPid, 0);
                } catch {
                    process.exit();
                }
            }, 5000);
        }
    }
}

process.kill() 函数并不是杀死进程,它实际上只是信号发送者,就像 kill 系统调用,发送的信号可能会做其他事情而不是杀死目标进程,比如发送 0 可以来测试进程是否存在,如果进程存在则没影响,如果进程不存在则抛出错误。

Promise Deferred 模式

/**
 * Simple implementation of the deferred pattern.
 * An object that exposes a promise and functions to resolve and reject it.
 */
export class Deferred<T = void> {
    state: 'resolved' | 'rejected' | 'unresolved' = 'unresolved';
    resolve: (value: T | PromiseLike<T>) => void;
    reject: (err?: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any

    promise = new Promise<T>((resolve, reject) => {
        this.resolve = result => {
            resolve(result);
            if (this.state === 'unresolved') {
                this.state = 'resolved';
            }
        };
        this.reject = err => {
            reject(err);
            if (this.state === 'unresolved') {
                this.state = 'rejected';
            }
        };
    });
}

用法:

function asyncDoSomeing(flag, message) {
    var deferred = new Deferred();
    setTimeout(function () {
        if (flag) {
            deferred.resolve(message);
        } else {
            deferred.reject({ code: 400, message: '拒绝' });
        }
    }, 3000);
    return deferred.promise;
}

DisposableCollection

export class DisposableCollection implements Disposable {
    protected readonly disposables: Disposable[] = [];
    protected readonly onDisposeEmitter = new Emitter<void>();

    constructor(...toDispose: Disposable[]) {
        toDispose.forEach(d => this.push(d));
    }

    /**
     * This event is fired only once
     * on first dispose of not empty collection.
     */
    get onDispose(): Event<void> {
        return this.onDisposeEmitter.event;
    }

    protected checkDisposed(): void {
        if (this.disposed && !this.disposingElements) {
            this.onDisposeEmitter.fire(undefined);
            this.onDisposeEmitter.dispose();
        }
    }

    get disposed(): boolean {
        return this.disposables.length === 0;
    }

    private disposingElements = false;
    dispose(): void {
        if (this.disposed || this.disposingElements) {
            return;
        }
        this.disposingElements = true;
        while (!this.disposed) {
            try {
                this.disposables.pop()!.dispose();
            } catch (e) {
                console.error(e);
            }
        }
        this.disposingElements = false;
        this.checkDisposed();
    }

    push(disposable: Disposable): Disposable {
        const disposables = this.disposables;
        disposables.push(disposable);
        const originalDispose = disposable.dispose.bind(disposable);
        const toRemove = Disposable.create(() => {
            const index = disposables.indexOf(disposable);
            if (index !== -1) {
                disposables.splice(index, 1);
            }
            this.checkDisposed();
        });
        disposable.dispose = () => {
            toRemove.dispose();
            disposable.dispose = originalDispose;
            originalDispose();
        };
        return toRemove;
    }

    pushAll(disposables: Disposable[]): Disposable[] {
        return disposables.map(disposable =>
            this.push(disposable)
        );
    }
}

用法:

// 初始化 Disposable 对象收集器
const toDispose = new DisposableCollection();
// 收集待销毁的 Disposable 对象(通过 Disposable.create 方法生成或者实现 Disposable 接口的类
toDispose.push(Disposable.create(() => {
  // ...
})
// 销毁
toDispose.dispose()

写文章不容易,也许写这些代码就几分钟的事,写一篇大家好接受的文章或许需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!
2020-12-06 at 11.48 AM.png