diff --git a/package.json b/package.json index 578597d..155a827 100644 --- a/package.json +++ b/package.json @@ -6028,6 +6028,17 @@ "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.search/" } ], + "customEditors": [ + { + "viewType": "gitlens.rebase", + "displayName": "Interactive Rebase Editor", + "selector": [ + { + "filenamePattern": "git-rebase-todo" + } + ] + } + ], "resourceLabelFormatters": [ { "scheme": "gitlens", diff --git a/src/container.ts b/src/container.ts index 59e6a44..b3c74c1 100644 --- a/src/container.ts +++ b/src/container.ts @@ -23,6 +23,7 @@ import { RepositoriesView } from './views/repositoriesView'; import { SearchView } from './views/searchView'; import { ViewCommands } from './views/viewCommands'; import { VslsController } from './vsls/vsls'; +import { RebaseEditorProvider } from './webviews/rebaseEditor'; import { SettingsWebview } from './webviews/settingsWebview'; import { WelcomeWebview } from './webviews/welcomeWebview'; @@ -109,6 +110,7 @@ export class Container { }); } + context.subscriptions.push(new RebaseEditorProvider()); context.subscriptions.push(new GitFileSystemProvider()); context.subscriptions.push(configuration.onWillChange(this.onConfigurationChanging, this)); diff --git a/src/webviews/apps/rebase/rebase.html b/src/webviews/apps/rebase/rebase.html new file mode 100644 index 0000000..0a42c3b --- /dev/null +++ b/src/webviews/apps/rebase/rebase.html @@ -0,0 +1,30 @@ + + + + + + + +
+
+

Interactive Rebase

+

+
+ +
+ pPick + rReword + eEdit + sSquash + dDrop + alt ↑Move Up + alt ↓Move Down +
+
+ + +
+
+ #{endOfBody} + + diff --git a/src/webviews/apps/rebase/rebase.ts b/src/webviews/apps/rebase/rebase.ts new file mode 100644 index 0000000..066ef32 --- /dev/null +++ b/src/webviews/apps/rebase/rebase.ts @@ -0,0 +1,314 @@ +'use strict'; +/*global document*/ +import { + onIpcNotification, + RebaseDidAbortCommandType, + RebaseDidChangeEntryCommandType, + RebaseDidChangeNotificationType, + RebaseDidMoveEntryCommandType, + RebaseDidStartCommandType, + RebaseEntry, + RebaseEntryAction, + RebaseState, +} from '../../protocol'; +import { App } from '../shared/appBase'; +import { DOM } from '../shared/dom'; + +const rebaseActions = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop']; +const rebaseActionsMap = new Map([ + ['p', 'pick'], + ['P', 'pick'], + ['r', 'reword'], + ['R', 'reword'], + ['e', 'edit'], + ['E', 'edit'], + ['s', 'squash'], + ['S', 'squash'], + ['f', 'fixup'], + ['F', 'fixup'], + ['d', 'drop'], + ['D', 'drop'], +]); + +class RebaseEditor extends App { + // eslint-disable-next-line no-template-curly-in-string + private readonly commitTokenRegex = new RegExp(encodeURIComponent('${commit}')); + + constructor() { + super('RebaseEditor', (window as any).bootstrap); + (window as any).bootstrap = undefined; + } + + protected onInitialize() { + this.state = this.getState() ?? this.state; + if (this.state != null) { + this.refresh(this.state); + } + } + + protected onBind() { + const disposables = super.onBind?.() ?? []; + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const me = this; + + disposables.push( + DOM.on('[data-action="start"]', 'click', () => this.onStartClicked()), + DOM.on('[data-action="abort"]', 'click', () => this.onAbortClicked()), + DOM.on('li[data-ref]', 'keydown', function (this: Element, e: KeyboardEvent) { + if ((e.target as HTMLElement).matches('select[data-ref]')) { + if (e.key === 'Escape') { + (this as HTMLLIElement).focus(); + } + + return; + } + + if (e.key === 'Enter' || e.key === ' ') { + const $select = (this as HTMLLIElement).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; + if (ref) { + e.stopPropagation(); + + me.moveEntry(ref, e.key === 'ArrowDown'); + } + } else { + if (me.state == null) return; + + let ref = (this as HTMLLIElement).dataset.ref; + if (ref == null) return; + + e.preventDefault(); + + let index = me.getEntryIndex(ref) + (e.key === 'ArrowDown' ? 1 : -1); + if (index < 0) { + index = me.state.entries.length - 1; + } else if (index === me.state.entries.length) { + index = 0; + } + + ref = me.state.entries[index].ref; + document.querySelectorAll(`li[data-ref="${ref}`)[0]?.focus(); + } + } + } else if (!e.metaKey && !e.altKey && !e.ctrlKey) { + const action = rebaseActionsMap.get(e.key); + if (action !== undefined) { + e.stopPropagation(); + + const $select = (this as HTMLLIElement).querySelectorAll( + 'select[data-ref]', + )[0]; + if ($select != null) { + $select.value = action; + me.onSelectChanged($select); + } + } + } + }), + DOM.on('select[data-ref]', 'input', function (this: Element) { + return me.onSelectChanged(this as HTMLSelectElement); + }), + ); + + return disposables; + } + + private getEntry(ref: string) { + return this.state?.entries.find(e => e.ref === ref); + } + + private getEntryIndex(ref: string) { + return this.state?.entries.findIndex(e => e.ref === ref) ?? -1; + } + + private moveEntry(ref: string, down: boolean) { + const entry = this.getEntry(ref); + if (entry !== undefined) { + this.sendCommand(RebaseDidMoveEntryCommandType, { + ref: entry.ref, + down: !down, + }); + } + } + + private setEntryAction(ref: string, action: RebaseEntryAction) { + const entry = this.getEntry(ref); + if (entry !== undefined) { + if (entry.action === action) return; + + this.sendCommand(RebaseDidChangeEntryCommandType, { + ref: entry.ref, + action: action, + }); + } + } + + private onAbortClicked() { + this.sendCommand(RebaseDidAbortCommandType, {}); + } + + private onSelectChanged($el: HTMLSelectElement) { + const ref = $el.dataset.ref; + if (ref) { + this.setEntryAction(ref, $el.options[$el.selectedIndex].value as RebaseEntryAction); + } + } + + private onStartClicked() { + this.sendCommand(RebaseDidStartCommandType, {}); + } + + protected onMessageReceived(e: MessageEvent) { + const msg = e.data; + + switch (msg.method) { + case RebaseDidChangeNotificationType.method: + onIpcNotification(RebaseDidChangeNotificationType, msg, params => { + this.setState({ ...this.state, ...params }); + this.refresh(this.state); + }); + break; + + default: + super.onMessageReceived?.(e); + } + } + + private refresh(state: RebaseState) { + const $subhead = document.getElementById('subhead')! as HTMLHeadingElement; + $subhead.innerHTML = `${state.branch}Rebasing ${ + state.entries.length + } commit${state.entries.length > 1 ? 's' : ''} onto ${state.onto}`; + + const $container = document.getElementById('entries')!; + + const focusRef = document.activeElement?.closest('li[data-ref]')?.dataset.ref; + let focusSelect = false; + if (document.activeElement?.matches('select[data-ref]')) { + focusSelect = true; + } + + $container.innerHTML = ''; + if (state.entries.length === 0) return; + + let tabIndex = 0; + + // let prev: string | undefined; + for (const entry of state.entries.reverse()) { + let $el: HTMLLIElement; + [$el, tabIndex] = this.createEntry(entry, state, ++tabIndex); + $container.appendChild($el); + + // if (entry.action !== 'drop') { + // prev = entry.ref; + // } + } + + const commit = state.commits.find(c => c.ref.startsWith(state.onto)); + if (commit) { + const [$el] = this.createEntry( + { + action: undefined!, + index: 0, + message: commit.message.split('\n')[0], + ref: state.onto, + }, + state, + ++tabIndex, + ); + $container.appendChild($el); + } + + document + .querySelectorAll( + `${focusSelect ? 'select' : 'li'}[data-ref="${focusRef ?? state.entries[0].ref}"]`, + )[0] + ?.focus(); + + this.bind(); + } + + private createEntry(entry: RebaseEntry, state: RebaseState, tabIndex: number): [HTMLLIElement, number] { + const $entry = document.createElement('li'); + $entry.classList.add('entry', `entry--${entry.action ?? 'base'}`); + $entry.dataset.ref = entry.ref; + + if (entry.action != null) { + $entry.tabIndex = tabIndex++; + + const $selectContainer = document.createElement('div'); + $selectContainer.classList.add('entry-action', 'select-container'); + $entry.appendChild($selectContainer); + + const $select = document.createElement('select'); + $select.dataset.ref = entry.ref; + $select.name = 'action'; + $select.tabIndex = tabIndex++; + + for (const action of rebaseActions) { + const option = document.createElement('option'); + option.value = action; + option.text = action; + + if (entry.action === action) { + option.selected = true; + } + + $select.appendChild(option); + } + $selectContainer.appendChild($select); + } + + const $message = document.createElement('span'); + $message.classList.add('entry-message'); + $message.innerText = entry.message ?? ''; + $entry.appendChild($message); + + const commit = state.commits.find(c => c.ref.startsWith(entry.ref)); + if (commit) { + $message.title = commit.message ?? ''; + + if (commit.author) { + const author = state.authors.find(a => a.author === commit.author); + if (author?.avatarUrl.length) { + const $avatar = document.createElement('img'); + $avatar.classList.add('entry-avatar'); + $avatar.src = author.avatarUrl; + $entry.appendChild($avatar); + } + + const $author = document.createElement('span'); + $author.classList.add('entry-author'); + $author.innerText = commit.author; + $entry.appendChild($author); + } + + if (commit.dateFromNow) { + const $date = document.createElement('span'); + $date.title = commit.date ?? ''; + $date.classList.add('entry-date'); + $date.innerText = commit.dateFromNow; + $entry.appendChild($date); + } + } + + const $ref = document.createElement('a'); + $ref.classList.add('entry-ref'); + // $ref.dataset.prev = prev ? `${prev} \u2190 ` : ''; + $ref.href = commit?.ref ? state.commands.commit.replace(this.commitTokenRegex, commit.ref) : '#'; + $ref.innerText = entry.ref; + $ref.tabIndex = tabIndex++; + $entry.appendChild($ref); + + return [$entry, tabIndex]; + } +} + +new RebaseEditor(); diff --git a/src/webviews/apps/scss/rebase.scss b/src/webviews/apps/scss/rebase.scss new file mode 100644 index 0000000..bd0ff85 --- /dev/null +++ b/src/webviews/apps/scss/rebase.scss @@ -0,0 +1,195 @@ +@import 'base'; +@import 'buttons'; +@import 'utils'; + +body { + height: unset; +} + +.container { + display: grid; + font-size: 1.3em; + grid-template-areas: 'header' 'entries' 'shortcuts' 'actions'; + grid-template-columns: repeat(1, 1fr min-content); + margin: 1em auto; + grid-gap: 0.5em 3em; + max-width: 1200px; + min-width: 450px; + + @media all and (max-width: 768px) { + grid-gap: 0.5em 0; + } +} + +header { + display: flex; + align-items: baseline; + margin: 0 0 1em 0; +} + +h4 { + font-size: 1em; + opacity: 0.8; +} + +.entries { + grid-area: entries; +} + +.shortcuts { + grid-area: shortcuts; + margin: 0 auto; +} + +.actions { + grid-area: actions; + margin: 10px; +} + +.entry { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 10px; + padding: 10px 0; + + &:focus-within { + outline: -webkit-focus-ring-color auto 1px; + } + + &.entry--base { + .vscode-dark & { + background: rgba(255, 255, 255, 0.1); + } + + .vscode-light & { + background: rgba(0, 0, 0, 0.1); + } + + &:focus, + &:focus-within { + outline: none !important; + } + } +} + +.entry-action { + flex: auto 0 0; + margin: 0 10px; + + .entry--edit > &, + .entry--reword > & { + & > select { + border: 1px solid rgba(0, 153, 0, 1) !important; + outline-color: rgba(0, 153, 0, 1) !important; + } + } + + .entry--squash > &, + .entry--fixup > & { + & > select { + border: 1px solid rgba(212, 153, 0, 1) !important; + outline-color: rgba(212, 153, 0, 1) !important; + } + } + + .entry--drop > & { + & > select { + border: 1px solid rgba(153, 0, 0, 1) !important; + outline-color: rgba(153, 0, 0, 1) !important; + } + } +} + +.entry-message { + flex: 100% 1 1; + margin: 0 10px; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .entry--squash &, + .entry--fixup & { + padding-left: 28px; + + &::before { + content: ' '; + position: absolute; + top: 10px; + left: 6px; + width: 15px; + height: 18px; + border-top: 1px solid currentColor; + border-left: 1px solid currentColor; + } + + &::after { + content: ' '; + position: absolute; + top: 14px; + left: 1px; + padding: 6px; + box-shadow: 1px -1px currentColor; + transform: rotate(135deg); + } + } + + .entry--drop & { + text-decoration: line-through; + opacity: 0.25; + } +} + +.entry-avatar { + flex: auto 0 0; + margin: 0 -5px 0 0; + + .entry--drop & { + // text-decoration: line-through; + opacity: 0.25; + } +} + +.entry-author, +.entry-date, +.entry-ref { + flex: auto 0 0; + margin: 0 10px; + opacity: 0.5; + + .entry--drop & { + text-decoration: line-through; + opacity: 0.25; + } +} + +.shortcut { + display: inline-block; + margin: 5px 10px 5px 0; + opacity: 0.6; + + & span { + margin: 0 0 0 5px; + } +} + +.branch { + &::before { + content: '\ea68'; + font-family: codicon; + position: relative; + top: 2px; + margin: 0 3px; + } +} + +.commit { + &::before { + content: '\eafc'; + font-family: codicon; + position: relative; + top: 2px; + margin: 0 1px 0 -1px; + } +} diff --git a/src/webviews/protocol.ts b/src/webviews/protocol.ts index d6174bf..cd5ab3a 100644 --- a/src/webviews/protocol.ts +++ b/src/webviews/protocol.ts @@ -71,3 +71,66 @@ export interface SettingsState extends AppStateWithConfig { } 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 { + entries: RebaseEntry[]; +} +export const RebaseDidChangeNotificationType = new IpcNotificationType( + 'rebase/change', +); + +export const RebaseDidStartCommandType = new IpcCommandType('rebase/start'); + +export const RebaseDidAbortCommandType = new IpcCommandType('rebase/abort'); + +export interface RebaseDidChangeEntryCommandParams { + ref: string; + action: RebaseEntryAction; +} +export const RebaseDidChangeEntryCommandType = new IpcCommandType( + 'rebase/change/entry', +); + +export interface RebaseDidMoveEntryCommandParams { + ref: string; + down: boolean; +} +export const RebaseDidMoveEntryCommandType = new IpcCommandType('rebase/move/entry'); + +export interface RebaseState extends RebaseDidChangeNotificationParams { + branch: string; + onto: string; + + entries: RebaseEntry[]; + authors: Author[]; + commits: Commit[]; + commands: { + commit: string; + }; +} diff --git a/src/webviews/rebaseEditor.ts b/src/webviews/rebaseEditor.ts new file mode 100644 index 0000000..ec54660 --- /dev/null +++ b/src/webviews/rebaseEditor.ts @@ -0,0 +1,346 @@ +'use strict'; +import * as paths from 'path'; +import * as fs from 'fs'; +import { + CancellationToken, + commands, + CustomTextEditorProvider, + Disposable, + Position, + Range, + TextDocument, + Uri, + Webview, + WebviewPanel, + window, + workspace, + WorkspaceEdit, +} from 'vscode'; +import { ShowQuickCommitCommand } from '../commands'; +import { Container } from '../container'; +import { Logger } from '../logger'; +import { + Author, + Commit, + IpcMessage, + onIpcCommand, + RebaseDidAbortCommandType, + RebaseDidChangeEntryCommandType, + RebaseDidChangeNotificationType, + RebaseDidMoveEntryCommandType, + RebaseDidStartCommandType, + RebaseEntry, + RebaseEntryAction, + RebaseState, +} from './protocol'; + +let ipcSequence = 0; +function nextIpcId() { + if (ipcSequence === Number.MAX_SAFE_INTEGER) { + ipcSequence = 1; + } else { + ipcSequence++; + } + + return `host:${ipcSequence}`; +} + +const rebaseRegex = /^\s?#\s?Rebase\s([0-9a-f]+?)..([0-9a-f]+?)\sonto\s([0-9a-f]+?)\s.*$/im; +const rebaseCommandsRegex = /^\s?(p|pick|r|reword|e|edit|s|squash|f|fixup|d|drop)\s([0-9a-f]+?)\s(.*)$/gm; + +const rebaseActionsMap = new Map([ + ['p', 'pick'], + ['pick', 'pick'], + ['r', 'reword'], + ['reword', 'reword'], + ['e', 'edit'], + ['edit', 'edit'], + ['s', 'squash'], + ['squash', 'squash'], + ['f', 'fixup'], + ['fixup', 'fixup'], + ['d', 'drop'], + ['drop', 'drop'], +]); + +export class RebaseEditorProvider implements CustomTextEditorProvider, Disposable { + private readonly _disposable: Disposable; + + constructor() { + this._disposable = Disposable.from( + window.registerCustomEditorProvider('gitlens.rebase', this, { + webviewOptions: { + enableFindWidget: true, + }, + }), + ); + } + + dispose() { + this._disposable.dispose(); + } + + async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel, _token: CancellationToken) { + const disposables: Disposable[] = []; + + disposables.push(panel.onDidDispose(() => disposables.forEach(d => d.dispose()))); + + panel.webview.options = { enableCommandUris: true, enableScripts: true }; + + disposables.push(panel.webview.onDidReceiveMessage(e => this.onMessageReceived(document, panel, e))); + + disposables.push( + workspace.onDidChangeTextDocument(e => { + if (e.contentChanges.length === 0 || e.document.uri.toString() !== document.uri.toString()) return; + + this.parseEntriesAndSendChange(panel, document); + }), + ); + + panel.webview.html = await this.getHtml(panel.webview, document); + } + + private parseEntries(contents: string): RebaseEntry[]; + private parseEntries(document: TextDocument): RebaseEntry[]; + private parseEntries(contentsOrDocument: string | TextDocument): RebaseEntry[] { + const contents = typeof contentsOrDocument === 'string' ? contentsOrDocument : contentsOrDocument.getText(); + + const entries: RebaseEntry[] = []; + + let match; + let action; + let ref; + let message; + + do { + match = rebaseCommandsRegex.exec(contents); + if (match == null) break; + + [, action, ref, message] = match; + + entries.push({ + index: match.index, + action: rebaseActionsMap.get(action) ?? 'pick', + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ref: ` ${ref}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + message: message == null || message.length === 0 ? '' : ` ${message}`.substr(1), + }); + } while (true); + + return entries; + } + + private parseEntriesAndSendChange(panel: WebviewPanel, document: TextDocument) { + const entries = this.parseEntries(document); + void this.postMessage(panel, { + id: nextIpcId(), + method: RebaseDidChangeNotificationType.method, + params: { entries: entries }, + }); + } + + private async parseState(document: TextDocument): Promise { + const repoPath = await Container.git.getRepoPath(paths.join(document.uri.fsPath, '../../..')); + const branch = await Container.git.getBranch(repoPath); + + const contents = document.getText(); + const entries = this.parseEntries(contents); + const [, onto] = rebaseRegex.exec(contents) ?? ['', '', '']; + + const authors = new Map(); + const commits: Commit[] = []; + + let commit = await Container.git.getCommit(repoPath!, onto); + if (commit != null) { + if (!authors.has(commit.author)) { + authors.set(commit.author, { + author: commit.author, + avatarUrl: commit.getAvatarUri(Container.config.defaultGravatarsStyle).toString(true), + email: commit.email, + }); + } + + commits.push({ + ref: commit.ref, + author: commit.author, + date: commit.formatDate(Container.config.defaultDateFormat), + dateFromNow: commit.formatDateFromNow(), + message: commit.message, + // command: `command:${Commands.ShowQuickCommitDetails}`, + // command: ShowQuickCommitDetailsCommand.getMarkdownCommandArgs({ + // sha: commit.ref, + // }), + }); + } + + for (const entry of entries) { + commit = await Container.git.getCommit(repoPath!, entry.ref); + if (commit == null) continue; + + if (!authors.has(commit.author)) { + authors.set(commit.author, { + author: commit.author, + avatarUrl: commit.getAvatarUri(Container.config.defaultGravatarsStyle).toString(true), + email: commit.email, + }); + } + + commits.push({ + ref: commit.ref, + author: commit.author, + date: commit.formatDate(Container.config.defaultDateFormat), + dateFromNow: commit.formatDateFromNow(), + message: commit.message, + // command: `command:${Commands.ShowQuickCommitDetails}`, + // command: ShowQuickCommitDetailsCommand.getMarkdownCommandArgs({ + // sha: commit.ref, + // }), + }); + } + + return { + branch: branch?.name ?? '', + onto: onto ?? '', + entries: entries, + authors: [...authors.values()], + commits: commits, + commands: { + // eslint-disable-next-line no-template-curly-in-string + commit: ShowQuickCommitCommand.getMarkdownCommandArgs('${commit}', repoPath), + }, + }; + } + + private async postMessage(panel: WebviewPanel, message: IpcMessage) { + try { + const success = await panel.webview.postMessage(message); + return success; + } catch (ex) { + Logger.error(ex); + return false; + } + } + + private onMessageReceived(document: TextDocument, panel: WebviewPanel, e: IpcMessage) { + switch (e.method) { + // case ReadyCommandType.method: + // onIpcCommand(ReadyCommandType, e, params => { + // this.parseDocumentAndSendChange(panel, document); + // }); + + // break; + + case RebaseDidStartCommandType.method: + onIpcCommand(RebaseDidStartCommandType, e, async _params => { + await document.save(); + await commands.executeCommand('workbench.action.closeActiveEditor'); + }); + + break; + + case RebaseDidAbortCommandType.method: + onIpcCommand(RebaseDidAbortCommandType, e, async _params => { + // Delete the contents to abort the rebase + const edit = new WorkspaceEdit(); + edit.replace(document.uri, new Range(0, 0, document.lineCount, 0), ''); + await workspace.applyEdit(edit); + await document.save(); + await commands.executeCommand('workbench.action.closeActiveEditor'); + }); + + break; + + case RebaseDidChangeEntryCommandType.method: + onIpcCommand(RebaseDidChangeEntryCommandType, e, async params => { + const entries = this.parseEntries(document); + + const entry = entries.find(e => e.ref === params.ref); + if (entry == null) return; + + const start = document.positionAt(entry.index); + const range = document.validateRange( + new Range(new Position(start.line, 0), new Position(start.line, Number.MAX_SAFE_INTEGER)), + ); + + const edit = new WorkspaceEdit(); + edit.replace(document.uri, range, `${params.action} ${entry.ref} ${entry.message}`); + await workspace.applyEdit(edit); + }); + + break; + + case RebaseDidMoveEntryCommandType.method: + onIpcCommand(RebaseDidMoveEntryCommandType, e, async params => { + const entries = this.parseEntries(document); + + const entry = entries.find(e => e.ref === params.ref); + if (entry == null) return; + + const index = entries.findIndex(e => e.ref === params.ref); + if ((!params.down && index === 0) || (params.down && index === entries.length - 1)) { + return; + } + + const start = document.positionAt(entry.index); + const range = document.validateRange( + new Range(new Position(start.line, 0), new Position(start.line + 1, 0)), + ); + + const edit = new WorkspaceEdit(); + edit.delete(document.uri, range); + edit.insert( + document.uri, + new Position(range.start.line + (params.down ? 2 : -1), 0), + `${entry.action} ${entry.ref} ${entry.message}\n`, + ); + await workspace.applyEdit(edit); + }); + + break; + } + } + + private _html: string | undefined; + private async getHtml(webview: Webview, document: TextDocument): Promise { + const filename = Container.context.asAbsolutePath(paths.join('dist/webviews/', 'rebase.html')); + + let content; + // When we are debugging avoid any caching so that we can change the html and have it update without reloading + if (Logger.isDebugging) { + content = await new Promise((resolve, reject) => { + fs.readFile(filename, 'utf8', (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } else { + if (this._html !== undefined) return this._html; + + const doc = await workspace.openTextDocument(filename); + content = doc.getText(); + } + + let html = content + .replace(/#{cspSource}/g, webview.cspSource) + .replace( + /#{root}/g, + Uri.file(Container.context.asAbsolutePath('.')).with({ scheme: 'vscode-resource' }).toString(), + ); + + const bootstrap = await this.parseState(document); + + html = html.replace( + /#{endOfBody}/i, + ``, + ); + + this._html = html; + return html; + } +} diff --git a/webpack.config.js b/webpack.config.js index e85ba61..da0d44d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -247,6 +247,35 @@ function getWebviewsConfig(mode, env) { filename: '[name].css', }), new HtmlPlugin({ + template: 'rebase/rebase.html', + chunks: ['rebase', 'rebase-styles'], + excludeAssets: [/.+-styles\.js/], + filename: path.resolve(__dirname, 'dist/webviews/rebase.html'), + inject: true, + inlineSource: mode === 'production' ? '.css$' : undefined, + cspPlugin: { + enabled: true, + policy: cspPolicy, + nonceEnabled: { + 'script-src': true, + 'style-src': true, + }, + }, + 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', 'settings-styles'], excludeAssets: [/.+-styles\.js/], @@ -328,6 +357,8 @@ function getWebviewsConfig(mode, env) { name: 'webviews', context: path.resolve(__dirname, 'src/webviews/apps'), entry: { + rebase: ['./rebase/rebase.ts'], + 'rebase-styles': ['./scss/rebase.scss'], settings: ['./settings/settings.ts'], 'settings-styles': ['./scss/settings.scss'], welcome: ['./welcome/welcome.ts'],