Browse Source

Reworks webviews & splits protocols individually

Improves DOM methods
main
Eric Amodio 2 years ago
parent
commit
455c375b99
17 changed files with 885 additions and 817 deletions
  1. +46
    -53
      src/webviews/apps/rebase/rebase.ts
  2. +18
    -24
      src/webviews/apps/settings/settings.ts
  3. +46
    -12
      src/webviews/apps/shared/appBase.ts
  4. +31
    -58
      src/webviews/apps/shared/appWithConfigBase.ts
  5. +31
    -23
      src/webviews/apps/shared/dom.ts
  6. +6
    -0
      src/webviews/apps/shared/theme.ts
  7. +2
    -2
      src/webviews/apps/welcome/welcome.ts
  8. +48
    -131
      src/webviews/protocol.ts
  9. +69
    -0
      src/webviews/rebase/protocol.ts
  10. +26
    -27
      src/webviews/rebase/rebaseEditor.ts
  11. +14
    -0
      src/webviews/settings/protocol.ts
  12. +25
    -52
      src/webviews/settings/settingsWebview.ts
  13. +99
    -263
      src/webviews/webviewBase.ts
  14. +228
    -0
      src/webviews/webviewWithConfigBase.ts
  15. +6
    -0
      src/webviews/welcome/protocol.ts
  16. +14
    -22
      src/webviews/welcome/welcomeWebview.ts
  17. +176
    -150
      webpack.config.js

+ 46
- 53
src/webviews/apps/rebase/rebase.ts View File

@ -1,19 +1,19 @@
/*global document window*/
import '../scss/rebase.scss';
import Sortable from 'sortablejs';
import { onIpc } from '../../protocol';
import {
onIpcNotification,
RebaseDidAbortCommandType,
RebaseDidChangeEntryCommandType,
RebaseDidChangeNotificationType,
RebaseDidDisableCommandType,
RebaseDidMoveEntryCommandType,
RebaseDidStartCommandType,
RebaseDidSwitchCommandType,
AbortCommandType,
ChangeEntryCommandType,
DidChangeNotificationType,
DisableCommandType,
MoveEntryCommandType,
RebaseEntry,
RebaseEntryAction,
RebaseState,
} from '../../protocol';
StartCommandType,
State,
SwitchCommandType,
} from '../../rebase/protocol';
import { App } from '../shared/appBase';
import { DOM } from '../shared/dom';
// import { Snow } from '../shared/snow';
@ -34,7 +34,7 @@ const rebaseActionsMap = new Map([
['D', 'drop'],
]);
class RebaseEditor extends App<RebaseState> {
class RebaseEditor extends App<State> {
private readonly commitTokenRegex = new RegExp(encodeURIComponent(`\${commit}`));
constructor() {
@ -52,9 +52,6 @@ class RebaseEditor extends App {
protected override onBind() {
const disposables = super.onBind?.() ?? [];
// eslint-disable-next-line @typescript-eslint/no-this-alias
const me = this;
const $container = document.getElementById('entries')!;
Sortable.create($container, {
animation: 150,
@ -108,7 +105,7 @@ class RebaseEditor extends App {
}
disposables.push(
DOM.on(window, 'keydown', (e: KeyboardEvent) => {
DOM.on(window, 'keydown', e => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'Enter' || e.key === 'r') {
e.preventDefault();
@ -127,78 +124,78 @@ class RebaseEditor extends App {
DOM.on('[data-action="abort"]', 'click', () => this.onAbortClicked()),
DOM.on('[data-action="disable"]', 'click', () => this.onDisableClicked()),
DOM.on('[data-action="switch"]', 'click', () => this.onSwitchClicked()),
DOM.on('li[data-ref]', 'keydown', function (this: Element, e: KeyboardEvent) {
if ((e.target as HTMLElement).matches('select[data-ref]')) {
DOM.on('li[data-ref]', 'keydown', (e, target: HTMLElement) => {
if (target.matches('select[data-ref]')) {
if (e.key === 'Escape') {
(this as HTMLLIElement).focus();
target.focus();
}
return;
}
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === 'Enter' && (e.target as HTMLElement).matches('a.entry-ref')) {
if (e.key === 'Enter' && target.matches('a.entry-ref')) {
return;
}
const $select = (this as HTMLLIElement).querySelectorAll<HTMLSelectElement>('select[data-ref]')[0];
const $select = target.querySelectorAll<HTMLSelectElement>('select[data-ref]')[0];
if ($select != null) {
$select.focus();
}
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (!e.metaKey && !e.ctrlKey && !e.shiftKey) {
if (e.altKey) {
const ref = (this as HTMLLIElement).dataset.ref;
const ref = target.dataset.ref;
if (ref) {
e.stopPropagation();
me.moveEntry(ref, e.key === 'ArrowDown' ? 1 : -1, true);
this.moveEntry(ref, e.key === 'ArrowDown' ? 1 : -1, true);
}
} else {
if (me.state == null) return;
if (this.state == null) return;
let ref = (this as HTMLLIElement).dataset.ref;
let ref = target.dataset.ref;
if (ref == null) return;
e.preventDefault();
let index = me.getEntryIndex(ref) + (e.key === 'ArrowDown' ? 1 : -1);
let index = this.getEntryIndex(ref) + (e.key === 'ArrowDown' ? 1 : -1);
if (index < 0) {
index = me.state.entries.length - 1;
} else if (index === me.state.entries.length) {
index = this.state.entries.length - 1;
} else if (index === this.state.entries.length) {
index = 0;
}
ref = me.state.entries[index].ref;
ref = this.state.entries[index].ref;
document.querySelectorAll<HTMLLIElement>(`li[data-ref="${ref}"]`)[0]?.focus();
}
}
} else if (e.key === 'j' || e.key === 'k') {
if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (me.state == null) return;
if (this.state == null) return;
let ref = (this as HTMLLIElement).dataset.ref;
let ref = target.dataset.ref;
if (ref == null) return;
e.preventDefault();
let index = me.getEntryIndex(ref) + (e.key === 'j' ? 1 : -1);
let index = this.getEntryIndex(ref) + (e.key === 'j' ? 1 : -1);
if (index < 0) {
index = me.state.entries.length - 1;
} else if (index === me.state.entries.length) {
index = this.state.entries.length - 1;
} else if (index === this.state.entries.length) {
index = 0;
}
ref = me.state.entries[index].ref;
ref = this.state.entries[index].ref;
document.querySelectorAll<HTMLLIElement>(`li[data-ref="${ref}"]`)[0]?.focus();
}
} else if (e.key === 'J' || e.key === 'K') {
if (!e.metaKey && !e.ctrlKey && !e.altKey && e.shiftKey) {
const ref = (this as HTMLLIElement).dataset.ref;
const ref = target.dataset.ref;
if (ref) {
e.stopPropagation();
me.moveEntry(ref, e.key === 'J' ? 1 : -1, true);
this.moveEntry(ref, e.key === 'J' ? 1 : -1, true);
}
}
} else if (!e.metaKey && !e.altKey && !e.ctrlKey) {
@ -206,19 +203,15 @@ class RebaseEditor extends App {
if (action !== undefined) {
e.stopPropagation();
const $select = (this as HTMLLIElement).querySelectorAll<HTMLSelectElement>(
'select[data-ref]',
)[0];
const $select = target.querySelectorAll<HTMLSelectElement>('select[data-ref]')[0];
if ($select != null && !$select.disabled) {
$select.value = action;
me.onSelectChanged($select);
this.onSelectChanged($select);
}
}
}
}),
DOM.on('select[data-ref]', 'input', function (this: Element) {
return me.onSelectChanged(this as HTMLSelectElement);
}),
DOM.on('select[data-ref]', 'input', (e, target: HTMLSelectElement) => this.onSelectChanged(target)),
);
return disposables;
@ -235,7 +228,7 @@ class RebaseEditor extends App {
private moveEntry(ref: string, index: number, relative: boolean) {
const entry = this.getEntry(ref);
if (entry != null) {
this.sendCommand(RebaseDidMoveEntryCommandType, {
this.sendCommand(MoveEntryCommandType, {
ref: entry.ref,
to: index,
relative: relative,
@ -248,7 +241,7 @@ class RebaseEditor extends App {
if (entry != null) {
if (entry.action === action) return;
this.sendCommand(RebaseDidChangeEntryCommandType, {
this.sendCommand(ChangeEntryCommandType, {
ref: entry.ref,
action: action,
});
@ -256,11 +249,11 @@ class RebaseEditor extends App {
}
private onAbortClicked() {
this.sendCommand(RebaseDidAbortCommandType, {});
this.sendCommand(AbortCommandType, undefined);
}
private onDisableClicked() {
this.sendCommand(RebaseDidDisableCommandType, {});
this.sendCommand(DisableCommandType, undefined);
}
private onSelectChanged($el: HTMLSelectElement) {
@ -271,19 +264,19 @@ class RebaseEditor extends App {
}
private onStartClicked() {
this.sendCommand(RebaseDidStartCommandType, {});
this.sendCommand(StartCommandType, undefined);
}
private onSwitchClicked() {
this.sendCommand(RebaseDidSwitchCommandType, {});
this.sendCommand(SwitchCommandType, undefined);
}
protected override onMessageReceived(e: MessageEvent) {
const msg = e.data;
switch (msg.method) {
case RebaseDidChangeNotificationType.method:
onIpcNotification(RebaseDidChangeNotificationType, msg, params => {
case DidChangeNotificationType.method:
onIpc(DidChangeNotificationType, msg, params => {
this.setState({ ...this.state, ...params.state });
this.refresh(this.state);
});
@ -294,7 +287,7 @@ class RebaseEditor extends App {
}
}
private refresh(state: RebaseState) {
private refresh(state: State) {
const focusRef = document.activeElement?.closest<HTMLLIElement>('li[data-ref]')?.dataset.ref;
let focusSelect = false;
if (document.activeElement?.matches('select[data-ref]')) {
@ -395,7 +388,7 @@ class RebaseEditor extends App {
private createEntry(
entry: RebaseEntry,
state: RebaseState,
state: State,
tabIndex: number,
squashToHere: boolean,
): [HTMLLIElement, number] {

+ 18
- 24
src/webviews/apps/settings/settings.ts View File

@ -1,11 +1,12 @@
/*global window document IntersectionObserver*/
import '../scss/settings.scss';
import { IpcMessage, onIpcNotification, SettingsDidRequestJumpToNotificationType, SettingsState } from '../../protocol';
import { IpcMessage, onIpc } from '../../protocol';
import { DidJumpToNotificationType, State } from '../../settings/protocol';
import { AppWithConfig } from '../shared/appWithConfigBase';
import { DOM } from '../shared/dom';
// import { Snow } from '../shared/snow';
export class SettingsApp extends AppWithConfig<SettingsState> {
export class SettingsApp extends AppWithConfig<State> {
private _scopes: HTMLSelectElement | null = null;
private _observer: IntersectionObserver | undefined;
@ -67,30 +68,25 @@ export class SettingsApp extends AppWithConfig {
protected override onBind() {
const disposables = super.onBind?.() ?? [];
// eslint-disable-next-line @typescript-eslint/no-this-alias
const me = this;
disposables.push(
DOM.on('.section--collapsible>.section__header', 'click', function (this: Element, e: MouseEvent) {
return me.onSectionHeaderClicked(this as HTMLInputElement, e);
}),
DOM.on('.setting--expandable .setting__expander', 'click', function (this: Element, e: MouseEvent) {
return me.onSettingExpanderCicked(this as HTMLInputElement, e);
}),
DOM.on('a[data-action="jump"]', 'mousedown', (e: Event) => {
DOM.on('.section--collapsible>.section__header', 'click', (e, target: HTMLInputElement) =>
this.onSectionHeaderClicked(target, e),
),
DOM.on('.setting--expandable .setting__expander', 'click', (e, target: HTMLInputElement) =>
this.onSettingExpanderCicked(target, e),
),
DOM.on('a[data-action="jump"]', 'mousedown', e => {
e.stopPropagation();
e.preventDefault();
}),
DOM.on('a[data-action="jump"]', 'click', function (this: Element, e: MouseEvent) {
return me.onJumpToLinkClicked(this as HTMLAnchorElement, e);
}),
DOM.on('[data-action]', 'mousedown', (e: Event) => {
DOM.on('a[data-action="jump"]', 'click', (e, target: HTMLAnchorElement) =>
this.onJumpToLinkClicked(target, e),
),
DOM.on('[data-action]', 'mousedown', e => {
e.stopPropagation();
e.preventDefault();
}),
DOM.on('[data-action]', 'click', function (this: Element, e: MouseEvent) {
return me.onActionLinkClicked(this as HTMLAnchorElement, e);
}),
DOM.on('[data-action]', 'click', (e, target: HTMLAnchorElement) => this.onActionLinkClicked(target, e)),
);
return disposables;
@ -100,16 +96,14 @@ export class SettingsApp extends AppWithConfig {
const msg = e.data as IpcMessage;
switch (msg.method) {
case SettingsDidRequestJumpToNotificationType.method:
onIpcNotification(SettingsDidRequestJumpToNotificationType, msg, params => {
case DidJumpToNotificationType.method:
onIpc(DidJumpToNotificationType, msg, params => {
this.scrollToAnchor(params.anchor);
});
break;
default:
if (super.onMessageReceived !== undefined) {
super.onMessageReceived(e);
}
super.onMessageReceived?.(e);
}
}

+ 46
- 12
src/webviews/apps/shared/appBase.ts View File

@ -1,12 +1,20 @@
/*global window document*/
import { IpcCommandParamsOf, IpcCommandType, IpcMessage, ReadyCommandType } from '../../protocol';
import {
IpcCommandType,
IpcMessage,
IpcMessageParams,
IpcNotificationType,
onIpc,
WebviewReadyCommandType,
} from '../../protocol';
import { DOM } from './dom';
import { Disposable } from './events';
import { initializeAndWatchThemeColors } from './theme';
interface VsCodeApi {
postMessage(msg: object): void;
setState(state: object): void;
getState(): object;
postMessage(msg: unknown): void;
setState(state: unknown): void;
getState(): unknown;
}
declare function acquireVsCodeApi(): VsCodeApi;
@ -22,7 +30,7 @@ function nextIpcId() {
return `webview:${ipcSequence}`;
}
export abstract class App<State extends object = any> {
export abstract class App<State = void> {
private readonly _api: VsCodeApi;
protected state: State;
@ -33,7 +41,7 @@ export abstract class App {
initializeAndWatchThemeColors();
this.state = state;
setTimeout(() => {
requestAnimationFrame(() => {
this.log(`${this.appName}.initializing`);
this.onInitialize?.();
@ -43,14 +51,14 @@ export abstract class App {
window.addEventListener('message', this.onMessageReceived.bind(this));
}
this.sendCommand(ReadyCommandType, {});
this.sendCommand(WebviewReadyCommandType, undefined);
this.onInitialized?.();
setTimeout(() => {
document.body.classList.remove('preload');
}, 500);
}, 0);
});
}
protected onInitialize?(): void;
@ -64,16 +72,42 @@ export abstract class App {
this.bindDisposables = this.onBind?.();
}
protected log(_message: string) {
// console.log(message);
protected log(message: string) {
console.log(message);
}
protected getState(): State {
return this._api.getState() as State;
}
protected sendCommand<CT extends IpcCommandType>(type: CT, params: IpcCommandParamsOf<CT>): void {
return this.postMessage({ id: nextIpcId(), method: type.method, params: params });
protected sendCommand<TCommand extends IpcCommandType<any>>(
command: TCommand,
params: IpcMessageParams<TCommand>,
): void {
return this.postMessage({ id: nextIpcId(), method: command.method, params: params });
}
protected sendCommandWithCompletion<
TCommand extends IpcCommandType<any>,
TCompletion extends IpcNotificationType<{ completionId: string }>,
>(
command: TCommand,
params: IpcMessageParams<TCommand>,
completion: TCompletion,
callback: (params: IpcMessageParams<TCompletion>) => void,
): void {
const id = nextIpcId();
const disposable = DOM.on(window, 'message', e => {
onIpc(completion, e.data as IpcMessage, params => {
if (params.completionId === id) {
disposable.dispose();
callback(params);
}
});
});
return this.postMessage({ id: id, method: command.method, params: params });
}
protected setState(state: State) {

+ 31
- 58
src/webviews/apps/shared/appWithConfigBase.ts View File

@ -1,11 +1,11 @@
/*global document*/
import type { Config } from '../../../config';
import {
AppStateWithConfig,
DidChangeConfigurationNotificationType,
DidPreviewConfigurationNotificationType,
DidGenerateConfigurationPreviewNotificationType,
GenerateConfigurationPreviewCommandType,
IpcMessage,
onIpcNotification,
PreviewConfigurationCommandType,
onIpc,
UpdateConfigurationCommandType,
} from '../../protocol';
import { formatDate } from '../shared/date';
@ -17,22 +17,16 @@ const date = new Date(
`Wed Jul 25 2018 19:18:00 GMT${offset >= 0 ? '-' : '+'}${String(Math.abs(offset)).padStart(4, '0')}`,
);
let ipcSequence = 0;
function nextIpcId() {
if (ipcSequence === Number.MAX_SAFE_INTEGER) {
ipcSequence = 1;
} else {
ipcSequence++;
}
return `${ipcSequence}`;
interface AppStateWithConfig {
config: Config;
customSettings?: Record<string, boolean>;
}
export abstract class AppWithConfig<TState extends AppStateWithConfig> extends App<TState> {
export abstract class AppWithConfig<State extends AppStateWithConfig> extends App<State> {
private _changes = Object.create(null) as Record<string, any>;
private _updating: boolean = false;
constructor(appName: string, state: TState) {
constructor(appName: string, state: State) {
super(appName, state);
}
@ -43,40 +37,27 @@ export abstract class AppWithConfig extends A
protected override onBind() {
const disposables = super.onBind?.() ?? [];
// eslint-disable-next-line @typescript-eslint/no-this-alias
const me = this;
disposables.push(
DOM.on('input[type=checkbox][data-setting]', 'change', function (this: HTMLInputElement) {
return me.onInputChecked(this);
}),
DOM.on('input[type=checkbox][data-setting]', 'change', (e, target: HTMLInputElement) =>
this.onInputChecked(target),
),
DOM.on(
'input[type=text][data-setting], input[type=number][data-setting], input:not([type])[data-setting]',
'blur',
function (this: HTMLInputElement) {
return me.onInputBlurred(this);
},
(e, target: HTMLInputElement) => this.onInputBlurred(target),
),
DOM.on(
'input[type=text][data-setting], input[type=number][data-setting], input:not([type])[data-setting]',
'focus',
function (this: HTMLInputElement) {
return me.onInputFocused(this);
},
(e, target: HTMLInputElement) => this.onInputFocused(target),
),
DOM.on(
'input[type=text][data-setting][data-setting-preview], input[type=number][data-setting][data-setting-preview]',
'input',
function (this: HTMLInputElement) {
return me.onInputChanged(this);
},
(e, target: HTMLInputElement) => this.onInputChanged(target),
),
DOM.on('select[data-setting]', 'change', function (this: HTMLSelectElement) {
return me.onInputSelected(this);
}),
DOM.on('.popup', 'mousedown', function (this: HTMLElement, e: Event) {
return me.onPopupMouseDown(this, e as MouseEvent);
}),
DOM.on('select[data-setting]', 'change', (e, target: HTMLSelectElement) => this.onInputSelected(target)),
DOM.on('.popup', 'mousedown', (e, target: HTMLElement) => this.onPopupMouseDown(target, e)),
);
return disposables;
@ -87,7 +68,7 @@ export abstract class AppWithConfig extends A
switch (msg.method) {
case DidChangeConfigurationNotificationType.method:
onIpcNotification(DidChangeConfigurationNotificationType, msg, params => {
onIpc(DidChangeConfigurationNotificationType, msg, params => {
this.state.config = params.config;
this.state.customSettings = params.customSettings;
@ -96,9 +77,7 @@ export abstract class AppWithConfig extends A
break;
default:
if (super.onMessageReceived !== undefined) {
super.onMessageReceived(e);
}
super.onMessageReceived?.(e);
}
}
@ -437,24 +416,18 @@ export abstract class AppWithConfig extends A
return;
}
const id = nextIpcId();
const disposable = DOM.on(window, 'message', (e: MessageEvent) => {
const msg = e.data as IpcMessage;
if (msg.method === DidPreviewConfigurationNotificationType.method && msg.params.id === id) {
disposable.dispose();
onIpcNotification(DidPreviewConfigurationNotificationType, msg, params => {
el.innerText = params.preview ?? '';
});
}
});
this.sendCommand(PreviewConfigurationCommandType, {
key: el.dataset.settingPreview!,
type: 'commit',
id: id,
format: value,
});
this.sendCommandWithCompletion(
GenerateConfigurationPreviewCommandType,
{
key: el.dataset.settingPreview!,
type: 'commit',
format: value,
},
DidGenerateConfigurationPreviewNotificationType,
params => {
el.innerText = params.preview ?? '';
},
);
break;
}

+ 31
- 23
src/webviews/apps/shared/dom.ts View File

@ -5,59 +5,67 @@ export interface Disposable {
}
export namespace DOM {
export function on<K extends keyof DocumentEventMap, T extends Element>(
selector: string,
export function on<K extends keyof WindowEventMap>(
window: Window,
name: K,
listener: (e: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): Disposable;
export function on<K extends keyof DocumentEventMap>(
document: Document,
name: K,
listener: (this: T, ev: DocumentEventMap[K]) => any,
listener: (e: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
el?: Element,
): Disposable;
export function on<K extends keyof DocumentEventMap, T extends Document | Element>(
el: Document | Element,
export function on<K extends keyof DocumentEventMap>(
element: Element,
name: K,
listener: (this: T, ev: DocumentEventMap[K]) => any,
listener: (e: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): Disposable;
export function on<K extends keyof WindowEventMap, T extends Window>(
el: Window,
export function on<T extends Element, K extends keyof DocumentEventMap>(
selector: string,
name: K,
listener: (this: T, ev: WindowEventMap[K]) => any,
listener: (e: DocumentEventMap[K], target: T) => void,
options?: boolean | AddEventListenerOptions,
): Disposable;
export function on<K extends keyof (DocumentEventMap | WindowEventMap), T extends Document | Element | Window>(
selectorOrElement: string | Window | Document | Element,
sourceOrSelector: string | Window | Document | Element,
name: K,
listener: (this: T, ev: (DocumentEventMap | WindowEventMap)[K]) => any,
listener: (e: (DocumentEventMap | WindowEventMap)[K], target: T) => void,
options?: boolean | AddEventListenerOptions,
el?: Element,
): Disposable {
let disposed = false;
if (typeof selectorOrElement === 'string') {
const $els = (el ?? document).querySelectorAll(selectorOrElement);
for (const $el of $els) {
$el.addEventListener(name, listener as EventListener, options ?? false);
}
if (typeof sourceOrSelector === 'string') {
const filteredListener = function (this: T, e: (DocumentEventMap | WindowEventMap)[K]) {
const target = e?.target as HTMLElement;
if (!target?.matches(sourceOrSelector)) return;
listener(e, target as unknown as T);
};
document.addEventListener(name, filteredListener as EventListener, options ?? true);
return {
dispose: () => {
if (disposed) return;
disposed = true;
for (const $el of $els) {
$el.removeEventListener(name, listener as EventListener, options ?? false);
}
document.removeEventListener(name, filteredListener as EventListener, options ?? true);
},
};
}
selectorOrElement.addEventListener(name, listener as EventListener, options ?? false);
const newListener = function (this: T, e: (DocumentEventMap | WindowEventMap)[K]) {
listener(e, this as unknown as T);
};
sourceOrSelector.addEventListener(name, newListener as EventListener, options ?? false);
return {
dispose: () => {
if (disposed) return;
disposed = true;
selectorOrElement.removeEventListener(name, listener as EventListener, options ?? false);
sourceOrSelector.removeEventListener(name, newListener as EventListener, options ?? false);
},
};
}

+ 6
- 0
src/webviews/apps/shared/theme.ts View File

@ -72,6 +72,12 @@ export function initializeAndWatchThemeColors() {
bodyStyle.setProperty('--color-link-foreground', color);
bodyStyle.setProperty('--color-link-foreground--darken-20', darken(color, 20));
bodyStyle.setProperty('--color-link-foreground--lighten-20', lighten(color, 20));
color = computedStyle.getPropertyValue('--vscode-sideBar-foreground').trim();
bodyStyle.setProperty('--color-view-foreground', color);
color = computedStyle.getPropertyValue('--vscode-sideBarSectionHeader-foreground').trim();
bodyStyle.setProperty('--color-view-header-foreground', color);
};
const observer = new MutationObserver(onColorThemeChanged);

+ 2
- 2
src/webviews/apps/welcome/welcome.ts View File

@ -1,10 +1,10 @@
/*global window*/
import '../scss/welcome.scss';
import { WelcomeState } from '../../protocol';
import type { State } from '../../welcome/protocol';
import { AppWithConfig } from '../shared/appWithConfigBase';
// import { Snow } from '../shared/snow';
export class WelcomeApp extends AppWithConfig<WelcomeState> {
export class WelcomeApp extends AppWithConfig<State> {
constructor() {
super('WelcomeApp', (window as any).bootstrap);
(window as any).bootstrap = undefined;

+ 48
- 131
src/webviews/protocol.ts View File

@ -1,163 +1,80 @@
import { Config } from '../config';
import type { Config } from '../config';
export interface IpcMessage {
id: string;
method: string;
params?: any;
params?: unknown;
}
export type IpcNotificationParamsOf<NT> = NT extends IpcNotificationType<infer P> ? P : never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export class IpcNotificationType<P = any> {
abstract class IpcMessageType<Params = void> {
_?: Params; // Required for type inferencing to work properly
constructor(public readonly method: string) {}
}
export type IpcCommandParamsOf<CT> = CT extends IpcCommandType<infer P> ? P : never;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export class IpcCommandType<P = any> {
constructor(public readonly method: string) {}
}
export function onIpcCommand<CT extends IpcCommandType>(
type: CT,
command: IpcMessage,
fn: (params: IpcCommandParamsOf<CT>) => unknown,
export type IpcMessageParams<T> = T extends IpcMessageType<infer P> ? P : never;
/**
* Commands are sent from the webview to the extension
*/
export class IpcCommandType<Params = void> extends IpcMessageType<Params> {}
/**
* Notifications are sent from the extension to the webview
*/
export class IpcNotificationType<Params = void> extends IpcMessageType<Params> {}
export function onIpc<T extends IpcMessageType<any>>(
type: T,
msg: IpcMessage,
fn: (params: IpcMessageParams<T>) => unknown,
) {
fn(command.params);
}
if (type.method !== msg.method) return;
export function onIpcNotification<NT extends IpcNotificationType>(
type: NT,
notification: IpcMessage,
fn: (params: IpcNotificationParamsOf<NT>) => void,
) {
fn(notification.params);
fn(msg.params as IpcMessageParams<T>);
}
export interface DidChangeConfigurationNotificationParams {
config: Config;
customSettings: Record<string, boolean>;
}
export const DidChangeConfigurationNotificationType = new IpcNotificationType<DidChangeConfigurationNotificationParams>(
'configuration/didChange',
);
// COMMANDS
export const ReadyCommandType = new IpcCommandType('webview/ready');
export const WebviewReadyCommandType = new IpcCommandType('webview/ready');
export interface UpdateConfigurationCommandParams {
changes: {
[key: string]: any;
};
removes: string[];
scope: 'user' | 'workspace';
uri?: string;
export interface ExecuteCommandParams {
command: string;
args?: [];
}
export const UpdateConfigurationCommandType = new IpcCommandType<UpdateConfigurationCommandParams>(
'configuration/update',
);
export const ExecuteCommandType = new IpcCommandType<ExecuteCommandParams>('command/execute');
export interface CommitPreviewConfigurationCommandParams {
export interface GenerateCommitPreviewParams {
key: string;
id: string;
type: 'commit';
format: string;
}
type PreviewConfigurationCommandParams = CommitPreviewConfigurationCommandParams;
export const PreviewConfigurationCommandType = new IpcCommandType<PreviewConfigurationCommandParams>(
type GenerateConfigurationPreviewParams = GenerateCommitPreviewParams;
export const GenerateConfigurationPreviewCommandType = new IpcCommandType<GenerateConfigurationPreviewParams>(
'configuration/preview',
);
export interface DidPreviewConfigurationNotificationParams {
id: string;
preview: string;
export interface UpdateConfigurationParams {
changes: {
[key: string]: any;
};
removes: string[];
scope?: 'user' | 'workspace';
uri?: string;
}
export const DidPreviewConfigurationNotificationType =
new IpcNotificationType<DidPreviewConfigurationNotificationParams>('configuration/didPreview');
export const UpdateConfigurationCommandType = new IpcCommandType<UpdateConfigurationParams>('configuration/update');
export interface SettingsDidRequestJumpToNotificationParams {
anchor: string;
}
export const SettingsDidRequestJumpToNotificationType =
new IpcNotificationType<SettingsDidRequestJumpToNotificationParams>('settings/jumpTo');
// NOTIFICATIONS
export interface AppStateWithConfig {
export interface DidChangeConfigurationParams {
config: Config;
customSettings?: Record<string, boolean>;
}
export interface SettingsState extends AppStateWithConfig {
scope: 'user' | 'workspace';
scopes: ['user' | 'workspace', string][];
}
export type WelcomeState = AppStateWithConfig;
export interface Author {
readonly author: string;
readonly avatarUrl: string;
readonly email: string | undefined;
}
export interface Commit {
readonly ref: string;
readonly author: string;
// readonly avatarUrl: string;
readonly date: string;
readonly dateFromNow: string;
// readonly email: string | undefined;
readonly message: string;
// readonly command: string;
}
export type RebaseEntryAction = 'pick' | 'reword' | 'edit' | 'squash' | 'fixup' | 'break' | 'drop';
export interface RebaseEntry {
readonly action: RebaseEntryAction;
readonly ref: string;
readonly message: string;
readonly index: number;
}
export interface RebaseDidChangeNotificationParams {
state: RebaseState;
}
export const RebaseDidChangeNotificationType = new IpcNotificationType<RebaseDidChangeNotificationParams>(
'rebase/change',
);
export const RebaseDidAbortCommandType = new IpcCommandType('rebase/abort');
export const RebaseDidDisableCommandType = new IpcCommandType('rebase/disable');
export const RebaseDidStartCommandType = new IpcCommandType('rebase/start');
export const RebaseDidSwitchCommandType = new IpcCommandType('rebase/switch');
export interface RebaseDidChangeEntryCommandParams {
ref: string;
action: RebaseEntryAction;
customSettings: Record<string, boolean>;
}
export const RebaseDidChangeEntryCommandType = new IpcCommandType<RebaseDidChangeEntryCommandParams>(
'rebase/change/entry',
export const DidChangeConfigurationNotificationType = new IpcNotificationType<DidChangeConfigurationParams>(
'configuration/didChange',
);
export interface RebaseDidMoveEntryCommandParams {
ref: string;
to: number;
relative: boolean;
}
export const RebaseDidMoveEntryCommandType = new IpcCommandType<RebaseDidMoveEntryCommandParams>('rebase/move/entry');
export interface RebaseState {
branch: string;
onto: string;
entries: RebaseEntry[];
authors: Author[];
commits: Commit[];
commands: {
commit: string;
};
export interface DidGenerateConfigurationPreviewParams {
completionId: string;
preview: string;
}
export const DidGenerateConfigurationPreviewNotificationType =
new IpcNotificationType<DidGenerateConfigurationPreviewParams>('configuration/didPreview');

+ 69
- 0
src/webviews/rebase/protocol.ts View File

@ -0,0 +1,69 @@
import { IpcCommandType, IpcNotificationType } from '../protocol';
export interface State {
branch: string;
onto: string;
entries: RebaseEntry[];
authors: Author[];
commits: Commit[];
commands: {
commit: string;
};
}
export interface RebaseEntry {
readonly action: RebaseEntryAction;
readonly ref: string;
readonly message: string;
readonly index: number;
}
export type RebaseEntryAction = 'pick' | 'reword' | 'edit' | 'squash' | 'fixup' | 'break' | 'drop';
export interface Author {
readonly author: string;
readonly avatarUrl: string;
readonly email: string | undefined;
}
export interface Commit {
readonly ref: string;
readonly author: string;
// readonly avatarUrl: string;
readonly date: string;
readonly dateFromNow: string;
// readonly email: string | undefined;
readonly message: string;
// readonly command: string;
}
// COMMANDS
export const AbortCommandType = new IpcCommandType('rebase/abort');
export const DisableCommandType = new IpcCommandType('rebase/disable');
export const StartCommandType = new IpcCommandType('rebase/start');
export const SwitchCommandType = new IpcCommandType('rebase/switch');
export interface ChangeEntryParams {
ref: string;
action: RebaseEntryAction;
}
export const ChangeEntryCommandType = new IpcCommandType<ChangeEntryParams>('rebase/change/entry');
export interface MoveEntryParams {
ref: string;
to: number;
relative: boolean;
}
export const MoveEntryCommandType = new IpcCommandType<MoveEntryParams>('rebase/move/entry');
// NOTIFICATIONS
export interface DidChangeParams {
state: State;
}
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('rebase/didChange');

+ 26
- 27
src/webviews/rebase/rebaseEditor.ts View File

@ -16,7 +16,7 @@ import { getNonce } from '@env/crypto';
import { ShowQuickCommitCommand } from '../../commands';
import { configuration } from '../../configuration';
import { CoreCommands } from '../../constants';
import { Container } from '../../container';
import type { Container } from '../../container';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models';
import { Logger } from '../../logger';
import { Messages } from '../../messages';
@ -25,22 +25,21 @@ import { gate } from '../../system/decorators/gate';
import { debug } from '../../system/decorators/log';
import { join, map } from '../../system/iterable';
import { normalizePath } from '../../system/path';
import { IpcMessage, onIpc } from '../protocol';
import {
AbortCommandType,
Author,
ChangeEntryCommandType,
Commit,
IpcMessage,
onIpcCommand,
RebaseDidAbortCommandType,
RebaseDidChangeEntryCommandType,
RebaseDidChangeNotificationType,
RebaseDidDisableCommandType,
RebaseDidMoveEntryCommandType,
RebaseDidStartCommandType,
RebaseDidSwitchCommandType,
DidChangeNotificationType,
DisableCommandType,
MoveEntryCommandType,
RebaseEntry,
RebaseEntryAction,
RebaseState,
} from '../protocol';
StartCommandType,
State,
SwitchCommandType,
} from './protocol';
let ipcSequence = 0;
function nextIpcId() {
@ -237,12 +236,12 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
const state = await this.parseState(context);
void this.postMessage(context, {
id: nextIpcId(),
method: RebaseDidChangeNotificationType.method,
method: DidChangeNotificationType.method,
params: { state: state },
});
}
private async parseState(context: RebaseEditorContext): Promise<RebaseState> {
private async parseState(context: RebaseEditorContext): Promise<State> {
const branch = await this.container.git.getBranch(context.repoPath);
const state = await parseRebaseTodo(this.container, context.document.getText(), context.repoPath, branch?.name);
return state;
@ -270,25 +269,25 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
// break;
case RebaseDidAbortCommandType.method:
onIpcCommand(RebaseDidAbortCommandType, e, () => this.abort(context));
case AbortCommandType.method:
onIpc(AbortCommandType, e, () => this.abort(context));
break;
case RebaseDidDisableCommandType.method:
onIpcCommand(RebaseDidDisableCommandType, e, () => this.disable(context));
case DisableCommandType.method:
onIpc(DisableCommandType, e, () => this.disable(context));
break;
case RebaseDidStartCommandType.method:
onIpcCommand(RebaseDidStartCommandType, e, () => this.rebase(context));
case StartCommandType.method:
onIpc(StartCommandType, e, () => this.rebase(context));
break;
case RebaseDidSwitchCommandType.method:
onIpcCommand(RebaseDidSwitchCommandType, e, () => this.switch(context));
case SwitchCommandType.method:
onIpc(SwitchCommandType, e, () => this.switch(context));
break;
case RebaseDidChangeEntryCommandType.method:
onIpcCommand(RebaseDidChangeEntryCommandType, e, async params => {
case ChangeEntryCommandType.method:
onIpc(ChangeEntryCommandType, e, async params => {
const entries = parseRebaseTodoEntries(context.document);
const entry = entries.find(e => e.ref === params.ref);
@ -345,8 +344,8 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl
break;
case RebaseDidMoveEntryCommandType.method:
onIpcCommand(RebaseDidMoveEntryCommandType, e, async params => {
case MoveEntryCommandType.method:
onIpc(MoveEntryCommandType, e, async params => {
const entries = parseRebaseTodoEntries(context.document);
const entry = entries.find(e => e.ref === params.ref);
@ -516,7 +515,7 @@ async function parseRebaseTodo(
contents: string | { entries: RebaseEntry[]; onto: string },
repoPath: string,
branch: string | undefined,
): Promise<Omit<RebaseState, 'rebasing'>> {
): Promise<Omit<State, 'rebasing'>> {
let onto: string;
let entries;
if (typeof contents === 'string') {

+ 14
- 0
src/webviews/settings/protocol.ts View File

@ -0,0 +1,14 @@
import type { Config } from '../../config';
import { IpcNotificationType } from '../protocol';
export interface State {
config: Config;
customSettings?: Record<string, boolean>;
scope: 'user' | 'workspace';
scopes: ['user' | 'workspace', string][];
}
export interface DidJumpToParams {
anchor: string;
}
export const DidJumpToNotificationType = new IpcNotificationType<DidJumpToParams>('settings/jumpTo');

+ 25
- 52
src/webviews/settings/settingsWebview.ts View File

@ -1,26 +1,26 @@
import { commands, Disposable, workspace } from 'vscode';
import { commands, workspace } from 'vscode';
import { configuration } from '../../configuration';
import { Commands } from '../../constants';
import { Container } from '../../container';
import {
IpcMessage,
onIpcCommand,
ReadyCommandType,
SettingsDidRequestJumpToNotificationType,
SettingsState,
} from '../protocol';
import { WebviewBase } from '../webviewBase';
import type { Container } from '../../container';
import { WebviewWithConfigBase } from '../webviewWithConfigBase';
import { DidJumpToNotificationType, State } from './protocol';
const anchorRegex = /.*?#(.*)/;
export class SettingsWebview extends WebviewBase {
export class SettingsWebview extends WebviewWithConfigBase<State> {
private _pendingJumpToAnchor: string | undefined;
constructor(container: Container) {
super(Commands.ShowSettingsPage, container);
super(
container,
'gitlens.settings',
'settings.html',
'images/gitlens-icon.png',
'GitLens Settings',
Commands.ShowSettingsPage,
);
this.disposable = Disposable.from(
this.disposable,
this.disposables.push(
...[
Commands.ShowSettingsPageAndJumpToBranchesView,
Commands.ShowSettingsPageAndJumpToCommitsView,
@ -46,6 +46,15 @@ export class SettingsWebview extends WebviewBase {
);
}
protected override onReady() {
if (this._pendingJumpToAnchor != null) {
void this.notify(DidJumpToNotificationType, {
anchor: this._pendingJumpToAnchor,
});
this._pendingJumpToAnchor = undefined;
}
}
protected override onShowCommand(anchor?: string) {
if (anchor) {
this._pendingJumpToAnchor = anchor;
@ -53,54 +62,18 @@ export class SettingsWebview extends WebviewBase {
super.onShowCommand();
}
protected override onMessageReceived(e: IpcMessage) {
switch (e.method) {
case ReadyCommandType.method:
onIpcCommand(ReadyCommandType, e, _params => {
if (this._pendingJumpToAnchor !== undefined) {
void this.notify(SettingsDidRequestJumpToNotificationType, {
anchor: this._pendingJumpToAnchor,
});
this._pendingJumpToAnchor = undefined;
}
});
break;
default:
super.onMessageReceived(e);
break;
}
}
get fileName(): string {
return 'settings.html';
}
get id(): string {
return 'gitlens.settings';
}
get title(): string {
return 'GitLens Settings';
}
override renderEndOfBody() {
protected override includeBootstrap(): State {
const scopes: ['user' | 'workspace', string][] = [['user', 'User']];
if (workspace.workspaceFolders?.length) {
scopes.push(['workspace', 'Workspace']);
}
const bootstrap: SettingsState = {
return {
// Make sure to get the raw config, not from the container which has the modes mixed in
config: configuration.get(),
customSettings: this.getCustomSettings(),
scope: 'user',
scopes: scopes,
};
return `<script type="text/javascript" nonce="#{cspNonce}">window.bootstrap = ${JSON.stringify(
bootstrap,
)};</script>`;
}
}

+ 99
- 263
src/webviews/webviewBase.ts View File

@ -1,7 +1,5 @@
import {
commands,
ConfigurationChangeEvent,
ConfigurationTarget,
Disposable,
Uri,
ViewColumn,
@ -12,28 +10,17 @@ import {
workspace,
} from 'vscode';
import { getNonce } from '@env/crypto';
import { configuration } from '../configuration';
import { Commands } from '../constants';
import { Container } from '../container';
import { CommitFormatter } from '../git/formatters';
import {
GitCommit,
GitCommitIdentity,
GitFileChange,
GitFileIndexStatus,
PullRequest,
PullRequestState,
} from '../git/models';
import type { Container } from '../container';
import { Logger } from '../logger';
import { executeCommand } from '../system/command';
import {
DidChangeConfigurationNotificationType,
DidPreviewConfigurationNotificationType,
ExecuteCommandType,
IpcMessage,
IpcNotificationParamsOf,
IpcMessageParams,
IpcNotificationType,
onIpcCommand,
PreviewConfigurationCommandType,
UpdateConfigurationCommandType,
onIpc,
WebviewReadyCommandType,
} from './protocol';
let ipcSequence = 0;
@ -55,231 +42,38 @@ const emptyCommands: Disposable[] = [
},
];
export abstract class WebviewBase implements Disposable {
protected disposable: Disposable;
export abstract class WebviewBase<State> implements Disposable {
protected readonly disposables: Disposable[] = [];
protected isReady: boolean = false;
private _disposablePanel: Disposable | undefined;
private _panel: WebviewPanel | undefined;
constructor(showCommand: Commands, protected readonly container: Container, private readonly _column?: ViewColumn) {
this.disposable = Disposable.from(
configuration.onDidChange(this.onConfigurationChanged, this),
configuration.onDidChangeAny(this.onAnyConfigurationChanged, this),
commands.registerCommand(showCommand, this.onShowCommand, this),
);
constructor(
protected readonly container: Container,
public readonly id: string,
private readonly fileName: string,
private readonly iconPath: string,
title: string,
showCommand: Commands,
) {
this._title = title;
this.disposables.push(commands.registerCommand(showCommand, this.onShowCommand, this));
}
abstract get fileName(): string;
abstract get id(): string;
abstract get title(): string;
registerCommands(): Disposable[] {
return emptyCommands;
}
renderHead?(): string | Promise<string>;
renderBody?(): string | Promise<string>;
renderEndOfBody?(): string | Promise<string>;
dispose() {
this.disposable.dispose();
this._disposablePanel?.dispose();
}
private _customSettings:
| Map<
string,
{
name: string;
enabled: () => boolean;
update: (enabled: boolean) => Promise<void>;
}
>
| undefined;
private get customSettings() {
if (this._customSettings == null) {
this._customSettings = new Map<
string,
{
name: string;
enabled: () => boolean;
update: (enabled: boolean) => Promise<void>;
}
>([
[
'rebaseEditor.enabled',
{
name: 'workbench.editorAssociations',
enabled: () => this.container.rebaseEditor.enabled,
update: this.container.rebaseEditor.setEnabled,
},
],
]);
}
return this._customSettings;
}
protected onShowCommand() {
void this.show(this._column);
}
private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
let notify = false;
for (const setting of this.customSettings.values()) {
if (e.affectsConfiguration(setting.name)) {
notify = true;
break;
}
}
if (!notify) return;
void this.notifyDidChangeConfiguration();
}
private onConfigurationChanged(_e: ConfigurationChangeEvent) {
void this.notifyDidChangeConfiguration();
}
private onPanelDisposed() {
this.disposables.forEach(d => d.dispose());
this._disposablePanel?.dispose();
this._panel = undefined;
}
private onViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent) {
Logger.log(
`Webview(${this.id}).onViewStateChanged`,
`active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}`,
);
// Anytime the webview becomes active, make sure it has the most up-to-date config
if (e.webviewPanel.active) {
void this.notifyDidChangeConfiguration();
}
private _title: string;
get title(): string {
return this._panel?.title ?? this._title;
}
set title(title: string) {
this._title = title;
if (this._panel == null) return;
protected onMessageReceived(e: IpcMessage) {
switch (e.method) {
case UpdateConfigurationCommandType.method:
onIpcCommand(UpdateConfigurationCommandType, e, async params => {
const target =
params.scope === 'workspace' ? ConfigurationTarget.Workspace : ConfigurationTarget.Global;
for (const key in params.changes) {
let value = params.changes[key];
const customSetting = this.customSettings.get(key);
if (customSetting != null) {
await customSetting.update(value);
continue;
}
const inspect = configuration.inspect(key as any)!;
if (value != null) {
if (params.scope === 'workspace') {
if (value === inspect.workspaceValue) continue;
} else {
if (value === inspect.globalValue && value !== inspect.defaultValue) continue;
if (value === inspect.defaultValue) {
value = undefined;
}
}
}
void (await configuration.update(key as any, value, target));
}
for (const key of params.removes) {
void (await configuration.update(key as any, undefined, target));
}
});
break;
case PreviewConfigurationCommandType.method:
onIpcCommand(PreviewConfigurationCommandType, e, async params => {
switch (params.type) {
case 'commit': {
const commit = new GitCommit(
this.container,
'~/code/eamodio/vscode-gitlens-demo',
'fe26af408293cba5b4bfd77306e1ac9ff7ccaef8',
new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2016-11-12T20:41:00.000Z')),
new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2020-11-01T06:57:21.000Z')),
'Supercharged',
['3ac1d3f51d7cf5f438cc69f25f6740536ad80fef'],
'Supercharged',
new GitFileChange(
'~/code/eamodio/vscode-gitlens-demo',
'code.ts',
GitFileIndexStatus.Modified,
),
undefined,
[],
);
let includePullRequest = false;
switch (params.key) {
case configuration.name('currentLine.format'):
includePullRequest = this.container.config.currentLine.pullRequests.enabled;
break;
case configuration.name('statusBar.format'):
includePullRequest = this.container.config.statusBar.pullRequests.enabled;
break;
}
let pr: PullRequest | undefined;
if (includePullRequest) {
pr = new PullRequest(
{ id: 'github', name: 'GitHub', domain: 'github.com' },
{
name: 'Eric Amodio',
avatarUrl: 'https://avatars1.githubusercontent.com/u/641685?s=32&v=4',
url: 'https://github.com/eamodio',
},
'1',
'Supercharged',
'https://github.com/eamodio/vscode-gitlens/pulls/1',
PullRequestState.Merged,
new Date('Sat, 12 Nov 2016 19:41:00 GMT'),
undefined,
new Date('Sat, 12 Nov 2016 20:41:00 GMT'),
);
}
let preview;
try {
preview = CommitFormatter.fromTemplate(params.format, commit, {
dateFormat: this.container.config.defaultDateFormat,
pullRequestOrRemote: pr,
messageTruncateAtNewLine: true,
});
} catch {
preview = 'Invalid format';
}
await this.notify(DidPreviewConfigurationNotificationType, {
id: params.id,
preview: preview,
});
}
}
});
break;
default:
break;
}
}
private onMessageReceivedCore(e: IpcMessage) {
if (e == null) return;
Logger.log(`Webview(${this.id}).onMessageReceived: method=${e.method}, data=${JSON.stringify(e)}`);
this.onMessageReceived(e);
this._panel.title = title;
}
get visible() {
@ -290,17 +84,11 @@ export abstract class WebviewBase implements Disposable {
this._panel?.dispose();
}
setTitle(title: string) {
if (this._panel == null) return;
this._panel.title = title;
}
async show(column: ViewColumn = ViewColumn.Beside): Promise<void> {
if (this._panel == null) {
this._panel = window.createWebviewPanel(
this.id,
this.title,
this._title,
{ viewColumn: column, preserveFocus: false },
{
retainContextWhenHidden: true,
@ -310,7 +98,7 @@ export abstract class WebviewBase implements Disposable {
},
);
this._panel.iconPath = Uri.file(this.container.context.asAbsolutePath('images/gitlens-icon.png'));
this._panel.iconPath = Uri.file(this.container.context.asAbsolutePath(this.iconPath));
this._disposablePanel = Disposable.from(
this._panel,
this._panel.onDidDispose(this.onPanelDisposed, this),
@ -331,14 +119,74 @@ export abstract class WebviewBase implements Disposable {
}
}
protected onReady?(): void;
protected onMessageReceived?(e: IpcMessage): void;
protected registerCommands(): Disposable[] {
return emptyCommands;
}
protected includeBootstrap?(): State | Promise<State>;
protected includeHead?(): string | Promise<string>;
protected includeBody?(): string | Promise<string>;
protected includeEndOfBody?(): string | Promise<string>;
private onPanelDisposed() {
this._disposablePanel?.dispose();
this._disposablePanel = undefined;
this._panel = undefined;
}
protected onShowCommand(): void {
void this.show();
}
protected onViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent): void {
Logger.log(
`Webview(${this.id}).onViewStateChanged`,
`active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}`,
);
}
protected onMessageReceivedCore(e: IpcMessage) {
if (e == null) return;
Logger.log(`Webview(${this.id}).onMessageReceived: method=${e.method}, data=${JSON.stringify(e)}`);
switch (e.method) {
case WebviewReadyCommandType.method:
onIpc(WebviewReadyCommandType, e, () => {
this.isReady = true;
this.onReady?.();
});
break;
case ExecuteCommandType.method:
onIpc(ExecuteCommandType, e, params => {
if (params.args != null) {
void executeCommand(params.command as Commands, ...params.args);
} else {
void executeCommand(params.command as Commands);
}
});
break;
default:
this.onMessageReceived?.(e);
break;
}
}
private async getHtml(webview: Webview): Promise<string> {
const uri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews', this.fileName);
const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri));
const [head, body, endOfBody] = await Promise.all([
this.renderHead?.(),
this.renderBody?.(),
this.renderEndOfBody?.(),
const [bootstrap, head, body, endOfBody] = await Promise.all([
this.includeBootstrap?.(),
this.includeHead?.(),
this.includeBody?.(),
this.includeEndOfBody?.(),
]);
const cspSource = webview.cspSource;
@ -353,7 +201,11 @@ export abstract class WebviewBase implements Disposable {
case 'body':
return body ?? '';
case 'endOfBody':
return endOfBody ?? '';
return bootstrap != null
? `<script type="text/javascript" nonce="#{cspNonce}">window.bootstrap = ${JSON.stringify(
bootstrap,
)};</script>${endOfBody ?? ''}`
: endOfBody ?? '';
default:
return '';
}
@ -374,26 +226,10 @@ export abstract class WebviewBase implements Disposable {
return html;
}
protected notify<NT extends IpcNotificationType>(type: NT, params: IpcNotificationParamsOf<NT>): Thenable<boolean> {
protected notify<T extends IpcNotificationType<pan>any>>(type: T, params: IpcMessageParams<T>): Thenable<boolean> {
return this.postMessage({ id: nextIpcId(), method: type.method, params: params });
}
protected getCustomSettings(): Record<string, boolean> {
const customSettings = Object.create(null);
for (const [key, setting] of this.customSettings) {
customSettings[key] = setting.enabled();
}
return customSettings;
}
private notifyDidChangeConfiguration() {
// Make sure to get the raw config, not from the container which has the modes mixed in
return this.notify(DidChangeConfigurationNotificationType, {
config: configuration.get(),
customSettings: this.getCustomSettings(),
});
}
private postMessage(message: IpcMessage) {
if (this._panel == null) return Promise.resolve(false);

+ 228
- 0
src/webviews/webviewWithConfigBase.ts View File

@ -0,0 +1,228 @@
import { ConfigurationChangeEvent, ConfigurationTarget, WebviewPanelOnDidChangeViewStateEvent } from 'vscode';
import { configuration } from '../configuration';
import { Commands } from '../constants';
import type { Container } from '../container';
import { CommitFormatter } from '../git/formatters';
import {
GitCommit,
GitCommitIdentity,
GitFileChange,
GitFileIndexStatus,
PullRequest,
PullRequestState,
} from '../git/models';
import { Logger } from '../logger';
import {
DidChangeConfigurationNotificationType,
DidGenerateConfigurationPreviewNotificationType,
GenerateConfigurationPreviewCommandType,
IpcMessage,
onIpc,
UpdateConfigurationCommandType,
} from './protocol';
import { WebviewBase } from './webviewBase';
export abstract class WebviewWithConfigBase<State> extends WebviewBase<State> {
constructor(
container: Container,
id: string,
fileName: string,
iconPath: string,
title: string,
showCommand: Commands,
) {
super(container, id, fileName, iconPath, title, showCommand);
this.disposables.push(
configuration.onDidChange(this.onConfigurationChanged, this),
configuration.onDidChangeAny(this.onAnyConfigurationChanged, this),
);
}
private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
let notify = false;
for (const setting of this.customSettings.values()) {
if (e.affectsConfiguration(setting.name)) {
notify = true;
break;
}
}
if (!notify) return;
void this.notifyDidChangeConfiguration();
}
private onConfigurationChanged(_e: ConfigurationChangeEvent) {
void this.notifyDidChangeConfiguration();
}
protected override onViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent): void {
super.onViewStateChanged(e);
// Anytime the webview becomes active, make sure it has the most up-to-date config
if (e.webviewPanel.active) {
void this.notifyDidChangeConfiguration();
}
}
protected override onMessageReceivedCore(e: IpcMessage): void {
if (e == null) return;
switch (e.method) {
case UpdateConfigurationCommandType.method:
Logger.log(`Webview(${this.id}).onMessageReceived: method=${e.method}, data=${JSON.stringify(e)}`);
onIpc(UpdateConfigurationCommandType, e, async params => {
const target =
params.scope === 'workspace' ? ConfigurationTarget.Workspace : ConfigurationTarget.Global;
for (const key in params.changes) {
let value = params.changes[key];
const customSetting = this.customSettings.get(key);
if (customSetting != null) {
await customSetting.update(value);
continue;
}
const inspect = configuration.inspect(key as any)!;
if (value != null) {
if (params.scope === 'workspace') {
if (value === inspect.workspaceValue) continue;
} else {
if (value === inspect.globalValue && value !== inspect.defaultValue) continue;
if (value === inspect.defaultValue) {
value = undefined;
}
}
}
void (await configuration.update(key as any, value, target));
}
for (const key of params.removes) {
void (await configuration.update(key as any, undefined, target));
}
});
break;
case GenerateConfigurationPreviewCommandType.method:
Logger.log(`Webview(${this.id}).onMessageReceived: method=${e.method}, data=${JSON.stringify(e)}`);
onIpc(GenerateConfigurationPreviewCommandType, e, async params => {
switch (params.type) {
case 'commit': {
const commit = new GitCommit(
this.container,
'~/code/eamodio/vscode-gitlens-demo',
'fe26af408293cba5b4bfd77306e1ac9ff7ccaef8',
new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2016-11-12T20:41:00.000Z')),
new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2020-11-01T06:57:21.000Z')),
'Supercharged',
['3ac1d3f51d7cf5f438cc69f25f6740536ad80fef'],
'Supercharged',
new GitFileChange(
'~/code/eamodio/vscode-gitlens-demo',
'code.ts',
GitFileIndexStatus.Modified,
),
undefined,
[],
);
let includePullRequest = false;
switch (params.key) {
case configuration.name('currentLine.format'):
includePullRequest = this.container.config.currentLine.pullRequests.enabled;
break;
case configuration.name('statusBar.format'):
includePullRequest = this.container.config.statusBar.pullRequests.enabled;
break;
}
let pr: PullRequest | undefined;
if (includePullRequest) {
pr = new PullRequest(
{ id: 'github', name: 'GitHub', domain: 'github.com' },
{
name: 'Eric Amodio',
avatarUrl: 'https://avatars1.githubusercontent.com/u/641685?s=32&v=4',
url: 'https://github.com/eamodio',
},
'1',
'Supercharged',
'https://github.com/eamodio/vscode-gitlens/pulls/1',
PullRequestState.Merged,
new Date('Sat, 12 Nov 2016 19:41:00 GMT'),
undefined,
new Date('Sat, 12 Nov 2016 20:41:00 GMT'),
);
}
let preview;
try {
preview = CommitFormatter.fromTemplate(params.format, commit, {
dateFormat: this.container.config.defaultDateFormat,
pullRequestOrRemote: pr,
messageTruncateAtNewLine: true,
});
} catch {
preview = 'Invalid format';
}
await this.notify(DidGenerateConfigurationPreviewNotificationType, {
completionId: e.id,
preview: preview,
});
}
}
});
break;
default:
super.onMessageReceivedCore(e);
}
}
private _customSettings: Map<string, CustomSetting> | undefined;
private get customSettings() {
if (this._customSettings == null) {
this._customSettings = new Map<string, CustomSetting>([
[
'rebaseEditor.enabled',
{
name: 'workbench.editorAssociations',
enabled: () => this.container.rebaseEditor.enabled,
update: this.container.rebaseEditor.setEnabled,
},
],
]);
}
return this._customSettings;
}
protected getCustomSettings(): Record<string, boolean> {
const customSettings = Object.create(null);
for (const [key, setting] of this.customSettings) {
customSettings[key] = setting.enabled();
}
return customSettings;
}
private notifyDidChangeConfiguration() {
// Make sure to get the raw config, not from the container which has the modes mixed in
return this.notify(DidChangeConfigurationNotificationType, {
config: configuration.get(),
customSettings: this.getCustomSettings(),
});
}
}
interface CustomSetting {
name: string;
enabled: () => boolean;
update: (enabled: boolean) => Promise<void>;
}

+ 6
- 0
src/webviews/welcome/protocol.ts View File

@ -0,0 +1,6 @@
import type { Config } from '../../config';
export interface State {
config: Config;
customSettings?: Record<string, boolean>;
}

+ 14
- 22
src/webviews/welcome/welcomeWebview.ts View File

@ -1,31 +1,23 @@
import { Commands } from '../../constants';
import { Container } from '../../container';
import { WelcomeState } from '../protocol';
import { WebviewBase } from '../webviewBase';
import type { Container } from '../../container';
import { WebviewWithConfigBase } from '../webviewWithConfigBase';
import type { State } from './protocol';
export class WelcomeWebview extends WebviewBase {
export class WelcomeWebview extends WebviewWithConfigBase<State> {
constructor(container: Container) {
super(Commands.ShowWelcomePage, container);
super(
container,
'gitlens.welcome',
'welcome.html',
'images/gitlens-icon.png',
'Welcome to GitLens',
Commands.ShowWelcomePage,
);
}
get fileName(): string {
return 'welcome.html';
}
get id(): string {
return 'gitlens.welcome';
}
get title(): string {
return 'Welcome to GitLens';
}
override renderEndOfBody() {
const bootstrap: WelcomeState = {
protected override includeBootstrap(): State {
return {
config: this.container.config,
};
return `<script type="text/javascript" nonce="#{cspNonce}">window.bootstrap = ${JSON.stringify(
bootstrap,
)};</script>`;
}
}

+ 176
- 150
webpack.config.js View File

@ -117,32 +117,37 @@ function getExtensionConfig(target, mode, env) {
},
optimization: {
minimizer: [
env.esbuild
? new TerserPlugin({
minify: TerserPlugin.esbuildMinify,
terserOptions: {
drop: ['debugger'],
new TerserPlugin(
env.esbuild
? {
minify: TerserPlugin.esbuildMinify,
terserOptions: {
// @ts-ignore
drop: ['debugger'],
// @ts-ignore
format: 'cjs',
minify: true,
treeShaking: true,
// Keep the class names otherwise @log won't provide a useful name
keepNames: true,
target: 'es2020',
},
}
: {
compress: {
drop_debugger: true,
},
extractComments: false,
parallel: true,
// @ts-ignore
format: 'cjs',
minify: true,
treeShaking: true,
// Keep the class names otherwise @log won't provide a useful name
keepNames: true,
target: 'es2020',
},
})
: new TerserPlugin({
drop_debugger: true,
extractComments: false,
parallel: true,
// @ts-ignore
terserOptions: {
ecma: 2020,
// Keep the class names otherwise @log won't provide a useful name
keep_classnames: true,
module: true,
},
}),
terserOptions: {
ecma: 2020,
// Keep the class names otherwise @log won't provide a useful name
keep_classnames: true,
module: true,
},
},
),
],
splitChunks:
target === 'webworker'
@ -228,67 +233,6 @@ function getExtensionConfig(target, mode, env) {
function getWebviewsConfig(mode, env) {
const basePath = path.join(__dirname, 'src', 'webviews', 'apps');
const cspHtmlPlugin = new CspHtmlPlugin(
{
'default-src': "'none'",
'img-src': ['#{cspSource}', 'https:', 'data:'],
'script-src':
mode !== 'production'
? ['#{cspSource}', "'nonce-#{cspNonce}'", "'unsafe-eval'"]
: ['#{cspSource}', "'nonce-#{cspNonce}'"],
'style-src': ['#{cspSource}', "'nonce-#{cspNonce}'"],
'font-src': ['#{cspSource}'],
},
{
enabled: true,
hashingMethod: 'sha256',
hashEnabled: {
'script-src': true,
'style-src': true,
},
nonceEnabled: {
'script-src': true,
'style-src': true,
},
},
);
// Override the nonce creation so we can dynamically generate them at runtime
// @ts-ignore
cspHtmlPlugin.createNonce = () => '#{cspNonce}';
/** @type ImageMinimizerPlugin.Generator<any> */
// @ts-ignore
let imageGeneratorConfig = env.squoosh
? {
type: 'asset',
implementation: ImageMinimizerPlugin.squooshGenerate,
options: {
encodeOptions: {
webp: {
// quality: 90,
lossless: 1,
},
},
},
}
: {
type: 'asset',
implementation: ImageMinimizerPlugin.imageminGenerate,
options: {
plugins: [
[
'imagemin-webp',
{
lossless: true,
nearLossless: 0,
quality: 100,
method: mode === 'production' ? 4 : 0,
},
],
],
},
};
/** @type WebpackConfig['plugins'] | any */
const plugins = [
new CleanPlugin(
@ -314,70 +258,11 @@ function getWebviewsConfig(mode, env) {
configFile: path.join(basePath, 'tsconfig.json'),
},
}),
new MiniCssExtractPlugin({
filename: '[name].css',
}),
new HtmlPlugin({
template: 'rebase/rebase.html',
chunks: ['rebase'],
filename: path.join(__dirname, 'dist', 'webviews', 'rebase.html'),
inject: true,
inlineSource: mode === 'production' ? '.css$' : undefined,
minify:
mode === 'production'
? {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: false,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyCSS: true,
}
: false,
}),
new HtmlPlugin({
template: 'settings/settings.html',
chunks: ['settings'],
filename: path.join(__dirname, 'dist', 'webviews', 'settings.html'),
inject: true,
inlineSource: mode === 'production' ? '.css$' : undefined,
minify:
mode === 'production'
? {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: false,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyCSS: true,
}
: false,
}),
new HtmlPlugin({
template: 'welcome/welcome.html',
chunks: ['welcome'],
filename: path.join(__dirname, 'dist', 'webviews', 'welcome.html'),
inject: true,
inlineSource: mode === 'production' ? '.css$' : undefined,
minify:
mode === 'production'
? {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: false,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyCSS: true,
}
: false,
}),
cspHtmlPlugin,
new MiniCssExtractPlugin({ filename: '[name].css' }),
getHtmlPlugin('rebase', false, mode, env),
getHtmlPlugin('settings', false, mode, env),
getHtmlPlugin('welcome', false, mode, env),
getCspHtmlPlugin(mode, env),
new InlineChunkHtmlPlugin(HtmlPlugin, mode === 'production' ? ['\\.css$'] : []),
new CopyPlugin({
patterns: [
@ -400,6 +285,8 @@ function getWebviewsConfig(mode, env) {
}),
];
const imageGeneratorConfig = getImageMinimizerConfig(mode, env);
if (mode !== 'production') {
plugins.push(
new ImageMinimizerPlugin({
@ -427,6 +314,38 @@ function getWebviewsConfig(mode, env) {
},
optimization: {
minimizer: [
new TerserPlugin(
env.esbuild
? {
minify: TerserPlugin.esbuildMinify,
terserOptions: {
// @ts-ignore
drop: ['debugger', 'console'],
// @ts-ignore
format: 'esm',
minify: true,
treeShaking: true,
// // Keep the class names otherwise @log won't provide a useful name
// keepNames: true,
target: 'es2020',
},
}
: {
compress: {
drop_debugger: true,
drop_console: true,
},
extractComments: false,
parallel: true,
// @ts-ignore
terserOptions: {
ecma: 2020,
// // Keep the class names otherwise @log won't provide a useful name
// keep_classnames: true,
module: true,
},
},
),
new ImageMinimizerPlugin({
deleteOriginalAssets: true,
generator: [imageGeneratorConfig],
@ -502,6 +421,113 @@ function getWebviewsConfig(mode, env) {
};
}
/**
* @param { 'production' | 'development' | 'none' } mode
* @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; squoosh?: boolean } | undefined } env
* @returns { CspHtmlPlugin }
*/
function getCspHtmlPlugin(mode, env) {
const cspPlugin = new CspHtmlPlugin(
{
'default-src': "'none'",
'img-src': ['#{cspSource}', 'https:', 'data:'],
'script-src':
mode !== 'production'
? ['#{cspSource}', "'nonce-#{cspNonce}'", "'unsafe-eval'"]
: ['#{cspSource}', "'nonce-#{cspNonce}'"],
'style-src': ['#{cspSource}', "'nonce-#{cspNonce}'"],
'font-src': ['#{cspSource}'],
},
{
enabled: true,
hashingMethod: 'sha256',
hashEnabled: {
'script-src': true,
'style-src': true,
},
nonceEnabled: {
'script-src': true,
'style-src': true,
},
},
);
// Override the nonce creation so we can dynamically generate them at runtime
// @ts-ignore
cspPlugin.createNonce = () => '#{cspNonce}';
return cspPlugin;
}
/**
* @param { 'production' | 'development' | 'none' } mode
* @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; squoosh?: boolean } | undefined } env
* @returns { ImageMinimizerPlugin.Generator<any> }
*/
function getImageMinimizerConfig(mode, env) {
/** @type ImageMinimizerPlugin.Generator<any> */
// @ts-ignore
return env.squoosh
? {
type: 'asset',
implementation: ImageMinimizerPlugin.squooshGenerate,
options: {
encodeOptions: {
webp: {
// quality: 90,
lossless: 1,
},
},
},
}
: {
type: 'asset',
implementation: ImageMinimizerPlugin.imageminGenerate,
options: {
plugins: [
[
'imagemin-webp',
{
lossless: true,
nearLossless: 0,
quality: 100,
method: mode === 'production' ? 4 : 0,
},
],
],
},
};
}
/**
* @param { string } name
* @param { boolean } premium
* @param { 'production' | 'development' | 'none' } mode
* @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; squoosh?: boolean } | undefined } env
* @returns { HtmlPlugin }
*/
function getHtmlPlugin(name, premium, mode, env) {
return new HtmlPlugin({
template: premium ? path.join('premium', name, `${name}.html`) : path.join(name, `${name}.html`),
chunks: [name],
filename: path.join(__dirname, 'dist', 'webviews', `${name}.html`),
inject: true,
inlineSource: mode === 'production' ? '.css$' : undefined,
minify:
mode === 'production'
? {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: false,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyCSS: true,
}
: false,
});
}
class InlineChunkHtmlPlugin {
constructor(htmlPlugin, patterns) {
this.htmlPlugin = htmlPlugin;

Loading…
Cancel
Save