提交 ea250cb9 编写于 作者: T Tomas Vik

Merge branch '168-only-one-webview' into 'main'

refactor: convert webview_controller to TypeScript and make it a class

See merge request gitlab-org/gitlab-vscode-extension!272
......@@ -10,7 +10,7 @@ const { createSnippet } = require('./commands/create_snippet');
const { insertSnippet } = require('./commands/insert_snippet');
const sidebar = require('./sidebar');
const ciConfigValidator = require('./ci_config_validator');
const webviewController = require('./webview_controller');
const { webviewController } = require('./webview_controller');
const IssuableDataProvider = require('./data_providers/issuable').DataProvider;
const CurrentBranchDataProvider = require('./data_providers/current_branch').DataProvider;
const { initializeLogging, handleError } = require('./log');
......@@ -77,7 +77,7 @@ const registerCommands = (context, outputChannel) => {
[USER_COMMANDS.INSERT_SNIPPET]: insertSnippet,
[USER_COMMANDS.VALIDATE_CI_CONFIG]: ciConfigValidator.validate,
[USER_COMMANDS.REFRESH_SIDEBAR]: sidebar.refresh,
[PROGRAMMATIC_COMMANDS.SHOW_RICH_CONTENT]: webviewController.create,
[PROGRAMMATIC_COMMANDS.SHOW_RICH_CONTENT]: webviewController.create.bind(webviewController),
[USER_COMMANDS.SHOW_OUTPUT]: () => outputChannel.show(),
[USER_COMMANDS.RESOLVE_THREAD]: toggleResolved,
[USER_COMMANDS.UNRESOLVE_THREAD]: toggleResolved,
......@@ -112,7 +112,8 @@ const activate = context => {
initializeLogging(line => outputChannel.appendLine(line));
vscode.workspace.registerTextDocumentContentProvider(REVIEW_URI_SCHEME, new GitContentProvider());
registerCommands(context, outputChannel);
webviewController.addDeps(context);
const isDev = process.env.NODE_ENV === 'development';
webviewController.init(context, isDev);
tokenService.init(context);
tokenServiceWrapper.init(context);
extensionState.init(tokenService);
......
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const vscode = require('vscode');
const gitLabService = require('./gitlab_service');
const { createGitLabNewService } = require('./service_factory');
const { logError } = require('./log');
const { getInstanceUrl } = require('./utils/get_instance_url');
let context = null;
const addDeps = ctx => {
context = ctx;
};
const getResources = panel => {
const paths = {
appScriptUri: 'src/webview/dist/js/app.js',
vendorUri: 'src/webview/dist/js/chunk-vendors.js',
styleUri: 'src/webview/dist/css/app.css',
devScriptUri: 'src/webview/dist/app.js',
};
Object.keys(paths).forEach(key => {
const uri = vscode.Uri.file(path.join(context.extensionPath, paths[key]));
paths[key] = panel.webview.asWebviewUri(uri);
});
return paths;
};
const getIndexPath = () => {
const isDev = process.env.NODE_ENV === 'development';
return isDev ? 'src/webview/public/dev.html' : 'src/webview/public/index.html';
};
const replaceResources = panel => {
const { appScriptUri, vendorUri, styleUri, devScriptUri } = getResources(panel);
const nonce = crypto.randomBytes(20).toString('hex');
return fs
.readFileSync(path.join(context.extensionPath, getIndexPath()), 'UTF-8')
.replace(/{{nonce}}/gm, nonce)
.replace('{{styleUri}}', styleUri)
.replace('{{vendorUri}}', vendorUri)
.replace('{{appScriptUri}}', appScriptUri)
.replace('{{devScriptUri}}', devScriptUri);
};
const createPanel = issuable => {
const title = `${issuable.title.slice(0, 20)}...`;
return vscode.window.createWebviewPanel('glWorkflow', title, vscode.ViewColumn.One, {
enableScripts: true,
localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'src'))],
retainContextWhenHidden: true,
});
};
const createMessageHandler = (panel, issuable, repositoryRoot) => async message => {
const instanceUrl = await getInstanceUrl(repositoryRoot);
if (message.command === 'renderMarkdown') {
const alteredMarkdown = message.markdown.replace(
/\(\/.*(\/-)?\/merge_requests\//,
'(/-/merge_requests/',
);
let rendered = await gitLabService.renderMarkdown(alteredMarkdown, repositoryRoot);
rendered = (rendered || '')
.replace(/ src=".*" alt/gim, ' alt')
.replace(/" data-src/gim, '" src')
.replace(/ href="\//gim, ` href="${instanceUrl}/`)
.replace(/\/master\/-\/merge_requests\//gim, '/-/merge_requests/');
panel.webview.postMessage({
type: 'markdownRendered',
ref: message.ref,
object: message.object,
markdown: rendered,
});
}
if (message.command === 'saveNote') {
const gitlabNewService = await createGitLabNewService(repositoryRoot);
try {
await gitlabNewService.createNote(issuable, message.note, message.replyId);
const discussionsAndLabels = await gitlabNewService.getDiscussionsAndLabelEvents(issuable);
panel.webview.postMessage({
type: 'issuableFetch',
issuable,
discussions: discussionsAndLabels,
});
panel.webview.postMessage({ type: 'noteSaved' });
} catch (e) {
logError(e);
panel.webview.postMessage({ type: 'noteSaved', status: false });
}
}
};
async function initPanelIfActive(panel, issuable, repositoryRoot) {
if (!panel.active) return;
const appReadyPromise = new Promise(resolve => {
const sub = panel.webview.onDidReceiveMessage(async message => {
if (message.command === 'appReady') {
sub.dispose();
resolve();
}
});
});
const gitlabNewService = await createGitLabNewService(repositoryRoot);
const discussionsAndLabels = await gitlabNewService.getDiscussionsAndLabelEvents(issuable);
await appReadyPromise;
panel.webview.postMessage({ type: 'issuableFetch', issuable, discussions: discussionsAndLabels });
}
const getIconPathForIssuable = issuable => {
const getIconUri = (shade, file) =>
vscode.Uri.file(path.join(context.extensionPath, 'src', 'assets', 'images', shade, file));
const lightIssueIcon = getIconUri('light', 'issues.svg');
const lightMrIcon = getIconUri('light', 'merge_requests.svg');
const darkIssueIcon = getIconUri('dark', 'issues.svg');
const darkMrIcon = getIconUri('dark', 'merge_requests.svg');
const isMr = issuable.squash_commit_sha !== undefined;
return isMr
? { light: lightMrIcon, dark: darkMrIcon }
: { light: lightIssueIcon, dark: darkIssueIcon };
};
async function create(issuable, repositoryRoot) {
const panel = createPanel(issuable);
const html = replaceResources(panel);
panel.webview.html = html;
panel.iconPath = getIconPathForIssuable(issuable);
initPanelIfActive(panel, issuable, repositoryRoot);
panel.onDidChangeViewState(() => {
initPanelIfActive(panel, issuable, repositoryRoot);
});
panel.webview.onDidReceiveMessage(createMessageHandler(panel, issuable, repositoryRoot));
return panel;
}
exports.addDeps = addDeps;
exports.create = create;
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as vscode from 'vscode';
import * as assert from 'assert';
import * as gitLabService from './gitlab_service';
import { createGitLabNewService } from './service_factory';
import { logError } from './log';
import { getInstanceUrl } from './utils/get_instance_url';
const webviewResourcePaths = {
appScriptUri: 'src/webview/dist/js/app.js',
vendorUri: 'src/webview/dist/js/chunk-vendors.js',
styleUri: 'src/webview/dist/css/app.css',
devScriptUri: 'src/webview/dist/app.js',
} as const;
type WebviewResources = Record<keyof typeof webviewResourcePaths, vscode.Uri>;
async function initPanelIfActive(
panel: vscode.WebviewPanel,
issuable: RestIssuable,
repositoryRoot: string,
) {
if (!panel.active) return;
const appReadyPromise = new Promise<void>(resolve => {
const sub = panel.webview.onDidReceiveMessage(async message => {
if (message.command === 'appReady') {
sub.dispose();
resolve();
}
});
});
const gitlabNewService = await createGitLabNewService(repositoryRoot);
const discussionsAndLabels = await gitlabNewService.getDiscussionsAndLabelEvents(issuable);
await appReadyPromise;
await panel.webview.postMessage({
type: 'issuableFetch',
issuable,
discussions: discussionsAndLabels,
});
}
class WebviewController {
context?: vscode.ExtensionContext;
isDev = false;
init(context: vscode.ExtensionContext, isDev: boolean) {
this.context = context;
this.isDev = isDev;
}
private getResources(panel: vscode.WebviewPanel): WebviewResources {
return Object.entries(webviewResourcePaths).reduce((acc, [key, value]) => {
assert(this.context);
const uri = vscode.Uri.file(path.join(this.context.extensionPath, value));
return { ...acc, [key]: panel.webview.asWebviewUri(uri) };
}, {}) as WebviewResources;
}
private getIndexPath() {
return this.isDev ? 'src/webview/public/dev.html' : 'src/webview/public/index.html';
}
private replaceResources(panel: vscode.WebviewPanel) {
assert(this.context);
const { appScriptUri, vendorUri, styleUri, devScriptUri } = this.getResources(panel);
const nonce = crypto.randomBytes(20).toString('hex');
return fs
.readFileSync(path.join(this.context.extensionPath, this.getIndexPath()), 'UTF-8')
.replace(/{{nonce}}/gm, nonce)
.replace('{{styleUri}}', styleUri.toString())
.replace('{{vendorUri}}', vendorUri.toString())
.replace('{{appScriptUri}}', appScriptUri.toString())
.replace('{{devScriptUri}}', devScriptUri.toString());
}
private createPanel(issuable: RestIssuable) {
assert(this.context);
const title = `${issuable.title.slice(0, 20)}...`;
return vscode.window.createWebviewPanel('glWorkflow', title, vscode.ViewColumn.One, {
enableScripts: true,
localResourceRoots: [vscode.Uri.file(path.join(this.context.extensionPath, 'src'))],
retainContextWhenHidden: true,
});
}
private createMessageHandler = (
panel: vscode.WebviewPanel,
issuable: RestIssuable,
repositoryRoot: string,
) => async (message: any) => {
const instanceUrl = await getInstanceUrl(repositoryRoot);
if (message.command === 'renderMarkdown') {
let rendered = await gitLabService.renderMarkdown(message.markdown, repositoryRoot);
rendered = (rendered || '')
.replace(/ src=".*" alt/gim, ' alt')
.replace(/" data-src/gim, '" src')
.replace(/ href="\//gim, ` href="${instanceUrl}/`);
await panel.webview.postMessage({
type: 'markdownRendered',
ref: message.ref,
object: message.object,
markdown: rendered,
});
}
if (message.command === 'saveNote') {
const gitlabNewService = await createGitLabNewService(repositoryRoot);
try {
await gitlabNewService.createNote(issuable, message.note, message.replyId);
const discussionsAndLabels = await gitlabNewService.getDiscussionsAndLabelEvents(issuable);
await panel.webview.postMessage({
type: 'issuableFetch',
issuable,
discussions: discussionsAndLabels,
});
await panel.webview.postMessage({ type: 'noteSaved' });
} catch (e) {
logError(e);
await panel.webview.postMessage({ type: 'noteSaved', status: false });
}
}
};
private getIconPathForIssuable(issuable: RestIssuable) {
const getIconUri = (shade: string, file: string) =>
vscode.Uri.file(
path.join(this.context!.extensionPath, 'src', 'assets', 'images', shade, file),
);
const lightIssueIcon = getIconUri('light', 'issues.svg');
const lightMrIcon = getIconUri('light', 'merge_requests.svg');
const darkIssueIcon = getIconUri('dark', 'issues.svg');
const darkMrIcon = getIconUri('dark', 'merge_requests.svg');
const isMr = issuable.sha !== undefined;
return isMr
? { light: lightMrIcon, dark: darkMrIcon }
: { light: lightIssueIcon, dark: darkIssueIcon };
}
async create(issuable: RestIssuable, repositoryRoot: string) {
assert(this.context);
const panel = this.createPanel(issuable);
const html = this.replaceResources(panel);
panel.webview.html = html;
panel.iconPath = this.getIconPathForIssuable(issuable);
await initPanelIfActive(panel, issuable, repositoryRoot);
panel.onDidChangeViewState(async () => {
await initPanelIfActive(panel, issuable, repositoryRoot);
});
panel.webview.onDidReceiveMessage(this.createMessageHandler(panel, issuable, repositoryRoot));
return panel;
}
}
export const webviewController = new WebviewController();
......@@ -3,7 +3,7 @@ const vscode = require('vscode');
const sinon = require('sinon');
const EventEmitter = require('events');
const { graphql } = require('msw');
const webviewController = require('../../src/webview_controller');
const { webviewController } = require('../../src/webview_controller');
const { tokenService } = require('../../src/services/token_service');
const openIssueResponse = require('./fixtures/rest/open_issue.json');
const { projectWithIssueDiscussions, note2 } = require('./fixtures/graphql/discussions');
......@@ -71,6 +71,10 @@ describe('GitLab webview', () => {
eventEmitter.on('', listener);
return { dispose: () => {} };
});
// this simulates real behaviour where the webview initializes Vue app and that sends a `appReady` message
setTimeout(() => {
eventEmitter.emit('', { command: 'appReady' });
}, 1);
return panel;
});
};
......@@ -102,4 +106,21 @@ describe('GitLab webview', () => {
assert.strictEqual(sentMessage.type, 'noteSaved');
assert.strictEqual(sentMessage.status, undefined);
});
it('adds the correct panel icon', () => {
const { dark, light } = webviewPanel.iconPath;
assert.match(dark.path, /src\/assets\/images\/dark\/issues.svg$/);
assert.match(light.path, /src\/assets\/images\/light\/issues.svg$/);
});
it('substitutes the resource URLs in the HTML markup', () => {
const resources = [
'src/webview/dist/js/app\\.js',
'src/webview/dist/js/chunk-vendors\\.js',
'src/webview/dist/css/app\\.css',
];
resources.forEach(r => {
assert.match(webviewPanel.webview.html, new RegExp(r, 'gm'));
});
});
});
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册