diff --git a/src/webviews/apps/settings/partials/commit-graph.html b/src/webviews/apps/settings/partials/commit-graph.html index a45fe7d..3b63db2 100644 --- a/src/webviews/apps/settings/partials/commit-graph.html +++ b/src/webviews/apps/settings/partials/commit-graph.html @@ -1,7 +1,7 @@

- Commit Graph + Commit Graph 

-
-
-
-
-
- -
- +
+
+
+
+
+
+ +
+ +
-
-
-
- - -
- - - - + /> + +
+ +
-
-
- +
+ +
diff --git a/src/webviews/apps/settings/partials/views.html b/src/webviews/apps/settings/partials/views.html deleted file mode 100644 index fb29aec..0000000 --- a/src/webviews/apps/settings/partials/views.html +++ /dev/null @@ -1,67 +0,0 @@ -
-
-

- Views - - - -

- -

Adds rich views to visualize, navigate, and explore

-
- -
-
-
-

- GitLens views can be configured to be shown in different side bar layouts to best match your - workflow -

- -
-
- Source Control Layout (default) -

Shows all the views together on the Source Control side bar

- -
-
- GitLens Layout -

Shows all the views together on the GitLens side bar

- -
-
- -

- You can also simply drag & drop individual views to create - custom layouts -

-
-
-
-
diff --git a/src/webviews/apps/settings/partials/views.worktrees.html b/src/webviews/apps/settings/partials/views.worktrees.html index 4f40464..4f93c5c 100644 --- a/src/webviews/apps/settings/partials/views.worktrees.html +++ b/src/webviews/apps/settings/partials/views.worktrees.html @@ -1,7 +1,9 @@

- Worktrees view + Worktrees view 
- - - +

Git Supercharged

+

+ Version + + Release notes +

@@ -66,7 +71,10 @@ <%= require('html-loader?{"esModule":false}!./partials/code-lens.html') %> <%= require('html-loader?{"esModule":false}!./partials/status-bar.html') %> <%= require('html-loader?{"esModule":false}!./partials/hovers.html') %> - <%= require('html-loader?{"esModule":false}!./partials/views.html') %> + <%= require('html-loader?{"esModule":false}!./partials/blame.html') %> + <%= require('html-loader?{"esModule":false}!./partials/changes.html') %> + <%= require('html-loader?{"esModule":false}!./partials/heatmap.html') %> + <%= require('html-loader?{"esModule":false}!./partials/commit-graph.html') %> <%= require('html-loader?{"esModule":false}!./partials/views.commits.html') %> <%= require('html-loader?{"esModule":false}!./partials/views.commitDetails.html') %> <%= require('html-loader?{"esModule":false}!./partials/views.repositories.html') %> @@ -79,10 +87,6 @@ <%= require('html-loader?{"esModule":false}!./partials/views.worktrees.html') %> <%= require('html-loader?{"esModule":false}!./partials/views.contributors.html') %> <%= require('html-loader?{"esModule":false}!./partials/views.searchAndCompare.html') %> - <%= require('html-loader?{"esModule":false}!./partials/blame.html') %> - <%= require('html-loader?{"esModule":false}!./partials/changes.html') %> - <%= require('html-loader?{"esModule":false}!./partials/heatmap.html') %> - <%= require('html-loader?{"esModule":false}!./partials/commit-graph.html') %> <%= require('html-loader?{"esModule":false}!./partials/rebase-editor.html') %> <%= require('html-loader?{"esModule":false}!./partials/autolinks.html') %> <%= require('html-loader?{"esModule":false}!./partials/terminal-links.html') %> @@ -137,15 +141,44 @@ ViewsFile Blame + +
  • + File Changes
  • File Heatmap +
  • + +
  • + Commit Graph ✨ +
  • + +
  • + Commits view✨ Worktrees viewWorktrees view ✨
  • @@ -255,43 +288,6 @@ File Blame -
  • -
  • - File Changes -
  • -
  • - File Heatmap -
  • - -
  • - ✨ Commit Graph -
  • -
  • - Interactive Rebase Editor= 0 ? '-' : '+'}${String(Math.abs(offset)).padStart(4, '0')}`, +); -export class SettingsApp extends AppWithConfig { +export class SettingsApp extends App { private _scopes: HTMLSelectElement | null = null; private _observer: IntersectionObserver | undefined; private _activeSection: string | undefined = 'general'; + private _changes = Object.create(null) as Record; private _sections = new Map(); + private _updating: boolean = false; constructor() { super('SettingsApp'); @@ -66,21 +84,33 @@ export class SettingsApp extends AppWithConfig { } } - protected override beforeUpdateState() { - const focusId = document.activeElement?.id; - this.renderAutolinks(); - if (focusId?.startsWith('autolinks.')) { - console.log(focusId, document.getElementById(focusId)); - queueMicrotask(() => { - document.getElementById(focusId)?.focus(); - }); - } - } - protected override onBind() { const disposables = super.onBind?.() ?? []; disposables.push( + 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', + (e, target: HTMLInputElement) => this.onInputBlurred(target), + ), + DOM.on( + 'input[type=text][data-setting], input[type=number][data-setting], input:not([type])[data-setting]', + 'focus', + (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', + (e, target: HTMLInputElement) => this.onInputChanged(target), + ), + DOM.on('button[data-setting-clear]', 'click', (e, target: HTMLButtonElement) => + this.onButtonClicked(target), + ), + DOM.on('select[data-setting]', 'change', (e, target: HTMLSelectElement) => this.onInputSelected(target)), + DOM.on('.token[data-token]', 'mousedown', (e, target: HTMLElement) => this.onTokenMouseDown(target, e)), DOM.on('.section--collapsible>.section__header', 'click', (e, target: HTMLInputElement) => this.onSectionHeaderClicked(target, e), ), @@ -104,14 +134,540 @@ export class SettingsApp extends AppWithConfig { return disposables; } - protected override scrollToAnchor(anchor: string, behavior: ScrollBehavior): void { - let offset = topOffset; + protected override onMessageReceived(e: MessageEvent) { + const msg = e.data as IpcMessage; + + this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); + + switch (msg.method) { + case DidOpenAnchorNotificationType.method: { + onIpc(DidOpenAnchorNotificationType, msg, params => { + this.scrollToAnchor(params.anchor, params.scrollBehavior); + }); + break; + } + case DidChangeConfigurationNotificationType.method: + onIpc(DidChangeConfigurationNotificationType, msg, params => { + this.state.config = params.config; + this.state.customSettings = params.customSettings; + + this.updateState(); + }); + break; + + default: + super.onMessageReceived?.(e); + } + } + + private applyChanges() { + this.sendCommand(UpdateConfigurationCommandType, { + changes: { ...this._changes }, + removes: Object.keys(this._changes).filter(k => this._changes[k] === undefined), + scope: this.getSettingsScope(), + }); + + this._changes = Object.create(null) as Record; + } + + private getSettingsScope(): 'user' | 'workspace' { + return this._scopes != null + ? (this._scopes.options[this._scopes.selectedIndex].value as 'user' | 'workspace') + : 'user'; + } + + private onInputBlurred(element: HTMLInputElement) { + this.log(`onInputBlurred(${element.name}): value=${element.value})`); + + const $popup = document.getElementById(`${element.name}.popup`); + if ($popup != null) { + $popup.classList.add('hidden'); + } + + let value: string | null | undefined = element.value; + if (value == null || value.length === 0) { + value = element.dataset.defaultValue; + if (value === undefined) { + value = null; + } + } + + if (element.dataset.settingType === 'arrayObject') { + const props = element.name.split('.'); + const settingName = props[0]; + const index = parseInt(props[1], 10); + const objectProps = props.slice(2); + + let setting: Record[] | undefined = this.getSettingValue(settingName); + if (value == null && (setting === undefined || setting?.length === 0)) { + if (setting !== undefined) { + this._changes[settingName] = undefined; + } + } else { + setting = setting ?? []; + + let settingItem = setting[index]; + if (value != null || (value == null && settingItem !== undefined)) { + if (settingItem === undefined) { + settingItem = Object.create(null); + setting[index] = settingItem; + } + + set( + settingItem, + objectProps.join('.'), + element.type === 'number' && value != null ? Number(value) : value, + ); + + this._changes[settingName] = setting; + } + } + } else { + this._changes[element.name] = element.type === 'number' && value != null ? Number(value) : value; + } + + // this.setAdditionalSettings(element.checked ? element.dataset.addSettingsOn : element.dataset.addSettingsOff); + this.applyChanges(); + } + + private onButtonClicked(element: HTMLButtonElement) { + if (element.dataset.settingType === 'arrayObject') { + const props = element.name.split('.'); + const settingName = props[0]; + + const setting = this.getSettingValue[]>(settingName); + if (setting === undefined) return; + + const index = parseInt(props[1], 10); + if (setting[index] == null) return; + + setting.splice(index, 1); + + this._changes[settingName] = setting.length ? setting : undefined; + + this.applyChanges(); + } + } + + private onInputChanged(element: HTMLInputElement) { + if (this._updating) return; + + for (const el of document.querySelectorAll(`span[data-setting-preview="${element.name}"]`)) { + this.updatePreview(el, element.value); + } + } + + private onInputChecked(element: HTMLInputElement) { + if (this._updating) return; + + this.log(`onInputChecked(${element.name}): checked=${element.checked}, value=${element.value})`); + + switch (element.dataset.settingType) { + case 'object': { + const props = element.name.split('.'); + const settingName = props.splice(0, 1)[0]; + const setting = this.getSettingValue(settingName) ?? Object.create(null); + + if (element.checked) { + set(setting, props.join('.'), fromCheckboxValue(element.value)); + } else { + set(setting, props.join('.'), false); + } + + this._changes[settingName] = setting; + + break; + } + case 'array': { + const setting = this.getSettingValue(element.name) ?? []; + if (Array.isArray(setting)) { + if (element.checked) { + if (!setting.includes(element.value)) { + setting.push(element.value); + } + } else { + const i = setting.indexOf(element.value); + if (i !== -1) { + setting.splice(i, 1); + } + } + this._changes[element.name] = setting; + } + + break; + } + case 'arrayObject': { + const props = element.name.split('.'); + const settingName = props[0]; + const index = parseInt(props[1], 10); + const objectProps = props.slice(2); + + const setting: Record[] = this.getSettingValue(settingName) ?? []; + + const settingItem = setting[index] ?? Object.create(null); + if (setting[index] === undefined) { + setting[index] = settingItem; + } + + if (element.checked) { + set(setting[index], objectProps.join('.'), fromCheckboxValue(element.value)); + } else { + set(setting[index], objectProps.join('.'), false); + } + + this._changes[settingName] = setting; + + break; + } + case 'custom': { + this._changes[element.name] = element.checked; + + break; + } + default: { + if (element.checked) { + this._changes[element.name] = fromCheckboxValue(element.value); + } else { + this._changes[element.name] = element.dataset.valueOff == null ? false : element.dataset.valueOff; + } + + break; + } + } + + this.setAdditionalSettings(element.checked ? element.dataset.addSettingsOn : element.dataset.addSettingsOff); + this.applyChanges(); + } + + private onInputFocused(element: HTMLInputElement) { + this.log(`onInputFocused(${element.name}): value=${element.value}`); + + const $popup = document.getElementById(`${element.name}.popup`); + if ($popup != null) { + if ($popup.childElementCount === 0) { + const $template = document.querySelector('#token-popup')?.content.cloneNode(true); + if ($template != null) { + $popup.appendChild($template); + } + } + $popup.classList.remove('hidden'); + } + } + + private onInputSelected(element: HTMLSelectElement) { + if (element === this._scopes || this._updating) return; + + const value = element.options[element.selectedIndex].value; + + this.log(`onInputSelected(${element.name}): value=${value}`); + + this._changes[element.name] = ensureIfBooleanOrNull(value); + + this.applyChanges(); + } + + private onTokenMouseDown(element: HTMLElement, e: MouseEvent) { + if (this._updating) return; + + this.log(`onTokenMouseDown(${element.id})`); + + const setting = element.closest('.setting'); + if (setting == null) return; + + const input = setting.querySelector('input[type=text], input:not([type])'); + if (input == null) return; + + const token = `\${${element.dataset.token}}`; + let selectionStart = input.selectionStart; + if (selectionStart != null) { + input.value = `${input.value.substring(0, selectionStart)}${token}${input.value.substr( + input.selectionEnd ?? selectionStart, + )}`; + + selectionStart += token.length; + } else { + selectionStart = input.value.length; + } + + input.focus(); + input.setSelectionRange(selectionStart, selectionStart); + if (selectionStart === input.value.length) { + input.scrollLeft = input.scrollWidth; + } + + setTimeout(() => this.onInputChanged(input), 0); + setTimeout(() => input.focus(), 250); + + e.stopPropagation(); + e.stopImmediatePropagation(); + e.preventDefault(); + } + + private scrollToAnchor(anchor: string, behavior: ScrollBehavior, offset?: number) { + offset = topOffset; const header = document.querySelector('.hero__area--sticky'); if (header != null) { offset = header.clientHeight; } - super.scrollToAnchor(anchor, behavior, offset); + const el = document.getElementById(anchor); + if (el == null) return; + + this.scrollTo(el, behavior, offset); + } + + private _scrollTimer: ReturnType | undefined; + private scrollTo(el: HTMLElement, behavior: ScrollBehavior, offset?: number) { + const top = el.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (offset ?? 0); + + window.scrollTo({ + top: top, + behavior: behavior ?? 'smooth', + }); + + const fn = () => { + if (this._scrollTimer != null) { + clearTimeout(this._scrollTimer); + } + + this._scrollTimer = setTimeout(() => { + window.removeEventListener('scroll', fn); + + const newTop = + el.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (offset ?? 0); + if (Math.abs(top - newTop) < 2) return; + + this.scrollTo(el, behavior, offset); + }, 50); + }; + + window.addEventListener('scroll', fn, false); + } + + private evaluateStateExpression(expression: string, changes: Record): boolean { + let state = false; + for (const expr of expression.trim().split('&')) { + const [lhs, op, rhs] = parseStateExpression(expr); + + switch (op) { + case '=': { + // Equals + let value = changes[lhs]; + if (value === undefined) { + value = this.getSettingValue(lhs) ?? false; + } + state = rhs !== undefined ? rhs === String(value) : Boolean(value); + break; + } + case '!': { + // Not equals + let value = changes[lhs]; + if (value === undefined) { + value = this.getSettingValue(lhs) ?? false; + } + state = rhs !== undefined ? rhs !== String(value) : !value; + break; + } + case '+': { + // Contains + if (rhs !== undefined) { + const setting = this.getSettingValue(lhs); + state = setting !== undefined ? setting.includes(rhs.toString()) : false; + } + break; + } + } + + if (!state) break; + } + return state; + } + + private getCustomSettingValue(path: string): boolean | undefined { + return this.state.customSettings?.[path]; + } + + private getSettingValue(path: string): T | undefined { + const customSetting = this.getCustomSettingValue(path); + if (customSetting != null) return customSetting as unknown as T; + + return get(this.state.config, path); + } + + private updateState() { + const { version } = this.state; + document.getElementById('version')!.textContent = version; + + const focusId = document.activeElement?.id; + this.renderAutolinks(); + if (focusId?.startsWith('autolinks.')) { + console.log(focusId, document.getElementById(focusId)); + queueMicrotask(() => { + document.getElementById(focusId)?.focus(); + }); + } + + this._updating = true; + + setDefaultDateLocales(this.state.config.defaultDateLocale); + + try { + for (const el of document.querySelectorAll('input[type=checkbox][data-setting]')) { + if (el.dataset.settingType === 'custom') { + el.checked = this.getCustomSettingValue(el.name) ?? false; + } else if (el.dataset.settingType === 'array') { + el.checked = (this.getSettingValue(el.name) ?? []).includes(el.value); + } else if (el.dataset.valueOff != null) { + const value = this.getSettingValue(el.name); + el.checked = el.dataset.valueOff !== value; + } else { + el.checked = this.getSettingValue(el.name) ?? false; + } + } + + for (const el of document.querySelectorAll( + 'input[type=text][data-setting], input[type=number][data-setting], input:not([type])[data-setting]', + )) { + el.value = this.getSettingValue(el.name) ?? ''; + } + + for (const el of document.querySelectorAll('select[data-setting]')) { + const value = this.getSettingValue(el.name); + const option = el.querySelector(`option[value='${value}']`); + if (option != null) { + option.selected = true; + } + } + + for (const el of document.querySelectorAll('span[data-setting-preview]')) { + this.updatePreview(el); + } + } finally { + this._updating = false; + } + + const state = flatten(this.state.config); + if (this.state.customSettings != null) { + for (const [key, value] of Object.entries(this.state.customSettings)) { + state[key] = value; + } + } + this.setVisibility(state); + this.setEnablement(state); + } + + private setAdditionalSettings(expression: string | undefined) { + if (!expression) return; + + const addSettings = parseAdditionalSettingsExpression(expression); + for (const [s, v] of addSettings) { + this._changes[s] = v; + } + } + + private setEnablement(state: Record) { + for (const el of document.querySelectorAll('[data-enablement]')) { + const disabled = !this.evaluateStateExpression(el.dataset.enablement!, state); + if (disabled) { + el.setAttribute('disabled', ''); + } else { + el.removeAttribute('disabled'); + } + + if (el.matches('input,select')) { + (el as HTMLInputElement | HTMLSelectElement).disabled = disabled; + } else { + const input = el.querySelector('input,select'); + if (input == null) continue; + + input.disabled = disabled; + } + } + } + + private setVisibility(state: Record) { + for (const el of document.querySelectorAll('[data-visibility]')) { + el.classList.toggle('hidden', !this.evaluateStateExpression(el.dataset.visibility!, state)); + } + } + + private updatePreview(el: HTMLSpanElement, value?: string) { + const previewType = el.dataset.settingPreviewType; + switch (previewType) { + case 'date': { + if (value === undefined) { + value = this.getSettingValue(el.dataset.settingPreview!); + } + + if (!value) { + value = el.dataset.settingPreviewDefault; + if (value == null) { + const lookup = el.dataset.settingPreviewDefaultLookup; + if (lookup != null) { + value = this.getSettingValue(lookup); + } + } + } + + el.innerText = value == null ? '' : formatDate(date, value, undefined, false); + break; + } + case 'date-locale': { + if (value === undefined) { + value = this.getSettingValue(el.dataset.settingPreview!); + } + + if (!value) { + value = undefined; + } + + const format = this.getSettingValue(el.dataset.settingPreviewDefault!) ?? 'MMMM Do, YYYY h:mma'; + try { + el.innerText = formatDate(date, format, value, false); + } catch (ex) { + el.innerText = ex.message; + } + break; + } + case 'commit': + case 'commit-uncommitted': { + if (value === undefined) { + value = this.getSettingValue(el.dataset.settingPreview!); + } + + if (!value) { + value = el.dataset.settingPreviewDefault; + if (value == null) { + const lookup = el.dataset.settingPreviewDefaultLookup; + if (lookup != null) { + value = this.getSettingValue(lookup); + } + } + } + + if (value == null) { + el.innerText = ''; + + return; + } + + void this.sendCommandWithCompletion( + GenerateConfigurationPreviewCommandType, + { + key: el.dataset.settingPreview!, + type: previewType, + format: value, + }, + DidGenerateConfigurationPreviewNotificationType, + ).then(params => { + el.innerText = params.preview ?? ''; + }); + + break; + } + default: + break; + } } private onObserver(entries: IntersectionObserverEntry[], _observer: IntersectionObserver) { @@ -154,12 +710,6 @@ export class SettingsApp extends AppWithConfig { this.toggleJumpLink(this._activeSection, true); } - protected override getSettingsScope(): 'user' | 'workspace' { - return this._scopes != null - ? (this._scopes.options[this._scopes.selectedIndex].value as 'user' | 'workspace') - : 'user'; - } - private onActionLinkClicked(element: HTMLElement, e: MouseEvent) { switch (element.dataset.action) { case 'collapse': @@ -201,13 +751,7 @@ export class SettingsApp extends AppWithConfig { e.stopPropagation(); } - protected override onInputSelected(element: HTMLSelectElement) { - if (element === this._scopes) return; - - super.onInputSelected(element); - } - - protected onJumpToLinkClicked(element: HTMLAnchorElement, e: MouseEvent) { + private onJumpToLinkClicked(element: HTMLAnchorElement, e: MouseEvent) { const href = element.getAttribute('href'); if (href == null) return; @@ -347,5 +891,84 @@ export class SettingsApp extends AppWithConfig { } } +function ensureIfBooleanOrNull(value: string | boolean): string | boolean | null { + if (value === 'true') return true; + if (value === 'false') return false; + if (value === 'null') return null; + return value; +} + +function get(o: Record, path: string): T | undefined { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return path.split('.').reduce((o = {}, key) => (o == null ? undefined : o[key]), o) as T; +} + +function set(o: Record, path: string, value: any): Record { + const props = path.split('.'); + const length = props.length; + const lastIndex = length - 1; + + let index = -1; + let nested = o; + + while (nested != null && ++index < length) { + const key = props[index]; + let newValue = value; + + if (index !== lastIndex) { + const objValue = nested[key]; + newValue = typeof objValue === 'object' ? objValue : {}; + } + + nested[key] = newValue; + nested = nested[key]; + } + + return o; +} + +function parseAdditionalSettingsExpression(expression: string): [string, string | boolean | null][] { + const settingsExpression = expression.trim().split(','); + return settingsExpression.map<[string, string | boolean | null]>(s => { + const [setting, value] = s.split('='); + return [setting, ensureIfBooleanOrNull(value)]; + }); +} + +function parseStateExpression(expression: string): [string, string, string | boolean | undefined] { + const [lhs, op, rhs] = expression.trim().split(/([=+!])/); + return [lhs.trim(), op !== undefined ? op.trim() : '=', rhs !== undefined ? rhs.trim() : rhs]; +} + +function flatten(o: Record, path?: string): Record { + const results: Record = {}; + + for (const key in o) { + const value = o[key]; + if (Array.isArray(value)) continue; + + if (typeof value === 'object') { + Object.assign(results, flatten(value, path === undefined ? key : `${path}.${key}`)); + } else { + results[path === undefined ? key : `${path}.${key}`] = value; + } + } + + return results; +} + +function fromCheckboxValue(elementValue: unknown) { + switch (elementValue) { + case 'on': + return true; + case 'null': + return null; + case 'undefined': + return undefined; + default: + return elementValue; + } +} + new SettingsApp(); // requestAnimationFrame(() => new Snow()); diff --git a/src/webviews/apps/shared/appWithConfigBase.ts b/src/webviews/apps/shared/appWithConfigBase.ts deleted file mode 100644 index 58da018..0000000 --- a/src/webviews/apps/shared/appWithConfigBase.ts +++ /dev/null @@ -1,666 +0,0 @@ -/*global document*/ -import type { Config } from '../../../config'; -import type { IpcMessage } from '../../protocol'; -import { - DidChangeConfigurationNotificationType, - DidGenerateConfigurationPreviewNotificationType, - DidOpenAnchorNotificationType, - GenerateConfigurationPreviewCommandType, - onIpc, - UpdateConfigurationCommandType, -} from '../../protocol'; -import { App } from './appBase'; -import { formatDate, setDefaultDateLocales } from './date'; -import { DOM } from './dom'; - -const offset = (new Date().getTimezoneOffset() / 60) * 100; -const date = new Date( - `Wed Jul 25 2018 19:18:00 GMT${offset >= 0 ? '-' : '+'}${String(Math.abs(offset)).padStart(4, '0')}`, -); - -interface AppStateWithConfig { - timestamp: number; - - config: Config; - customSettings?: Record; -} - -export abstract class AppWithConfig extends App { - private _changes = Object.create(null) as Record; - private _updating: boolean = false; - - protected override onInitialized() { - this.updateState(); - } - - protected override onBind() { - const disposables = super.onBind?.() ?? []; - - disposables.push( - 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', - (e, target: HTMLInputElement) => this.onInputBlurred(target), - ), - DOM.on( - 'input[type=text][data-setting], input[type=number][data-setting], input:not([type])[data-setting]', - 'focus', - (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', - (e, target: HTMLInputElement) => this.onInputChanged(target), - ), - DOM.on('button[data-setting-clear]', 'click', (e, target: HTMLButtonElement) => - this.onButtonClicked(target), - ), - DOM.on('select[data-setting]', 'change', (e, target: HTMLSelectElement) => this.onInputSelected(target)), - DOM.on('.token[data-token]', 'mousedown', (e, target: HTMLElement) => this.onTokenMouseDown(target, e)), - ); - - return disposables; - } - - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - switch (msg.method) { - case DidOpenAnchorNotificationType.method: { - onIpc(DidOpenAnchorNotificationType, msg, params => { - this.scrollToAnchor(params.anchor, params.scrollBehavior); - }); - break; - } - case DidChangeConfigurationNotificationType.method: - onIpc(DidChangeConfigurationNotificationType, msg, params => { - this.state.config = params.config; - this.state.customSettings = params.customSettings; - - this.updateState(); - }); - break; - - default: - super.onMessageReceived?.(e); - } - } - - protected applyChanges() { - this.sendCommand(UpdateConfigurationCommandType, { - changes: { ...this._changes }, - removes: Object.keys(this._changes).filter(k => this._changes[k] === undefined), - scope: this.getSettingsScope(), - }); - - this._changes = Object.create(null) as Record; - } - - protected getSettingsScope(): 'user' | 'workspace' { - return 'user'; - } - - protected onInputBlurred(element: HTMLInputElement) { - this.log(`onInputBlurred(${element.name}): value=${element.value})`); - - const $popup = document.getElementById(`${element.name}.popup`); - if ($popup != null) { - $popup.classList.add('hidden'); - } - - let value: string | null | undefined = element.value; - if (value == null || value.length === 0) { - value = element.dataset.defaultValue; - if (value === undefined) { - value = null; - } - } - - if (element.dataset.settingType === 'arrayObject') { - const props = element.name.split('.'); - const settingName = props[0]; - const index = parseInt(props[1], 10); - const objectProps = props.slice(2); - - let setting: Record[] | undefined = this.getSettingValue(settingName); - if (value == null && (setting === undefined || setting?.length === 0)) { - if (setting !== undefined) { - this._changes[settingName] = undefined; - } - } else { - setting = setting ?? []; - - let settingItem = setting[index]; - if (value != null || (value == null && settingItem !== undefined)) { - if (settingItem === undefined) { - settingItem = Object.create(null); - setting[index] = settingItem; - } - - set( - settingItem, - objectProps.join('.'), - element.type === 'number' && value != null ? Number(value) : value, - ); - - this._changes[settingName] = setting; - } - } - } else { - this._changes[element.name] = element.type === 'number' && value != null ? Number(value) : value; - } - - // this.setAdditionalSettings(element.checked ? element.dataset.addSettingsOn : element.dataset.addSettingsOff); - this.applyChanges(); - } - - protected onButtonClicked(element: HTMLButtonElement) { - if (element.dataset.settingType === 'arrayObject') { - const props = element.name.split('.'); - const settingName = props[0]; - - const setting = this.getSettingValue[]>(settingName); - if (setting === undefined) return; - - const index = parseInt(props[1], 10); - if (setting[index] == null) return; - - setting.splice(index, 1); - - this._changes[settingName] = setting.length ? setting : undefined; - - this.applyChanges(); - } - } - - protected onInputChanged(element: HTMLInputElement) { - if (this._updating) return; - - for (const el of document.querySelectorAll(`span[data-setting-preview="${element.name}"]`)) { - this.updatePreview(el, element.value); - } - } - - protected onInputChecked(element: HTMLInputElement) { - if (this._updating) return; - - this.log(`onInputChecked(${element.name}): checked=${element.checked}, value=${element.value})`); - - switch (element.dataset.settingType) { - case 'object': { - const props = element.name.split('.'); - const settingName = props.splice(0, 1)[0]; - const setting = this.getSettingValue(settingName) ?? Object.create(null); - - if (element.checked) { - set(setting, props.join('.'), fromCheckboxValue(element.value)); - } else { - set(setting, props.join('.'), false); - } - - this._changes[settingName] = setting; - - break; - } - case 'array': { - const setting = this.getSettingValue(element.name) ?? []; - if (Array.isArray(setting)) { - if (element.checked) { - if (!setting.includes(element.value)) { - setting.push(element.value); - } - } else { - const i = setting.indexOf(element.value); - if (i !== -1) { - setting.splice(i, 1); - } - } - this._changes[element.name] = setting; - } - - break; - } - case 'arrayObject': { - const props = element.name.split('.'); - const settingName = props[0]; - const index = parseInt(props[1], 10); - const objectProps = props.slice(2); - - const setting: Record[] = this.getSettingValue(settingName) ?? []; - - const settingItem = setting[index] ?? Object.create(null); - if (setting[index] === undefined) { - setting[index] = settingItem; - } - - if (element.checked) { - set(setting[index], objectProps.join('.'), fromCheckboxValue(element.value)); - } else { - set(setting[index], objectProps.join('.'), false); - } - - this._changes[settingName] = setting; - - break; - } - case 'custom': { - this._changes[element.name] = element.checked; - - break; - } - default: { - if (element.checked) { - this._changes[element.name] = fromCheckboxValue(element.value); - } else { - this._changes[element.name] = element.dataset.valueOff == null ? false : element.dataset.valueOff; - } - - break; - } - } - - this.setAdditionalSettings(element.checked ? element.dataset.addSettingsOn : element.dataset.addSettingsOff); - this.applyChanges(); - } - - protected onInputFocused(element: HTMLInputElement) { - this.log(`onInputFocused(${element.name}): value=${element.value}`); - - const $popup = document.getElementById(`${element.name}.popup`); - if ($popup != null) { - if ($popup.childElementCount === 0) { - const $template = document.querySelector('#token-popup')?.content.cloneNode(true); - if ($template != null) { - $popup.appendChild($template); - } - } - $popup.classList.remove('hidden'); - } - } - - protected onInputSelected(element: HTMLSelectElement) { - if (this._updating) return; - - const value = element.options[element.selectedIndex].value; - - this.log(`onInputSelected(${element.name}): value=${value}`); - - this._changes[element.name] = ensureIfBooleanOrNull(value); - - this.applyChanges(); - } - - protected onTokenMouseDown(element: HTMLElement, e: MouseEvent) { - if (this._updating) return; - - this.log(`onTokenMouseDown(${element.id})`); - - const setting = element.closest('.setting'); - if (setting == null) return; - - const input = setting.querySelector('input[type=text], input:not([type])'); - if (input == null) return; - - const token = `\${${element.dataset.token}}`; - let selectionStart = input.selectionStart; - if (selectionStart != null) { - input.value = `${input.value.substring(0, selectionStart)}${token}${input.value.substr( - input.selectionEnd ?? selectionStart, - )}`; - - selectionStart += token.length; - } else { - selectionStart = input.value.length; - } - - input.focus(); - input.setSelectionRange(selectionStart, selectionStart); - if (selectionStart === input.value.length) { - input.scrollLeft = input.scrollWidth; - } - - setTimeout(() => this.onInputChanged(input), 0); - setTimeout(() => input.focus(), 250); - - e.stopPropagation(); - e.stopImmediatePropagation(); - e.preventDefault(); - } - - protected scrollToAnchor(anchor: string, behavior: ScrollBehavior, offset?: number) { - const el = document.getElementById(anchor); - if (el == null) return; - - this.scrollTo(el, behavior, offset); - } - - private _scrollTimer: ReturnType | undefined; - private scrollTo(el: HTMLElement, behavior: ScrollBehavior, offset?: number) { - const top = el.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (offset ?? 0); - - window.scrollTo({ - top: top, - behavior: behavior ?? 'smooth', - }); - - const fn = () => { - if (this._scrollTimer != null) { - clearTimeout(this._scrollTimer); - } - - this._scrollTimer = setTimeout(() => { - window.removeEventListener('scroll', fn); - - const newTop = - el.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (offset ?? 0); - if (Math.abs(top - newTop) < 2) return; - - this.scrollTo(el, behavior, offset); - }, 50); - }; - - window.addEventListener('scroll', fn, false); - } - - private evaluateStateExpression(expression: string, changes: Record): boolean { - let state = false; - for (const expr of expression.trim().split('&')) { - const [lhs, op, rhs] = parseStateExpression(expr); - - switch (op) { - case '=': { - // Equals - let value = changes[lhs]; - if (value === undefined) { - value = this.getSettingValue(lhs) ?? false; - } - state = rhs !== undefined ? rhs === String(value) : Boolean(value); - break; - } - case '!': { - // Not equals - let value = changes[lhs]; - if (value === undefined) { - value = this.getSettingValue(lhs) ?? false; - } - state = rhs !== undefined ? rhs !== String(value) : !value; - break; - } - case '+': { - // Contains - if (rhs !== undefined) { - const setting = this.getSettingValue(lhs); - state = setting !== undefined ? setting.includes(rhs.toString()) : false; - } - break; - } - } - - if (!state) break; - } - return state; - } - - private getCustomSettingValue(path: string): boolean | undefined { - return this.state.customSettings?.[path]; - } - - private getSettingValue(path: string): T | undefined { - const customSetting = this.getCustomSettingValue(path); - if (customSetting != null) return customSetting as unknown as T; - - return get(this.state.config, path); - } - - protected beforeUpdateState?(): void; - - private updateState() { - this.beforeUpdateState?.(); - - this._updating = true; - - setDefaultDateLocales(this.state.config.defaultDateLocale); - - try { - for (const el of document.querySelectorAll('input[type=checkbox][data-setting]')) { - if (el.dataset.settingType === 'custom') { - el.checked = this.getCustomSettingValue(el.name) ?? false; - } else if (el.dataset.settingType === 'array') { - el.checked = (this.getSettingValue(el.name) ?? []).includes(el.value); - } else if (el.dataset.valueOff != null) { - const value = this.getSettingValue(el.name); - el.checked = el.dataset.valueOff !== value; - } else { - el.checked = this.getSettingValue(el.name) ?? false; - } - } - - for (const el of document.querySelectorAll( - 'input[type=text][data-setting], input[type=number][data-setting], input:not([type])[data-setting]', - )) { - el.value = this.getSettingValue(el.name) ?? ''; - } - - for (const el of document.querySelectorAll('select[data-setting]')) { - const value = this.getSettingValue(el.name); - const option = el.querySelector(`option[value='${value}']`); - if (option != null) { - option.selected = true; - } - } - - for (const el of document.querySelectorAll('span[data-setting-preview]')) { - this.updatePreview(el); - } - } finally { - this._updating = false; - } - - const state = flatten(this.state.config); - if (this.state.customSettings != null) { - for (const [key, value] of Object.entries(this.state.customSettings)) { - state[key] = value; - } - } - this.setVisibility(state); - this.setEnablement(state); - } - - private setAdditionalSettings(expression: string | undefined) { - if (!expression) return; - - const addSettings = parseAdditionalSettingsExpression(expression); - for (const [s, v] of addSettings) { - this._changes[s] = v; - } - } - - private setEnablement(state: Record) { - for (const el of document.querySelectorAll('[data-enablement]')) { - const disabled = !this.evaluateStateExpression(el.dataset.enablement!, state); - if (disabled) { - el.setAttribute('disabled', ''); - } else { - el.removeAttribute('disabled'); - } - - if (el.matches('input,select')) { - (el as HTMLInputElement | HTMLSelectElement).disabled = disabled; - } else { - const input = el.querySelector('input,select'); - if (input == null) continue; - - input.disabled = disabled; - } - } - } - - private setVisibility(state: Record) { - for (const el of document.querySelectorAll('[data-visibility]')) { - el.classList.toggle('hidden', !this.evaluateStateExpression(el.dataset.visibility!, state)); - } - } - - private updatePreview(el: HTMLSpanElement, value?: string) { - const previewType = el.dataset.settingPreviewType; - switch (previewType) { - case 'date': { - if (value === undefined) { - value = this.getSettingValue(el.dataset.settingPreview!); - } - - if (!value) { - value = el.dataset.settingPreviewDefault; - if (value == null) { - const lookup = el.dataset.settingPreviewDefaultLookup; - if (lookup != null) { - value = this.getSettingValue(lookup); - } - } - } - - el.innerText = value == null ? '' : formatDate(date, value, undefined, false); - break; - } - case 'date-locale': { - if (value === undefined) { - value = this.getSettingValue(el.dataset.settingPreview!); - } - - if (!value) { - value = undefined; - } - - const format = this.getSettingValue(el.dataset.settingPreviewDefault!) ?? 'MMMM Do, YYYY h:mma'; - try { - el.innerText = formatDate(date, format, value, false); - } catch (ex) { - el.innerText = ex.message; - } - break; - } - case 'commit': - case 'commit-uncommitted': { - if (value === undefined) { - value = this.getSettingValue(el.dataset.settingPreview!); - } - - if (!value) { - value = el.dataset.settingPreviewDefault; - if (value == null) { - const lookup = el.dataset.settingPreviewDefaultLookup; - if (lookup != null) { - value = this.getSettingValue(lookup); - } - } - } - - if (value == null) { - el.innerText = ''; - - return; - } - - void this.sendCommandWithCompletion( - GenerateConfigurationPreviewCommandType, - { - key: el.dataset.settingPreview!, - type: previewType, - format: value, - }, - DidGenerateConfigurationPreviewNotificationType, - ).then(params => { - el.innerText = params.preview ?? ''; - }); - - break; - } - default: - break; - } - } -} - -function ensureIfBooleanOrNull(value: string | boolean): string | boolean | null { - if (value === 'true') return true; - if (value === 'false') return false; - if (value === 'null') return null; - return value; -} - -function get(o: Record, path: string): T | undefined { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return path.split('.').reduce((o = {}, key) => (o == null ? undefined : o[key]), o) as T; -} - -function set(o: Record, path: string, value: any): Record { - const props = path.split('.'); - const length = props.length; - const lastIndex = length - 1; - - let index = -1; - let nested = o; - - while (nested != null && ++index < length) { - const key = props[index]; - let newValue = value; - - if (index !== lastIndex) { - const objValue = nested[key]; - newValue = typeof objValue === 'object' ? objValue : {}; - } - - nested[key] = newValue; - nested = nested[key]; - } - - return o; -} - -function parseAdditionalSettingsExpression(expression: string): [string, string | boolean | null][] { - const settingsExpression = expression.trim().split(','); - return settingsExpression.map<[string, string | boolean | null]>(s => { - const [setting, value] = s.split('='); - return [setting, ensureIfBooleanOrNull(value)]; - }); -} - -function parseStateExpression(expression: string): [string, string, string | boolean | undefined] { - const [lhs, op, rhs] = expression.trim().split(/([=+!])/); - return [lhs.trim(), op !== undefined ? op.trim() : '=', rhs !== undefined ? rhs.trim() : rhs]; -} - -function flatten(o: Record, path?: string): Record { - const results: Record = {}; - - for (const key in o) { - const value = o[key]; - if (Array.isArray(value)) continue; - - if (typeof value === 'object') { - Object.assign(results, flatten(value, path === undefined ? key : `${path}.${key}`)); - } else { - results[path === undefined ? key : `${path}.${key}`] = value; - } - } - - return results; -} - -function fromCheckboxValue(elementValue: unknown) { - switch (elementValue) { - case 'on': - return true; - case 'null': - return null; - case 'undefined': - return undefined; - default: - return elementValue; - } -} diff --git a/src/webviews/settings/protocol.ts b/src/webviews/settings/protocol.ts index fe0746a..d5681a0 100644 --- a/src/webviews/settings/protocol.ts +++ b/src/webviews/settings/protocol.ts @@ -3,6 +3,7 @@ import type { Config } from '../../config'; export interface State { timestamp: number; + version: string; config: Config; customSettings?: Record; scope: 'user' | 'workspace'; diff --git a/src/webviews/settings/settingsWebview.ts b/src/webviews/settings/settingsWebview.ts index 799cf70..88bea50 100644 --- a/src/webviews/settings/settingsWebview.ts +++ b/src/webviews/settings/settingsWebview.ts @@ -41,6 +41,7 @@ export class SettingsWebviewProvider extends WebviewProviderWithConfigBase