Browse Source

Allows setting a default column for webviews #2610

Reworks webview registration
 - Splits out resolver from descriptor
 - Allows for more accurate typings
main
Eric Amodio 1 year ago
parent
commit
8b14e94975
15 changed files with 403 additions and 346 deletions
  1. +1
    -6
      src/plus/webviews/focus/focusWebview.ts
  2. +20
    -10
      src/plus/webviews/focus/registration.ts
  3. +5
    -10
      src/plus/webviews/graph/graphWebview.ts
  4. +35
    -28
      src/plus/webviews/graph/registration.ts
  5. +35
    -19
      src/plus/webviews/timeline/registration.ts
  6. +9
    -14
      src/plus/webviews/timeline/timelineWebview.ts
  7. +5
    -6
      src/webviews/commitDetails/commitDetailsWebview.ts
  8. +15
    -9
      src/webviews/commitDetails/registration.ts
  9. +3
    -8
      src/webviews/home/homeWebview.ts
  10. +15
    -9
      src/webviews/home/registration.ts
  11. +20
    -11
      src/webviews/settings/registration.ts
  12. +91
    -72
      src/webviews/webviewController.ts
  13. +4
    -8
      src/webviews/webviewWithConfigBase.ts
  14. +125
    -126
      src/webviews/webviewsController.ts
  15. +20
    -10
      src/webviews/welcome/registration.ts

+ 1
- 6
src/plus/webviews/focus/focusWebview.ts View File

@ -1,6 +1,5 @@
import { Disposable, Uri, window } from 'vscode';
import type { GHPRPullRequest } from '../../../commands';
import type { WebviewIds, WebviewViewIds } from '../../../constants';
import { Commands } from '../../../constants';
import type { Container } from '../../../container';
import { setContext } from '../../../context';
@ -59,11 +58,7 @@ export class FocusWebviewProvider implements WebviewProvider {
private _repositoryEventsDisposable?: Disposable;
private _repos?: RepoWithRichRemote[];
constructor(
readonly container: Container,
readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
readonly host: WebviewController<State>,
) {
constructor(private readonly container: Container, private readonly host: WebviewController<State>) {
this._disposable = Disposable.from(
this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
this.container.git.onDidChangeRepositories(() => void this.host.refresh(true)),

+ 20
- 10
src/plus/webviews/focus/registration.ts View File

@ -1,18 +1,28 @@
import { ViewColumn } from 'vscode';
import { Commands } from '../../../constants';
import type { WebviewsController } from '../../../webviews/webviewsController';
import type { State } from './protocol';
export function registerFocusWebviewPanel(controller: WebviewsController) {
return controller.registerWebviewPanel<State>(Commands.ShowFocusPage, 'gitlens.focus', {
fileName: 'focus.html',
iconPath: 'images/gitlens-icon.png',
title: 'Focus View',
contextKeyPrefix: `gitlens:webview:focus`,
trackingFeature: 'focusWebview',
plusFeature: true,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewPanel<State>(
Commands.ShowFocusPage,
{
id: 'gitlens.focus',
fileName: 'focus.html',
iconPath: 'images/gitlens-icon.png',
title: 'Focus View',
contextKeyPrefix: `gitlens:webview:focus`,
trackingFeature: 'focusWebview',
plusFeature: true,
column: ViewColumn.Active,
panelOptions: {
retainContextWhenHidden: true,
enableFindWidget: true,
},
},
async (container, host) => {
const { FocusWebviewProvider } = await import(/* webpackChunkName: "focus" */ './focusWebview');
return new FocusWebviewProvider(container, id, host);
return new FocusWebviewProvider(container, host);
},
});
);
}

+ 5
- 10
src/plus/webviews/graph/graphWebview.ts View File

@ -12,7 +12,6 @@ import type {
} from '../../../commands';
import { parseCommandContext } from '../../../commands/base';
import type { Config } from '../../../config';
import type { WebviewIds, WebviewViewIds } from '../../../constants';
import { Commands, GlyphChars } from '../../../constants';
import type { Container } from '../../../container';
import { getContext, onDidChangeContext } from '../../../context';
@ -214,11 +213,7 @@ export class GraphWebviewProvider implements WebviewProvider {
private trialBanner?: boolean;
private isWindowFocused: boolean = true;
constructor(
readonly container: Container,
readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
readonly host: WebviewController<State>,
) {
constructor(private readonly container: Container, private readonly host: WebviewController<State>) {
this._showDetailsView = configuration.get('graph.showDetailsView');
this._theme = window.activeColorTheme;
this.ensureRepositorySubscriptions();
@ -520,7 +515,7 @@ export class GraphWebviewProvider implements WebviewProvider {
preserveVisibility: this._showDetailsView === false,
},
{
source: this.id,
source: this.host.id,
},
);
}
@ -691,7 +686,7 @@ export class GraphWebviewProvider implements WebviewProvider {
preserveVisibility: false,
},
{
source: this.id,
source: this.host.id,
},
);
@ -1060,7 +1055,7 @@ export class GraphWebviewProvider implements WebviewProvider {
: this._showDetailsView !== 'selection',
},
{
source: this.id,
source: this.host.id,
},
);
this._firstSelection = false;
@ -1688,7 +1683,7 @@ export class GraphWebviewProvider implements WebviewProvider {
const item = typeof context === 'string' ? JSON.parse(context) : context;
// Add the `webview` prop to the context if its missing (e.g. when this context doesn't come through via the context menus)
if (item != null && !('webview' in item)) {
item.webview = this.id;
item.webview = this.host.id;
}
return item;
}

+ 35
- 28
src/plus/webviews/graph/registration.ts View File

@ -1,4 +1,4 @@
import { Disposable } from 'vscode';
import { Disposable, ViewColumn } from 'vscode';
import { Commands } from '../../../constants';
import type { Container } from '../../../container';
import type { Repository } from '../../../git/models/repository';
@ -13,42 +13,49 @@ import type { WebviewPanelProxy, WebviewsController } from '../../../webviews/we
import type { ShowInCommitGraphCommandArgs, State } from './protocol';
export function registerGraphWebviewPanel(controller: WebviewsController) {
return controller.registerWebviewPanel<State>(Commands.ShowGraphPage, 'gitlens.graph', {
fileName: 'graph.html',
iconPath: 'images/gitlens-icon.png',
title: 'Commit Graph',
contextKeyPrefix: `gitlens:webview:graph`,
trackingFeature: 'graphWebview',
plusFeature: true,
panelOptions: {
retainContextWhenHidden: true,
enableFindWidget: false,
},
canResolveWebviewProvider: function (_container, _id) {
return configuration.get('graph.experimental.location') === 'tab';
return controller.registerWebviewPanel<State>(
Commands.ShowGraphPage,
{
id: 'gitlens.graph',
fileName: 'graph.html',
iconPath: 'images/gitlens-icon.png',
title: 'Commit Graph',
contextKeyPrefix: `gitlens:webview:graph`,
trackingFeature: 'graphWebview',
plusFeature: true,
column: ViewColumn.Active,
panelOptions: {
retainContextWhenHidden: true,
enableFindWidget: false,
},
},
resolveWebviewProvider: async function (container, id, host) {
async (container, host) => {
const { GraphWebviewProvider } = await import(/* webpackChunkName: "graph" */ './graphWebview');
return new GraphWebviewProvider(container, id, host);
return new GraphWebviewProvider(container, host);
},
});
() => configuration.get('graph.experimental.location') === 'tab',
);
}
export function registerGraphWebviewView(controller: WebviewsController) {
return controller.registerWebviewView<State>('gitlens.views.graph', {
fileName: 'graph.html',
title: 'Commit Graph',
contextKeyPrefix: `gitlens:webviewView:graph`,
trackingFeature: 'graphView',
plusFeature: true,
canResolveWebviewProvider: function (_container, _id) {
return configuration.get('graph.experimental.location') === 'view';
return controller.registerWebviewView<State>(
{
id: 'gitlens.views.graph',
fileName: 'graph.html',
title: 'Commit Graph',
contextKeyPrefix: `gitlens:webviewView:graph`,
trackingFeature: 'graphView',
plusFeature: true,
webviewViewOptions: {
retainContextWhenHidden: true,
},
},
resolveWebviewProvider: async function (container, id, host) {
async (container, host) => {
const { GraphWebviewProvider } = await import(/* webpackChunkName: "graph" */ './graphWebview');
return new GraphWebviewProvider(container, id, host);
return new GraphWebviewProvider(container, host);
},
});
() => configuration.get('graph.experimental.location') === 'view',
);
}
export function registerGraphWebviewCommands(container: Container, webview: WebviewPanelProxy) {

+ 35
- 19
src/plus/webviews/timeline/registration.ts View File

@ -1,32 +1,48 @@
import { ViewColumn } from 'vscode';
import { Commands } from '../../../constants';
import type { WebviewsController } from '../../../webviews/webviewsController';
import type { State } from './protocol';
export function registerTimelineWebviewPanel(controller: WebviewsController) {
return controller.registerWebviewPanel<State>(Commands.ShowTimelinePage, 'gitlens.timeline', {
fileName: 'timeline.html',
iconPath: 'images/gitlens-icon.png',
title: 'Visual File History',
contextKeyPrefix: `gitlens:webview:timeline`,
trackingFeature: 'timelineWebview',
plusFeature: true,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewPanel<State>(
Commands.ShowTimelinePage,
{
id: 'gitlens.timeline',
fileName: 'timeline.html',
iconPath: 'images/gitlens-icon.png',
title: 'Visual File History',
contextKeyPrefix: `gitlens:webview:timeline`,
trackingFeature: 'timelineWebview',
plusFeature: true,
column: ViewColumn.Active,
panelOptions: {
retainContextWhenHidden: true,
enableFindWidget: false,
},
},
async (container, host) => {
const { TimelineWebviewProvider } = await import(/* webpackChunkName: "timeline" */ './timelineWebview');
return new TimelineWebviewProvider(container, id, host);
return new TimelineWebviewProvider(container, host);
},
});
);
}
export function registerTimelineWebviewView(controller: WebviewsController) {
return controller.registerWebviewView<State>('gitlens.views.timeline', {
fileName: 'timeline.html',
title: 'Visual File History',
contextKeyPrefix: `gitlens:webviewView:timeline`,
trackingFeature: 'timelineView',
plusFeature: true,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewView<State>(
{
id: 'gitlens.views.timeline',
fileName: 'timeline.html',
title: 'Visual File History',
contextKeyPrefix: `gitlens:webviewView:timeline`,
trackingFeature: 'timelineView',
plusFeature: true,
webviewViewOptions: {
retainContextWhenHidden: false,
},
},
async (container, host) => {
const { TimelineWebviewProvider } = await import(/* webpackChunkName: "timeline" */ './timelineWebview');
return new TimelineWebviewProvider(container, id, host);
return new TimelineWebviewProvider(container, host);
},
});
);
}

+ 9
- 14
src/plus/webviews/timeline/timelineWebview.ts View File

@ -1,6 +1,5 @@
import type { TextEditor, ViewColumn } from 'vscode';
import { commands, Disposable, Uri, window } from 'vscode';
import type { WebviewIds, WebviewViewIds } from '../../../constants';
import { Commands } from '../../../constants';
import type { Container } from '../../../container';
import type { FileSelectedEvent } from '../../../eventBus';
@ -43,11 +42,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
private _pendingContext: Partial<Context> | undefined;
private readonly _disposable: Disposable;
constructor(
readonly container: Container,
readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
readonly host: WebviewController<State>,
) {
constructor(private readonly container: Container, private readonly host: WebviewController<State>) {
this._context = {
uri: undefined,
period: defaultPeriod,
@ -60,7 +55,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
if (this.host.isType('tab')) {
if (this.host.isTab()) {
this._disposable = Disposable.from(
this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
this.container.git.onDidChangeRepository(this.onRepositoryChanged, this),
@ -112,13 +107,13 @@ export class TimelineWebviewProvider implements WebviewProvider {
}
registerCommands(): Disposable[] {
if (this.host.isType('tab')) {
if (this.host.isTab()) {
return [registerCommand(Commands.RefreshTimelinePage, () => this.host.refresh(true))];
}
return [
registerCommand(`${this.id}.refresh`, () => this.host.refresh(true), this),
registerCommand(`${this.id}.openInTab`, () => this.openInTab(), this),
registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true), this),
registerCommand(`${this.host.id}.openInTab`, () => this.openInTab(), this),
];
}
@ -159,7 +154,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
preserveFocus: true,
preserveVisibility: true,
},
{ source: this.id },
{ source: this.host.id },
);
});
@ -270,7 +265,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
}
const title = gitUri.relativePath;
if (this.host.isType('tab')) {
if (this.host.isTab()) {
this.host.title = `${this.host.originalTitle}: ${gitUri.fileName}`;
} else {
this.host.description = gitUri.fileName;
@ -403,7 +398,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
private updateState(immediate: boolean = false) {
if (!this.host.isReady || !this.host.visible) return;
if (this._pendingContext == null && this.host.isType('view')) {
if (this._pendingContext == null && this.host.isView()) {
this.updatePendingEditor(window.activeTextEditor);
}
@ -428,7 +423,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
const context = { ...this._context, ...this._pendingContext };
return window.withProgress({ location: { viewId: this.id } }, async () => {
return window.withProgress({ location: { viewId: this.host.id } }, async () => {
const success = await this.host.notify(DidChangeNotificationType, {
state: await this.getState(context),
});

+ 5
- 6
src/webviews/commitDetails/commitDetailsWebview.ts View File

@ -2,7 +2,7 @@ import type { CancellationToken, ConfigurationChangeEvent, TextDocumentShowOptio
import { CancellationTokenSource, Disposable, Uri, ViewColumn, window } from 'vscode';
import { serializeAutolink } from '../../annotations/autolinks';
import type { CopyShaToClipboardCommandArgs } from '../../commands';
import type { CoreConfiguration, WebviewIds, WebviewViewIds } from '../../constants';
import type { CoreConfiguration } from '../../constants';
import { Commands } from '../../constants';
import type { Container } from '../../container';
import { getContext } from '../../context';
@ -95,9 +95,8 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
private _commitStack = new MRU<GitRevisionReference>(10, (a, b) => a.ref === b.ref);
constructor(
readonly container: Container,
readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
readonly host: WebviewController<State, Serialized<State>>,
private readonly container: Container,
private readonly host: WebviewController<State, Serialized<State>>,
) {
this._context = {
pinned: false,
@ -671,7 +670,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
const context = { ...this._context, ...this._pendingContext };
return window.withProgress({ location: { viewId: this.id } }, async () => {
return window.withProgress({ location: { viewId: this.host.id } }, async () => {
try {
const success = await this.host.notify(DidChangeNotificationType, {
state: await this.getState(context),
@ -862,7 +861,7 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
preview: true,
...this.getShowOptions(params),
});
this.container.events.fire('file:selected', { uri: file.uri }, { source: this.id });
this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id });
}
private async openFile(params: FileActionParams) {

+ 15
- 9
src/webviews/commitDetails/registration.ts View File

@ -3,17 +3,23 @@ import type { WebviewsController } from '../webviewsController';
import type { State } from './protocol';
export function registerCommitDetailsWebviewView(controller: WebviewsController) {
return controller.registerWebviewView<State, Serialized<State>>('gitlens.views.commitDetails', {
fileName: 'commitDetails.html',
title: 'Commit Details',
contextKeyPrefix: `gitlens:webviewView:commitDetails`,
trackingFeature: 'commitDetailsView',
plusFeature: false,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewView<State, Serialized<State>>(
{
id: 'gitlens.views.commitDetails',
fileName: 'commitDetails.html',
title: 'Commit Details',
contextKeyPrefix: `gitlens:webviewView:commitDetails`,
trackingFeature: 'commitDetailsView',
plusFeature: false,
webviewViewOptions: {
retainContextWhenHidden: false,
},
},
async (container, host) => {
const { CommitDetailsWebviewProvider } = await import(
/* webpackChunkName: "commitDetails" */ './commitDetailsWebview'
);
return new CommitDetailsWebviewProvider(container, id, host);
return new CommitDetailsWebviewProvider(container, host);
},
});
);
}

+ 3
- 8
src/webviews/home/homeWebview.ts View File

@ -2,7 +2,6 @@ import type { ConfigurationChangeEvent } from 'vscode';
import { Disposable, window } from 'vscode';
import { getAvatarUriFromGravatarEmail } from '../../avatars';
import { ViewsLayout } from '../../commands/setViewsLayout';
import type { WebviewIds, WebviewViewIds } from '../../constants';
import type { Container } from '../../container';
import { getContext, onDidChangeContext } from '../../context';
import type { RepositoriesVisibility } from '../../git/gitProviderService';
@ -32,11 +31,7 @@ import {
export class HomeWebviewProvider implements WebviewProvider<State> {
private readonly _disposable: Disposable;
constructor(
readonly container: Container,
readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
readonly host: WebviewController<State>,
) {
constructor(private readonly container: Container, private readonly host: WebviewController<State>) {
this._disposable = Disposable.from(
this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
onDidChangeContext(key => {
@ -95,7 +90,7 @@ export class HomeWebviewProvider implements WebviewProvider {
registerCommands(): Disposable[] {
return [
registerCommand(`${this.id}.refresh`, () => this.host.refresh(), this),
registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(), this),
registerCommand('gitlens.home.toggleWelcome', async () => {
const welcomeVisible = !this.welcomeVisible;
await this.container.storage.store('views:welcome:visible', welcomeVisible);
@ -255,7 +250,7 @@ export class HomeWebviewProvider implements WebviewProvider {
};
};
return window.withProgress({ location: { viewId: this.id } }, async () =>
return window.withProgress({ location: { viewId: this.host.id } }, async () =>
this.host.notify(DidChangeSubscriptionNotificationType, await getSub()),
);
}

+ 15
- 9
src/webviews/home/registration.ts View File

@ -2,15 +2,21 @@ import type { WebviewsController } from '../webviewsController';
import type { State } from './protocol';
export function registerHomeWebviewView(controller: WebviewsController) {
return controller.registerWebviewView<State>('gitlens.views.home', {
fileName: 'home.html',
title: 'Home',
contextKeyPrefix: `gitlens:webviewView:home`,
trackingFeature: 'homeView',
plusFeature: false,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewView<State>(
{
id: 'gitlens.views.home',
fileName: 'home.html',
title: 'Home',
contextKeyPrefix: `gitlens:webviewView:home`,
trackingFeature: 'homeView',
plusFeature: false,
webviewViewOptions: {
retainContextWhenHidden: false,
},
},
async (container, host) => {
const { HomeWebviewProvider } = await import(/* webpackChunkName: "home" */ './homeWebview');
return new HomeWebviewProvider(container, id, host);
return new HomeWebviewProvider(container, host);
},
});
);
}

+ 20
- 11
src/webviews/settings/registration.ts View File

@ -1,22 +1,31 @@
import { Disposable } from 'vscode';
import { Disposable, ViewColumn } from 'vscode';
import { Commands } from '../../constants';
import { registerCommand } from '../../system/command';
import type { WebviewPanelProxy, WebviewsController } from '../webviewsController';
import type { State } from './protocol';
export function registerSettingsWebviewPanel(controller: WebviewsController) {
return controller.registerWebviewPanel<State>(Commands.ShowSettingsPage, 'gitlens.settings', {
fileName: 'settings.html',
iconPath: 'images/gitlens-icon.png',
title: 'GitLens Settings',
contextKeyPrefix: `gitlens:webview:settings`,
trackingFeature: 'settingsWebview',
plusFeature: false,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewPanel<State>(
Commands.ShowSettingsPage,
{
id: 'gitlens.settings',
fileName: 'settings.html',
iconPath: 'images/gitlens-icon.png',
title: 'GitLens Settings',
contextKeyPrefix: `gitlens:webview:settings`,
trackingFeature: 'settingsWebview',
plusFeature: false,
column: ViewColumn.Beside,
panelOptions: {
retainContextWhenHidden: false,
enableFindWidget: true,
},
},
async (container, host) => {
const { SettingsWebviewProvider } = await import(/* webpackChunkName: "settings" */ './settingsWebview');
return new SettingsWebviewProvider(container, id, host);
return new SettingsWebviewProvider(container, host);
},
});
);
}
export function registerSettingsWebviewCommands(webview: WebviewPanelProxy) {

+ 91
- 72
src/webviews/webviewController.ts View File

@ -14,7 +14,6 @@ import { setContext } from '../context';
import { executeCommand } from '../system/command';
import { debug, logName } from '../system/decorators/log';
import { serialize } from '../system/decorators/serialize';
import type { TrackedUsageFeatures } from '../telemetry/usageTracker';
import type { IpcMessage, IpcMessageParams, IpcNotificationType, WebviewFocusChangedParams } from './protocol';
import { ExecuteCommandType, onIpc, WebviewFocusChangedCommandType, WebviewReadyCommandType } from './protocol';
import type { WebviewPanelDescriptor, WebviewViewDescriptor } from './webviewsController';
@ -33,6 +32,12 @@ function nextIpcId() {
return `host:${ipcSequence}`;
}
type GetParentType<T extends WebviewPanelDescriptor | WebviewViewDescriptor> = T extends WebviewPanelDescriptor
? WebviewPanel
: T extends WebviewViewDescriptor
? WebviewView
: never;
export interface WebviewProvider<State, SerializedState = State> extends Disposable {
canShowWebviewPanel?(
firstTime: boolean,
@ -65,95 +70,99 @@ export interface WebviewProvider extends Disposa
onWindowFocusChanged?(focused: boolean): void;
}
type WebviewPanelController<State, SerializedState = State> = WebviewController<
State,
SerializedState,
WebviewPanelDescriptor
>;
type WebviewViewController<State, SerializedState = State> = WebviewController<
State,
SerializedState,
WebviewViewDescriptor
>;
@logName<WebviewController<any>>((c, name) => `${name}(${c.id})`)
export class WebviewController<State, SerializedState = State> implements Disposable {
export class WebviewController<
State,
SerializedState = State,
Descriptor extends WebviewPanelDescriptor | WebviewViewDescriptor = WebviewPanelDescriptor | WebviewViewDescriptor,
> implements Disposable
{
static async create<State, SerializedState = State>(
container: Container,
id: `gitlens.${WebviewIds}`,
webview: Webview,
descriptor: WebviewPanelDescriptor,
parent: WebviewPanel,
metadata: WebviewPanelDescriptor<State, SerializedState>,
): Promise<WebviewController<State, SerializedState>>;
resolveProvider: (
container: Container,
controller: WebviewController<State, SerializedState>,
) => Promise<WebviewProvider<State, SerializedState>>,
): Promise<WebviewController<State, SerializedState, WebviewPanelDescriptor>>;
static async create<State, SerializedState = State>(
container: Container,
id: `gitlens.views.${WebviewViewIds}`,
webview: Webview,
descriptor: WebviewViewDescriptor,
parent: WebviewView,
metadata: WebviewViewDescriptor<State, SerializedState>,
): Promise<WebviewController<State, SerializedState>>;
resolveProvider: (
container: Container,
controller: WebviewController<State, SerializedState>,
) => Promise<WebviewProvider<State, SerializedState>>,
): Promise<WebviewController<State, SerializedState, WebviewViewDescriptor>>;
static async create<State, SerializedState = State>(
container: Container,
id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
webview: Webview,
descriptor: WebviewPanelDescriptor | WebviewViewDescriptor,
parent: WebviewPanel | WebviewView,
metadata: WebviewPanelDescriptor<State, SerializedState> | WebviewViewDescriptor<State, SerializedState>,
resolveProvider: (
container: Container,
controller: WebviewController<State, SerializedState>,
) => Promise<WebviewProvider<State, SerializedState>>,
): Promise<WebviewController<State, SerializedState>> {
const controller = new WebviewController<State, SerializedState>(
container,
id,
webview,
descriptor,
parent,
metadata.title,
metadata.fileName,
metadata.contextKeyPrefix,
metadata.trackingFeature,
host => metadata.resolveWebviewProvider(container, id, host),
resolveProvider,
);
await controller.initialize();
return controller;
}
private readonly _onDidDispose = new EventEmitter<WebviewController<State, SerializedState>>();
private readonly _onDidDispose = new EventEmitter<void>();
get onDidDispose() {
return this._onDidDispose.event;
}
public readonly id: Descriptor['id'];
private _isReady: boolean = false;
get isReady() {
return this._isReady;
}
readonly type: 'tab' | 'view';
isType(type: 'tab'): this is WebviewController<State, SerializedState> & {
type: 'tab';
id: `gitlens.${WebviewIds}`;
parent: WebviewPanel;
};
isType(type: 'view'): this is WebviewController<State, SerializedState> & {
type: 'view';
id: `gitlens.views.${WebviewViewIds}`;
parent: WebviewView;
};
isType(type: 'tab' | 'view') {
return this.type === type;
}
private readonly disposables: Disposable[] = [];
private /*readonly*/ provider!: WebviewProvider<State, SerializedState>;
private readonly webview: Webview;
private constructor(
private readonly container: Container,
public readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
public readonly webview: Webview,
public readonly parent: WebviewPanel | WebviewView,
title: string,
private readonly fileName: string,
private readonly contextKeyPrefix: `gitlens:webview:${WebviewIds}` | `gitlens:webviewView:${WebviewViewIds}`,
private readonly trackingFeature: TrackedUsageFeatures,
private readonly descriptor: Descriptor,
public readonly parent: GetParentType<Descriptor>,
resolveProvider: (
container: Container,
host: WebviewController<State, SerializedState>,
) => Promise<WebviewProvider<State, SerializedState>>,
) {
this.id = descriptor.id;
this.webview = parent.webview;
const isInTab = 'onDidChangeViewState' in parent;
this.type = isInTab ? 'tab' : 'view';
this._originalTitle = title;
parent.title = title;
this._isTab = isInTab;
this._originalTitle = descriptor.title;
parent.title = descriptor.title;
this._initializing = resolveProvider(this).then(provider => {
this._initializing = resolveProvider(container, this).then(provider => {
this.provider = provider;
this.disposables.push(
window.onDidChangeWindowState(this.onWindowStateChanged, this),
webview.onDidReceiveMessage(this.onMessageReceivedCore, this),
parent.webview.onDidReceiveMessage(this.onMessageReceivedCore, this),
isInTab
? parent.onDidChangeViewState(this.onParentViewStateChanged, this)
: parent.onDidChangeVisibility(() => this.onParentVisibilityChanged(this.visible), this),
@ -164,26 +173,34 @@ export class WebviewController implements Dispos
});
}
private _initializing: Promise<void> | undefined;
private async initialize() {
if (this._initializing == null) return;
await this._initializing;
this._initializing = undefined;
}
dispose() {
resetContextKeys(this.contextKeyPrefix);
resetContextKeys(this.descriptor.contextKeyPrefix);
this.provider.onFocusChanged?.(false);
this.provider.onVisibilityChanged?.(false);
this._isReady = false;
this._onDidDispose.fire(this);
this._onDidDispose.fire();
this.disposables.forEach(d => void d.dispose());
}
private _initializing: Promise<void> | undefined;
private async initialize() {
if (this._initializing == null) return;
await this._initializing;
this._initializing = undefined;
}
private _isTab: boolean;
isTab(): this is WebviewPanelController<State, SerializedState> {
return this._isTab;
}
isView(): this is WebviewViewController<State, SerializedState> {
return !this._isTab;
}
private _description: string | undefined;
get description(): string | undefined {
if ('description' in this.parent) {
@ -219,7 +236,7 @@ export class WebviewController implements Dispos
options = {};
}
if (this.isType('tab')) {
if (this.isTab()) {
const result = await this.provider.canShowWebviewPanel?.(firstTime, options, ...args);
if (result === false) return;
@ -228,10 +245,13 @@ export class WebviewController implements Dispos
}
await this.provider.onShowWebviewPanel?.(firstTime, options, ...args);
if (firstTime) {
this.parent.reveal(this.parent.viewColumn ?? ViewColumn.Active, options?.preserveFocus ?? false);
if (!firstTime) {
this.parent.reveal(
options?.column ?? this.parent.viewColumn ?? this.descriptor.column ?? ViewColumn.Beside,
options?.preserveFocus ?? false,
);
}
} else if (this.isType('view')) {
} else if (this.isView()) {
const result = await this.provider.canShowWebviewView?.(firstTime, options, ...args);
if (result === false) return;
@ -322,7 +342,7 @@ export class WebviewController implements Dispos
args: { 0: e => `focused=${e.focused}, inputFocused=${e.inputFocused}` },
})
onViewFocusChanged(e: WebviewFocusChangedParams): void {
setContextKeys(this.contextKeyPrefix, undefined, e.focused, e.inputFocused);
setContextKeys(this.descriptor.contextKeyPrefix, undefined, e.focused, e.inputFocused);
this.provider.onFocusChanged?.(e.focused);
}
@ -332,13 +352,13 @@ export class WebviewController implements Dispos
private onParentViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent): void {
const { active, visible } = e.webviewPanel;
if (visible) {
setContextKeys(this.contextKeyPrefix, active);
setContextKeys(this.descriptor.contextKeyPrefix, active);
this.provider.onActiveChanged?.(active);
if (!active) {
this.provider.onFocusChanged?.(false);
}
} else {
resetContextKeys(this.contextKeyPrefix);
resetContextKeys(this.descriptor.contextKeyPrefix);
this.provider.onActiveChanged?.(false);
this.provider.onFocusChanged?.(false);
@ -348,12 +368,11 @@ export class WebviewController implements Dispos
}
@debug()
private async onParentVisibilityChanged(visible: boolean) {
private onParentVisibilityChanged(visible: boolean) {
if (visible) {
void this.container.usage.track(`${this.trackingFeature}:shown`);
await this.refresh();
void this.container.usage.track(`${this.descriptor.trackingFeature}:shown`);
} else {
resetContextKeys(this.contextKeyPrefix);
resetContextKeys(this.descriptor.contextKeyPrefix);
this.provider.onFocusChanged?.(false);
}
this.provider.onVisibilityChanged?.(visible);
@ -387,7 +406,7 @@ export class WebviewController implements Dispos
private async getHtml(webview: Webview): Promise<string> {
const webRootUri = this.getWebRootUri();
const uri = Uri.joinPath(webRootUri, this.fileName);
const uri = Uri.joinPath(webRootUri, this.descriptor.fileName);
const [bytes, bootstrap, head, body, endOfBody] = await Promise.all([
workspace.fs.readFile(uri),
@ -403,7 +422,7 @@ export class WebviewController implements Dispos
this._cspNonce,
this.asWebviewUri(this.getRootUri()).toString(),
this.getWebRoot(),
this.type,
this.isTab() ? 'tab' : 'view',
bootstrap,
head,
body,
@ -452,7 +471,7 @@ export function replaceWebviewHtmlTokens(
cspNonce: string,
root: string,
webRoot: string,
placement: WebviewController<any>['type'],
placement: 'tab' | 'view',
bootstrap?: SerializedState,
head?: string,
body?: string,

+ 4
- 8
src/webviews/webviewWithConfigBase.ts View File

@ -1,6 +1,6 @@
import type { ConfigurationChangeEvent, Disposable } from 'vscode';
import { ConfigurationTarget } from 'vscode';
import type { CoreConfiguration, WebviewIds, WebviewViewIds } from '../constants';
import type { CoreConfiguration } from '../constants';
import { extensionPrefix } from '../constants';
import type { Container } from '../container';
import { CommitFormatter } from '../git/formatters/commitFormatter';
@ -25,11 +25,7 @@ import type { WebviewController, WebviewProvider } from './webviewController';
export abstract class WebviewProviderWithConfigBase<State> implements WebviewProvider<State> {
private readonly _disposable: Disposable;
constructor(
readonly container: Container,
readonly id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
readonly host: WebviewController<State>,
) {
constructor(protected readonly container: Container, protected readonly host: WebviewController<State>) {
this._disposable = configuration.onDidChangeAny(this.onAnyConfigurationChanged, this);
}
@ -49,7 +45,7 @@ export abstract class WebviewProviderWithConfigBase implements WebviewPro
switch (e.method) {
case UpdateConfigurationCommandType.method:
Logger.debug(`Webview(${this.id}).onMessageReceived: method=${e.method}`);
Logger.debug(`Webview(${this.host.id}).onMessageReceived: method=${e.method}`);
onIpc(UpdateConfigurationCommandType, e, async params => {
const target =
@ -96,7 +92,7 @@ export abstract class WebviewProviderWithConfigBase implements WebviewPro
break;
case GenerateConfigurationPreviewCommandType.method:
Logger.debug(`Webview(${this.id}).onMessageReceived: method=${e.method}`);
Logger.debug(`Webview(${this.host.id}).onMessageReceived: method=${e.method}`);
onIpc(GenerateConfigurationPreviewCommandType, e, async params => {
switch (params.type) {

+ 125
- 126
src/webviews/webviewsController.ts View File

@ -14,78 +14,64 @@ import type { TrackedUsageFeatures } from '../telemetry/usageTracker';
import type { WebviewProvider } from './webviewController';
import { WebviewController } from './webviewController';
export interface WebviewPanelDescriptor<State = any, SerializedState = State> {
export interface WebviewPanelDescriptor {
id: `gitlens.${WebviewIds}`;
readonly fileName: string;
readonly iconPath: string;
readonly title: string;
readonly contextKeyPrefix: `gitlens:webview:${WebviewIds}`;
readonly trackingFeature: TrackedUsageFeatures;
readonly plusFeature: boolean;
readonly column?: ViewColumn;
readonly options?: WebviewOptions;
readonly panelOptions?: WebviewPanelOptions;
}
interface WebviewPanelRegistration<State, SerializedState = State> {
readonly descriptor: WebviewPanelDescriptor;
controller?: WebviewController<State, SerializedState, WebviewPanelDescriptor> | undefined;
}
canResolveWebviewProvider?(
container: Container,
id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
): boolean | Promise<boolean>;
resolveWebviewProvider(
container: Container,
id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
host: WebviewController<State, SerializedState>,
): Promise<WebviewProvider<State, SerializedState>>;
export interface WebviewPanelProxy extends Disposable {
readonly id: `gitlens.${WebviewIds}`;
readonly loaded: boolean;
readonly visible: boolean;
close(): void;
refresh(force?: boolean): Promise<void>;
show(options?: { column?: ViewColumn; preserveFocus?: boolean }, ...args: unknown[]): Promise<void>;
}
export interface WebviewViewDescriptor<State = any, SerializedState = State> {
export interface WebviewViewDescriptor {
id: `gitlens.views.${WebviewViewIds}`;
readonly fileName: string;
readonly title: string;
readonly contextKeyPrefix: `gitlens:webviewView:${WebviewViewIds}`;
readonly trackingFeature: TrackedUsageFeatures;
readonly plusFeature: boolean;
readonly options?: WebviewOptions;
canResolveWebviewProvider?(
container: Container,
id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
): boolean | Promise<boolean>;
resolveWebviewProvider(
container: Container,
id: `gitlens.${WebviewIds}` | `gitlens.views.${WebviewViewIds}`,
host: WebviewController<State, SerializedState>,
): Promise<WebviewProvider<State, SerializedState>>;
readonly webviewViewOptions?: {
readonly retainContextWhenHidden?: boolean;
};
}
interface WebviewPanelMetadata<State = any, SerializedState = State> {
readonly id: `gitlens.${WebviewIds}`;
readonly descriptor: WebviewPanelDescriptor<State, SerializedState>;
webview?: WebviewController<State, SerializedState> | undefined;
}
interface WebviewViewMetadata<State = any, SerializedState = State> {
readonly id: `gitlens.views.${WebviewViewIds}`;
readonly descriptor: WebviewViewDescriptor<State, SerializedState>;
webview?: WebviewController<State, SerializedState> | undefined;
interface WebviewViewRegistration<State, SerializedState = State> {
readonly descriptor: WebviewViewDescriptor;
controller?: WebviewController<State, SerializedState, WebviewViewDescriptor> | undefined;
pendingShowArgs?: Parameters<WebviewViewProxy['show']> | undefined;
}
export interface WebviewViewProxy extends Disposable {
readonly id: string;
readonly id: `gitlens.views.${WebviewViewIds}`;
readonly loaded: boolean;
readonly visible: boolean;
refresh(force?: boolean): Promise<void>;
show(options?: { preserveFocus?: boolean }, ...args: unknown[]): Promise<void>;
}
export interface WebviewPanelProxy extends Disposable {
readonly id: string;
readonly visible: boolean;
refresh(force?: boolean): Promise<void>;
show(options?: { column?: ViewColumn; preserveFocus?: boolean }, ...args: unknown[]): Promise<void>;
}
export class WebviewsController implements Disposable {
private readonly disposables: Disposable[] = [];
private readonly _panels = new Map<string, WebviewPanelMetadata>();
private readonly _views = new Map<string, WebviewViewMetadata>();
private readonly _panels = new Map<string, WebviewPanelRegistration<any>>();
private readonly _views = new Map<string, WebviewViewRegistration<any>>();
constructor(private readonly container: Container) {}
@ -94,96 +80,107 @@ export class WebviewsController implements Disposable {
}
registerWebviewView<State, SerializedState = State>(
id: `gitlens.views.${WebviewViewIds}`,
descriptor: Omit<WebviewViewDescriptor<State, SerializedState>, 'id'>,
descriptor: WebviewViewDescriptor,
resolveProvider: (
container: Container,
host: WebviewController<State, SerializedState>,
) => Promise<WebviewProvider<State, SerializedState>>,
canResolveProvider?: () => boolean | Promise<boolean>,
): WebviewViewProxy {
const metadata: WebviewViewMetadata<State, SerializedState> = { id: id, descriptor: descriptor };
this._views.set(id, metadata);
const registration: WebviewViewRegistration<State, SerializedState> = { descriptor: descriptor };
this._views.set(descriptor.id, registration);
const disposables: Disposable[] = [];
disposables.push(
window.registerWebviewViewProvider(id, {
resolveWebviewView: async (
webviewView: WebviewView,
_context: WebviewViewResolveContext<SerializedState>,
token: CancellationToken,
) => {
if (metadata.descriptor.canResolveWebviewProvider != null) {
if ((await metadata.descriptor.canResolveWebviewProvider(this.container, id)) === false) return;
}
if (metadata.descriptor.plusFeature) {
if (!(await ensurePlusFeaturesEnabled())) return;
if (token.isCancellationRequested) return;
}
webviewView.webview.options = {
...descriptor.options,
enableCommandUris: true,
enableScripts: true,
localResourceRoots: [Uri.file(this.container.context.extensionPath)],
};
webviewView.title = descriptor.title;
const webview = await WebviewController.create(
this.container,
id,
webviewView.webview,
webviewView,
descriptor,
);
metadata.webview = webview;
disposables.push(
webview.onDidDispose(() => {
metadata.pendingShowArgs = undefined;
metadata.webview = undefined;
}, this),
webview,
);
if (metadata.pendingShowArgs != null) {
await webview.show(true, ...metadata.pendingShowArgs);
metadata.pendingShowArgs = undefined;
} else {
await webview.show(true);
}
window.registerWebviewViewProvider(
descriptor.id,
{
resolveWebviewView: async (
webviewView: WebviewView,
_context: WebviewViewResolveContext<SerializedState>,
token: CancellationToken,
) => {
if (canResolveProvider != null) {
if ((await canResolveProvider()) === false) return;
}
if (registration.descriptor.plusFeature) {
if (!(await ensurePlusFeaturesEnabled())) return;
if (token.isCancellationRequested) return;
}
webviewView.webview.options = {
enableCommandUris: true,
enableScripts: true,
localResourceRoots: [Uri.file(this.container.context.extensionPath)],
...descriptor.options,
};
webviewView.title = descriptor.title;
const controller = await WebviewController.create(
this.container,
descriptor,
webviewView,
resolveProvider,
);
registration.controller = controller;
disposables.push(
controller.onDidDispose(() => {
registration.pendingShowArgs = undefined;
registration.controller = undefined;
}, this),
controller,
);
if (registration.pendingShowArgs != null) {
await controller.show(true, ...registration.pendingShowArgs);
registration.pendingShowArgs = undefined;
} else {
await controller.show(true);
}
},
},
}),
descriptor.webviewViewOptions != null ? { webviewOptions: descriptor.webviewViewOptions } : undefined,
),
);
const disposable = Disposable.from(...disposables);
this.disposables.push(disposable);
return {
id: id,
id: descriptor.id,
get loaded() {
return metadata.webview != null;
return registration.controller != null;
},
get visible() {
return metadata.webview?.visible ?? false;
return registration.controller?.visible ?? false;
},
dispose: function () {
disposable.dispose();
},
refresh: async force => metadata.webview?.refresh(force),
refresh: async force => registration.controller?.refresh(force),
// eslint-disable-next-line @typescript-eslint/require-await
show: async (options?: { preserveFocus?: boolean }, ...args) => {
if (metadata.webview != null) return void metadata.webview.show(false, options, ...args);
if (registration.controller != null) return void registration.controller.show(false, options, ...args);
metadata.pendingShowArgs = [options, ...args];
return void executeCommand(`${id}.focus`, options);
registration.pendingShowArgs = [options, ...args];
return void executeCommand(`${descriptor.id}.focus`, options);
},
} satisfies WebviewViewProxy;
}
registerWebviewPanel<State, SerializedState = State>(
command: Commands,
id: `gitlens.${WebviewIds}`,
descriptor: Omit<WebviewPanelDescriptor<State, SerializedState>, 'id'>,
descriptor: WebviewPanelDescriptor,
resolveProvider: (
container: Container,
host: WebviewController<State, SerializedState>,
) => Promise<WebviewProvider<State, SerializedState>>,
canResolveProvider?: () => boolean | Promise<boolean>,
): WebviewPanelProxy {
const metadata: WebviewPanelMetadata<State, SerializedState> = { id: id, descriptor: descriptor };
this._panels.set(id, metadata);
const registration: WebviewPanelRegistration<State, SerializedState> = { descriptor: descriptor };
this._panels.set(descriptor.id, registration);
const disposables: Disposable[] = [];
const { container } = this;
@ -192,54 +189,52 @@ export class WebviewsController implements Disposable {
options?: { column?: ViewColumn; preserveFocus?: boolean },
...args: unknown[]
): Promise<void> {
const { webview, descriptor } = metadata;
if (descriptor.canResolveWebviewProvider != null) {
if ((await descriptor.canResolveWebviewProvider(container, id)) === false) return;
if (canResolveProvider != null) {
if ((await canResolveProvider()) === false) return;
}
const { descriptor } = registration;
if (descriptor.plusFeature) {
if (!(await ensurePlusFeaturesEnabled())) return;
}
void container.usage.track(`${descriptor.trackingFeature}:shown`);
let column = options?.column ?? ViewColumn.Beside;
let column = options?.column ?? descriptor.column ?? ViewColumn.Beside;
// Only try to open beside if there is an active tab
if (column === ViewColumn.Beside && window.tabGroups.activeTabGroup.activeTab == null) {
column = ViewColumn.Active;
}
if (webview == null) {
let { controller } = registration;
if (controller == null) {
const panel = window.createWebviewPanel(
metadata.id,
descriptor.id,
descriptor.title,
{ viewColumn: column, preserveFocus: options?.preserveFocus ?? false },
{
...descriptor.panelOptions,
...(descriptor.options ?? {
retainContextWhenHidden: true,
enableFindWidget: true,
...{
enableCommandUris: true,
enableScripts: true,
localResourceRoots: [Uri.file(container.context.extensionPath)],
}),
},
...descriptor.options,
...descriptor.panelOptions,
},
);
panel.iconPath = Uri.file(container.context.asAbsolutePath(descriptor.iconPath));
const webview = await WebviewController.create(container, id, panel.webview, panel, descriptor);
metadata.webview = webview;
controller = await WebviewController.create(container, descriptor, panel, resolveProvider);
registration.controller = controller;
disposables.push(
webview.onDidDispose(() => (metadata.webview = undefined)),
webview,
controller.onDidDispose(() => (registration.controller = undefined)),
controller,
);
await webview.show(true, options, ...args);
await controller.show(true, options, ...args);
} else {
await webview.show(false, options, ...args);
await controller.show(false, options, ...args);
}
}
@ -249,14 +244,18 @@ export class WebviewsController implements Disposable {
);
this.disposables.push(disposable);
return {
id: id,
id: descriptor.id,
get loaded() {
return registration.controller != null;
},
get visible() {
return metadata.webview?.visible ?? false;
return registration.controller?.visible ?? false;
},
dispose: function () {
disposable.dispose();
},
refresh: async force => metadata.webview?.refresh(force),
close: () => void registration.controller?.parent.dispose(),
refresh: async force => registration.controller?.refresh(force),
show: show,
} satisfies WebviewPanelProxy;
}

+ 20
- 10
src/webviews/welcome/registration.ts View File

@ -1,18 +1,28 @@
import { ViewColumn } from 'vscode';
import { Commands } from '../../constants';
import type { WebviewsController } from '../webviewsController';
import type { State } from './protocol';
export function registerWelcomeWebviewPanel(controller: WebviewsController) {
return controller.registerWebviewPanel<State>(Commands.ShowWelcomePage, 'gitlens.welcome', {
fileName: 'welcome.html',
iconPath: 'images/gitlens-icon.png',
title: 'Welcome to GitLens',
contextKeyPrefix: `gitlens:webview:welcome`,
trackingFeature: 'welcomeWebview',
plusFeature: false,
resolveWebviewProvider: async function (container, id, host) {
return controller.registerWebviewPanel<State>(
Commands.ShowWelcomePage,
{
id: 'gitlens.welcome',
fileName: 'welcome.html',
iconPath: 'images/gitlens-icon.png',
title: 'Welcome to GitLens',
contextKeyPrefix: `gitlens:webview:welcome`,
trackingFeature: 'welcomeWebview',
plusFeature: false,
column: ViewColumn.Beside,
panelOptions: {
retainContextWhenHidden: false,
enableFindWidget: true,
},
},
async (container, host) => {
const { WelcomeWebviewProvider } = await import(/* webpackChunkName: "welcome" */ './welcomeWebview');
return new WelcomeWebviewProvider(container, id, host);
return new WelcomeWebviewProvider(container, host);
},
});
);
}

Loading…
Cancel
Save