diff --git a/src/webviews/apps/rebase/rebase.ts b/src/webviews/apps/rebase/rebase.ts index 7bb1dbb..8c035e5 100644 --- a/src/webviews/apps/rebase/rebase.ts +++ b/src/webviews/apps/rebase/rebase.ts @@ -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 { +class RebaseEditor extends App { 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('select[data-ref]')[0]; + const $select = target.querySelectorAll('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(`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(`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( - 'select[data-ref]', - )[0]; + const $select = target.querySelectorAll('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('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] { diff --git a/src/webviews/apps/settings/settings.ts b/src/webviews/apps/settings/settings.ts index a25a5e3..943e34e 100644 --- a/src/webviews/apps/settings/settings.ts +++ b/src/webviews/apps/settings/settings.ts @@ -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 { +export class SettingsApp extends AppWithConfig { 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); } } diff --git a/src/webviews/apps/shared/appBase.ts b/src/webviews/apps/shared/appBase.ts index 06eb5fc..a35145a 100644 --- a/src/webviews/apps/shared/appBase.ts +++ b/src/webviews/apps/shared/appBase.ts @@ -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 { +export abstract class App { 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(type: CT, params: IpcCommandParamsOf): void { - return this.postMessage({ id: nextIpcId(), method: type.method, params: params }); + protected sendCommand>( + command: TCommand, + params: IpcMessageParams, + ): void { + return this.postMessage({ id: nextIpcId(), method: command.method, params: params }); + } + + protected sendCommandWithCompletion< + TCommand extends IpcCommandType, + TCompletion extends IpcNotificationType<{ completionId: string }>, + >( + command: TCommand, + params: IpcMessageParams, + completion: TCompletion, + callback: (params: IpcMessageParams) => 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) { diff --git a/src/webviews/apps/shared/appWithConfigBase.ts b/src/webviews/apps/shared/appWithConfigBase.ts index e5b73a2..377b9e0 100644 --- a/src/webviews/apps/shared/appWithConfigBase.ts +++ b/src/webviews/apps/shared/appWithConfigBase.ts @@ -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; } -export abstract class AppWithConfig extends App { +export abstract class AppWithConfig extends App { private _changes = Object.create(null) as Record; 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; } diff --git a/src/webviews/apps/shared/dom.ts b/src/webviews/apps/shared/dom.ts index 1a7a2c4..d996744 100644 --- a/src/webviews/apps/shared/dom.ts +++ b/src/webviews/apps/shared/dom.ts @@ -5,59 +5,67 @@ export interface Disposable { } export namespace DOM { - export function on( - selector: string, + export function on( + window: Window, + name: K, + listener: (e: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): Disposable; + export function on( + 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( - el: Document | Element, + export function on( + element: Element, name: K, - listener: (this: T, ev: DocumentEventMap[K]) => any, + listener: (e: DocumentEventMap[K]) => void, options?: boolean | AddEventListenerOptions, ): Disposable; - export function on( - el: Window, + export function on( + 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( - 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); }, }; } diff --git a/src/webviews/apps/shared/theme.ts b/src/webviews/apps/shared/theme.ts index b1d0a43..18a231f 100644 --- a/src/webviews/apps/shared/theme.ts +++ b/src/webviews/apps/shared/theme.ts @@ -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); diff --git a/src/webviews/apps/welcome/welcome.ts b/src/webviews/apps/welcome/welcome.ts index 6d66e64..feaf5ef 100644 --- a/src/webviews/apps/welcome/welcome.ts +++ b/src/webviews/apps/welcome/welcome.ts @@ -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 { +export class WelcomeApp extends AppWithConfig { constructor() { super('WelcomeApp', (window as any).bootstrap); (window as any).bootstrap = undefined; diff --git a/src/webviews/protocol.ts b/src/webviews/protocol.ts index c017ffa..27e5944 100644 --- a/src/webviews/protocol.ts +++ b/src/webviews/protocol.ts @@ -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 extends IpcNotificationType ? P : never; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export class IpcNotificationType

{ +abstract class IpcMessageType { + _?: Params; // Required for type inferencing to work properly constructor(public readonly method: string) {} } - -export type IpcCommandParamsOf = CT extends IpcCommandType ? P : never; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export class IpcCommandType

{ - constructor(public readonly method: string) {} -} - -export function onIpcCommand( - type: CT, - command: IpcMessage, - fn: (params: IpcCommandParamsOf) => unknown, +export type IpcMessageParams = T extends IpcMessageType ? P : never; + +/** + * Commands are sent from the webview to the extension + */ +export class IpcCommandType extends IpcMessageType {} +/** + * Notifications are sent from the extension to the webview + */ +export class IpcNotificationType extends IpcMessageType {} + +export function onIpc>( + type: T, + msg: IpcMessage, + fn: (params: IpcMessageParams) => unknown, ) { - fn(command.params); -} + if (type.method !== msg.method) return; -export function onIpcNotification( - type: NT, - notification: IpcMessage, - fn: (params: IpcNotificationParamsOf) => void, -) { - fn(notification.params); + fn(msg.params as IpcMessageParams); } -export interface DidChangeConfigurationNotificationParams { - config: Config; - customSettings: Record; -} -export const DidChangeConfigurationNotificationType = new IpcNotificationType( - '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( - 'configuration/update', -); +export const ExecuteCommandType = new IpcCommandType('command/execute'); -export interface CommitPreviewConfigurationCommandParams { +export interface GenerateCommitPreviewParams { key: string; - id: string; type: 'commit'; - format: string; } -type PreviewConfigurationCommandParams = CommitPreviewConfigurationCommandParams; -export const PreviewConfigurationCommandType = new IpcCommandType( +type GenerateConfigurationPreviewParams = GenerateCommitPreviewParams; +export const GenerateConfigurationPreviewCommandType = new IpcCommandType( '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('configuration/didPreview'); +export const UpdateConfigurationCommandType = new IpcCommandType('configuration/update'); -export interface SettingsDidRequestJumpToNotificationParams { - anchor: string; -} -export const SettingsDidRequestJumpToNotificationType = - new IpcNotificationType('settings/jumpTo'); +// NOTIFICATIONS -export interface AppStateWithConfig { +export interface DidChangeConfigurationParams { config: Config; - customSettings?: Record; -} - -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( - '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; } -export const RebaseDidChangeEntryCommandType = new IpcCommandType( - 'rebase/change/entry', +export const DidChangeConfigurationNotificationType = new IpcNotificationType( + 'configuration/didChange', ); -export interface RebaseDidMoveEntryCommandParams { - ref: string; - to: number; - relative: boolean; -} -export const RebaseDidMoveEntryCommandType = new IpcCommandType('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('configuration/didPreview'); diff --git a/src/webviews/rebase/protocol.ts b/src/webviews/rebase/protocol.ts new file mode 100644 index 0000000..2fcb57c --- /dev/null +++ b/src/webviews/rebase/protocol.ts @@ -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('rebase/change/entry'); + +export interface MoveEntryParams { + ref: string; + to: number; + relative: boolean; +} +export const MoveEntryCommandType = new IpcCommandType('rebase/move/entry'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: State; +} +export const DidChangeNotificationType = new IpcNotificationType('rebase/didChange'); diff --git a/src/webviews/rebase/rebaseEditor.ts b/src/webviews/rebase/rebaseEditor.ts index af6a14b..4820b43 100644 --- a/src/webviews/rebase/rebaseEditor.ts +++ b/src/webviews/rebase/rebaseEditor.ts @@ -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 { + private async parseState(context: RebaseEditorContext): Promise { 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> { +): Promise> { let onto: string; let entries; if (typeof contents === 'string') { diff --git a/src/webviews/settings/protocol.ts b/src/webviews/settings/protocol.ts new file mode 100644 index 0000000..aa7b8ae --- /dev/null +++ b/src/webviews/settings/protocol.ts @@ -0,0 +1,14 @@ +import type { Config } from '../../config'; +import { IpcNotificationType } from '../protocol'; + +export interface State { + config: Config; + customSettings?: Record; + scope: 'user' | 'workspace'; + scopes: ['user' | 'workspace', string][]; +} + +export interface DidJumpToParams { + anchor: string; +} +export const DidJumpToNotificationType = new IpcNotificationType('settings/jumpTo'); diff --git a/src/webviews/settings/settingsWebview.ts b/src/webviews/settings/settingsWebview.ts index 6b7def6..9b7982a 100644 --- a/src/webviews/settings/settingsWebview.ts +++ b/src/webviews/settings/settingsWebview.ts @@ -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 { 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 ``; } } diff --git a/src/webviews/webviewBase.ts b/src/webviews/webviewBase.ts index 2cb5e50..e8c1a5a 100644 --- a/src/webviews/webviewBase.ts +++ b/src/webviews/webviewBase.ts @@ -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 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; - renderBody?(): string | Promise; - renderEndOfBody?(): string | Promise; - dispose() { - this.disposable.dispose(); - this._disposablePanel?.dispose(); - } - - private _customSettings: - | Map< - string, - { - name: string; - enabled: () => boolean; - update: (enabled: boolean) => Promise; - } - > - | undefined; - private get customSettings() { - if (this._customSettings == null) { - this._customSettings = new Map< - string, - { - name: string; - enabled: () => boolean; - update: (enabled: boolean) => Promise; - } - >([ - [ - '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 { 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; + protected includeHead?(): string | Promise; + protected includeBody?(): string | Promise; + protected includeEndOfBody?(): string | Promise; + + 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 { 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 + ? `${endOfBody ?? ''}` + : endOfBody ?? ''; default: return ''; } @@ -374,26 +226,10 @@ export abstract class WebviewBase implements Disposable { return html; } - protected notify(type: NT, params: IpcNotificationParamsOf): Thenable { + protected notify>(type: T, params: IpcMessageParams): Thenable { return this.postMessage({ id: nextIpcId(), method: type.method, params: params }); } - protected getCustomSettings(): Record { - 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); diff --git a/src/webviews/webviewWithConfigBase.ts b/src/webviews/webviewWithConfigBase.ts new file mode 100644 index 0000000..e63d2d3 --- /dev/null +++ b/src/webviews/webviewWithConfigBase.ts @@ -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 extends WebviewBase { + 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 | undefined; + private get customSettings() { + if (this._customSettings == null) { + this._customSettings = new Map([ + [ + 'rebaseEditor.enabled', + { + name: 'workbench.editorAssociations', + enabled: () => this.container.rebaseEditor.enabled, + update: this.container.rebaseEditor.setEnabled, + }, + ], + ]); + } + return this._customSettings; + } + + protected getCustomSettings(): Record { + 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; +} diff --git a/src/webviews/welcome/protocol.ts b/src/webviews/welcome/protocol.ts new file mode 100644 index 0000000..2a81f1c --- /dev/null +++ b/src/webviews/welcome/protocol.ts @@ -0,0 +1,6 @@ +import type { Config } from '../../config'; + +export interface State { + config: Config; + customSettings?: Record; +} diff --git a/src/webviews/welcome/welcomeWebview.ts b/src/webviews/welcome/welcomeWebview.ts index 41b7479..8919a42 100644 --- a/src/webviews/welcome/welcomeWebview.ts +++ b/src/webviews/welcome/welcomeWebview.ts @@ -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 { 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 ``; } } diff --git a/webpack.config.js b/webpack.config.js index f343944..6babef7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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 */ - // @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 } + */ +function getImageMinimizerConfig(mode, env) { + /** @type ImageMinimizerPlugin.Generator */ + // @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;