696 lines
20 KiB

import type { CancellationToken, CustomTextEditorProvider, TextDocument, WebviewPanel } from 'vscode';
import { ConfigurationTarget, Disposable, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode';
import { getNonce } from '@env/crypto';
import { ShowQuickCommitCommand } from '../../commands';
import { GitActions } from '../../commands/gitCommands.actions';
import { configuration } from '../../configuration';
import { CoreCommands } from '../../constants';
import type { Container } from '../../container';
import { emojify } from '../../emojis';
import type { GitCommit } from '../../git/models/commit';
import { GitReference } from '../../git/models/reference';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository';
import { Logger } from '../../logger';
import { showRebaseSwitchToTextWarningMessage } from '../../messages';
import { executeCoreCommand } from '../../system/command';
import { debug, log } from '../../system/decorators/log';
import type { Deferrable } from '../../system/function';
import { debounce } from '../../system/function';
import { join, map } from '../../system/iterable';
import { normalizePath } from '../../system/path';
import type { IpcMessage } from '../protocol';
import { onIpc } from '../protocol';
import type {
Author,
ChangeEntryParams,
MoveEntryParams,
RebaseEntry,
RebaseEntryAction,
ReorderParams,
State,
UpdateSelectionParams,
} from './protocol';
import {
AbortCommandType,
ChangeEntryCommandType,
DidChangeNotificationType,
DisableCommandType,
MoveEntryCommandType,
ReorderCommandType,
SearchCommandType,
StartCommandType,
SwitchCommandType,
UpdateSelectionCommandType,
} from './protocol';
const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers)
let ipcSequence = 0;
function nextIpcId() {
if (ipcSequence === maxSmallIntegerV8) {
ipcSequence = 1;
} else {
ipcSequence++;
}
return `host:${ipcSequence}`;
}
let webviewId = 0;
function nextWebviewId() {
if (webviewId === maxSmallIntegerV8) {
webviewId = 1;
} else {
webviewId++;
}
return webviewId;
}
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<string, RebaseEntryAction>([
['p', 'pick'],
['pick', 'pick'],
['r', 'reword'],
['reword', 'reword'],
['e', 'edit'],
['edit', 'edit'],
['s', 'squash'],
['squash', 'squash'],
['f', 'fixup'],
['fixup', 'fixup'],
['d', 'drop'],
['drop', 'drop'],
]);
interface RebaseEditorContext {
dispose(): void;
readonly id: number;
readonly document: TextDocument;
readonly panel: WebviewPanel;
readonly repoPath: string;
readonly subscriptions: Disposable[];
authors?: Map<string, Author>;
branchName?: string | null;
commits?: GitCommit[];
pendingChange?: boolean;
firstSelection?: boolean;
fireSelectionChangedDebounced?: Deferrable<RebaseEditorProvider['fireSelectionChanged']> | undefined;
notifyDidChangeStateDebounced?: Deferrable<RebaseEditorProvider['notifyDidChangeState']> | undefined;
}
export class RebaseEditorProvider implements CustomTextEditorProvider, Disposable {
private readonly _disposable: Disposable;
private ascending = false;
constructor(private readonly container: Container) {
this._disposable = Disposable.from(
window.registerCustomEditorProvider('gitlens.rebase', this, {
supportsMultipleEditorsPerDocument: false,
webviewOptions: {
enableFindWidget: true,
retainContextWhenHidden: true,
},
}),
);
this.ascending = configuration.get('rebaseEditor.ordering') === 'asc';
}
dispose() {
this._disposable.dispose();
}
get enabled(): boolean {
const associations = configuration.inspectAny<
{ [key: string]: string } | { viewType: string; filenamePattern: string }[]
>('workbench.editorAssociations')?.globalValue;
if (associations == null || associations.length === 0) return true;
if (Array.isArray(associations)) {
const association = associations.find(a => a.filenamePattern === 'git-rebase-todo');
return association != null ? association.viewType === 'gitlens.rebase' : true;
}
const association = associations['git-rebase-todo'];
return association != null ? association === 'gitlens.rebase' : true;
}
private _disableAfterNextUse: boolean = false;
async enableForNextUse() {
if (!this.enabled) {
await this.setEnabled(true);
this._disableAfterNextUse = true;
}
}
async setEnabled(enabled: boolean): Promise<void> {
this._disableAfterNextUse = false;
const inspection = configuration.inspectAny<
{ [key: string]: string } | { viewType: string; filenamePattern: string }[]
>('workbench.editorAssociations');
let associations = inspection?.globalValue;
if (Array.isArray(associations)) {
associations = associations.reduce<Record<string, string>>((accumulator, current) => {
accumulator[current.filenamePattern] = current.viewType;
return accumulator;
}, Object.create(null));
}
if (associations == null) {
if (enabled) return;
associations = {
'git-rebase-todo': 'default',
};
} else {
associations['git-rebase-todo'] = enabled ? 'gitlens.rebase' : 'default';
}
await configuration.updateAny('workbench.editorAssociations', associations, ConfigurationTarget.Global);
}
@debug<RebaseEditorProvider['resolveCustomTextEditor']>({ args: { 0: d => d.uri.toString(true) } })
async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel, _token: CancellationToken) {
const repoPath = normalizePath(Uri.joinPath(document.uri, '..', '..', '..').fsPath);
const repo = this.container.git.getRepository(repoPath);
const subscriptions: Disposable[] = [];
const context: RebaseEditorContext = {
dispose: () => void Disposable.from(...subscriptions).dispose(),
id: nextWebviewId(),
subscriptions: subscriptions,
document: document,
panel: panel,
repoPath: repo?.path ?? repoPath,
firstSelection: true,
};
subscriptions.push(
panel.onDidDispose(() => {
Disposable.from(...subscriptions).dispose();
}),
panel.onDidChangeViewState(() => {
if (!context.pendingChange) return;
this.updateState(context);
}),
panel.webview.onDidReceiveMessage(e => this.onMessageReceived(context, e)),
workspace.onDidChangeTextDocument(e => {
if (e.contentChanges.length === 0 || e.document.uri.toString() !== document.uri.toString()) return;
this.updateState(context, true);
}),
workspace.onDidSaveTextDocument(e => {
if (e.uri.toString() !== document.uri.toString()) return;
this.updateState(context, true);
}),
);
if (repo != null) {
subscriptions.push(
repo.onDidChange(e => {
if (!e.changed(RepositoryChange.Rebase, RepositoryChangeComparisonMode.Any)) return;
this.updateState(context);
}),
);
}
panel.webview.options = { enableCommandUris: true, enableScripts: true };
panel.webview.html = await this.getHtml(context);
if (this._disableAfterNextUse) {
this._disableAfterNextUse = false;
void this.setEnabled(false);
}
}
private async parseState(context: RebaseEditorContext): Promise<State> {
if (context.branchName === undefined) {
const branch = await this.container.git.getBranch(context.repoPath);
context.branchName = branch?.name ?? null;
}
const state = await this.parseRebaseTodo(context);
return state;
}
private async postMessage(context: RebaseEditorContext, message: IpcMessage) {
try {
const success = await context.panel.webview.postMessage(message);
context.pendingChange = !success;
return success;
} catch (ex) {
Logger.error(ex);
context.pendingChange = true;
return false;
}
}
private onMessageReceived(context: RebaseEditorContext, e: IpcMessage) {
switch (e.method) {
// case ReadyCommandType.method:
// onIpcCommand(ReadyCommandType, e, params => {
// this.parseDocumentAndSendChange(panel, document);
// });
// break;
case AbortCommandType.method:
onIpc(AbortCommandType, e, () => this.abort(context));
break;
case DisableCommandType.method:
onIpc(DisableCommandType, e, () => this.disable(context));
break;
case SearchCommandType.method:
onIpc(SearchCommandType, e, () => executeCoreCommand(CoreCommands.CustomEditorShowFindWidget));
break;
case StartCommandType.method:
onIpc(StartCommandType, e, () => this.rebase(context));
break;
case SwitchCommandType.method:
onIpc(SwitchCommandType, e, () => this.switchToText(context));
break;
case ReorderCommandType.method:
onIpc(ReorderCommandType, e, params => this.swapOrdering(params, context));
break;
case ChangeEntryCommandType.method:
onIpc(ChangeEntryCommandType, e, params => this.onEntryChanged(context, params));
break;
case MoveEntryCommandType.method:
onIpc(MoveEntryCommandType, e, params => this.onEntryMoved(context, params));
break;
case UpdateSelectionCommandType.method:
onIpc(UpdateSelectionCommandType, e, params => this.onSelectionChanged(context, params));
}
}
private async onEntryChanged(context: RebaseEditorContext, params: ChangeEntryParams) {
const entries = parseRebaseTodoEntries(context.document);
const entry = entries.find(e => e.sha === params.sha);
if (entry == null) return;
const start = context.document.positionAt(entry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
let action = params.action;
const edit = new WorkspaceEdit();
// Fake the new set of entries, to check if last entry is a squash/fixup
const newEntries = [...entries];
newEntries.splice(entries.indexOf(entry), 1, {
...entry,
action: params.action,
});
let squashing = false;
for (const entry of newEntries) {
if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true;
} else if (squashing) {
if (entry.action !== 'drop') {
squashing = false;
}
}
}
// Ensure that the last entry isn't a squash/fixup
if (squashing) {
const lastEntry = newEntries[newEntries.length - 1];
if (entry.sha === lastEntry.sha) {
action = 'pick';
} else {
const start = context.document.positionAt(lastEntry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
edit.replace(context.document.uri, range, `pick ${lastEntry.sha} ${lastEntry.message}`);
}
}
edit.replace(context.document.uri, range, `${action} ${entry.sha} ${entry.message}`);
await workspace.applyEdit(edit);
}
private async onEntryMoved(context: RebaseEditorContext, params: MoveEntryParams) {
const entries = parseRebaseTodoEntries(context.document);
const entry = entries.find(e => e.sha === params.sha);
if (entry == null) return;
const index = entries.findIndex(e => e.sha === params.sha);
let newIndex;
if (params.relative) {
if ((params.to === -1 && index === 0) || (params.to === 1 && index === entries.length - 1)) {
return;
}
newIndex = index + params.to;
} else {
if (index === params.to) return;
newIndex = params.to;
}
const newEntry = entries[newIndex];
let newLine = context.document.positionAt(newEntry.index).line;
if (newIndex < index) {
newLine++;
}
const start = context.document.positionAt(entry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line + 1, 0)),
);
// Fake the new set of entries, so we can ensure that the last entry isn't a squash/fixup
const newEntries = [...entries];
newEntries.splice(index, 1);
newEntries.splice(newIndex, 0, entry);
let squashing = false;
for (const entry of newEntries) {
if (entry.action === 'squash' || entry.action === 'fixup') {
squashing = true;
} else if (squashing) {
if (entry.action !== 'drop') {
squashing = false;
}
}
}
const edit = new WorkspaceEdit();
let action = entry.action;
// Ensure that the last entry isn't a squash/fixup
if (squashing) {
const lastEntry = newEntries[newEntries.length - 1];
if (entry.sha === lastEntry.sha) {
action = 'pick';
} else {
const start = context.document.positionAt(lastEntry.index);
const range = context.document.validateRange(
new Range(new Position(start.line, 0), new Position(start.line, maxSmallIntegerV8)),
);
edit.replace(context.document.uri, range, `pick ${lastEntry.sha} ${lastEntry.message}`);
}
}
edit.delete(context.document.uri, range);
edit.insert(context.document.uri, new Position(newLine, 0), `${action} ${entry.sha} ${entry.message}\n`);
await workspace.applyEdit(edit);
}
private onSelectionChanged(context: RebaseEditorContext, params: UpdateSelectionParams) {
if (context.fireSelectionChangedDebounced == null) {
context.fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 250);
}
context.fireSelectionChangedDebounced(context, params.sha);
}
private fireSelectionChanged(context: RebaseEditorContext, sha: string | undefined) {
if (sha == null) return;
const showDetailsView = configuration.get('rebaseEditor.showDetailsView');
// Find the full sha
sha = context.commits?.find(c => c.sha.startsWith(sha!))?.sha ?? sha;
void GitActions.Commit.showDetailsView(GitReference.create(sha, context.repoPath, { refType: 'revision' }), {
pin: false,
preserveFocus: true,
preserveVisibility: context.firstSelection ? showDetailsView === false : showDetailsView !== 'selection',
});
context.firstSelection = false;
}
@debug<RebaseEditorProvider['updateState']>({ args: { 0: c => `${c.id}:${c.document.uri.toString(true)}` } })
private updateState(context: RebaseEditorContext, immediate: boolean = false) {
if (immediate) {
context.notifyDidChangeStateDebounced?.cancel();
void this.notifyDidChangeState(context);
return;
}
if (context.notifyDidChangeStateDebounced == null) {
context.notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 250);
}
void context.notifyDidChangeStateDebounced(context);
}
@debug<RebaseEditorProvider['notifyDidChangeState']>({
args: { 0: c => `${c.id}:${c.document.uri.toString(true)}` },
})
private async notifyDidChangeState(context: RebaseEditorContext) {
if (!context.panel.visible) {
context.pendingChange = true;
return;
}
const state = await this.parseState(context);
void this.postMessage(context, {
id: nextIpcId(),
method: DidChangeNotificationType.method,
params: { state: state },
});
}
@log({ args: false })
private async abort(context: RebaseEditorContext) {
// Avoid triggering events by disposing them first
context.dispose();
// Delete the contents to abort the rebase
const edit = new WorkspaceEdit();
edit.replace(context.document.uri, new Range(0, 0, context.document.lineCount, 0), '');
await workspace.applyEdit(edit);
await context.document.save();
context.panel.dispose();
}
@log({ args: false })
private async disable(context: RebaseEditorContext) {
await this.abort(context);
await this.setEnabled(false);
}
@log({ args: false })
private async rebase(context: RebaseEditorContext) {
// Avoid triggering events by disposing them first
context.dispose();
await context.document.save();
context.panel.dispose();
}
@log({ args: false })
private swapOrdering(params: ReorderParams, context: RebaseEditorContext) {
this.ascending = params.ascending ?? false;
void configuration.updateEffective('rebaseEditor.ordering', this.ascending ? 'asc' : 'desc');
this.updateState(context, true);
}
@log({ args: false })
private switchToText(context: RebaseEditorContext) {
void showRebaseSwitchToTextWarningMessage();
// Open the text version of the document
void executeCoreCommand(CoreCommands.Open, context.document.uri, {
override: false,
preview: false,
});
}
private async getHtml(context: RebaseEditorContext): Promise<string> {
const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews');
const uri = Uri.joinPath(webRootUri, 'rebase.html');
const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri));
const bootstrap = await this.parseState(context);
const cspSource = context.panel.webview.cspSource;
const cspNonce = getNonce();
const root = context.panel.webview.asWebviewUri(this.container.context.extensionUri).toString();
const webRoot = context.panel.webview.asWebviewUri(webRootUri).toString();
const html = content.replace(
/#{(head|body|endOfBody|placement|cspSource|cspNonce|root|webroot)}/g,
(_substring: string, token: string) => {
switch (token) {
case 'endOfBody':
return `<script type="text/javascript" nonce="${cspNonce}">window.bootstrap=${JSON.stringify(
bootstrap,
)};</script>`;
case 'placement':
return 'editor';
case 'cspSource':
return cspSource;
case 'cspNonce':
return cspNonce;
case 'root':
return root;
case 'webroot':
return webRoot;
default:
return '';
}
},
);
return html;
}
@debug({ args: false })
private async parseRebaseTodo(context: RebaseEditorContext): Promise<Omit<State, 'rebasing'>> {
const contents = context.document.getText();
const entries = parseRebaseTodoEntries(contents);
let [, , , onto] = rebaseRegex.exec(contents) ?? ['', '', ''];
if (context.authors == null || context.commits == null) {
await this.loadRichCommitData(context, onto, entries);
}
const defaultDateFormat = configuration.get('defaultDateFormat');
const command = ShowQuickCommitCommand.getMarkdownCommandArgs(`\${commit}`, context.repoPath);
const ontoCommit = onto ? context.commits?.find(c => c.sha.startsWith(onto)) : undefined;
let commit;
for (const entry of entries) {
commit = context.commits?.find(c => c.sha.startsWith(entry.sha));
if (commit == null) continue;
// If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201
if (commit.sha === ontoCommit?.sha) {
onto = '';
}
entry.commit = {
sha: commit.sha,
author: commit.author.name,
date: commit.formatDate(defaultDateFormat),
dateFromNow: commit.formatDateFromNow(),
message: emojify(commit.message ?? commit.summary),
};
}
return {
branch: context.branchName ?? '',
onto: onto
? {
sha: onto,
commit:
ontoCommit != null
? {
sha: ontoCommit.sha,
author: ontoCommit.author.name,
date: ontoCommit.formatDate(defaultDateFormat),
dateFromNow: ontoCommit.formatDateFromNow(),
message: emojify(ontoCommit.message || 'root'),
}
: undefined,
}
: undefined,
entries: entries,
authors: context.authors != null ? Object.fromEntries(context.authors) : {},
commands: { commit: command },
ascending: this.ascending,
};
}
@debug({ args: false })
private async loadRichCommitData(context: RebaseEditorContext, onto: string, entries: RebaseEntry[]) {
context.commits = [];
context.authors = new Map<string, Author>();
const log = await this.container.git.richSearchCommits(
context.repoPath,
{
query: `${onto ? `#:${onto} ` : ''}${join(
map(entries, e => `#:${e.sha}`),
' ',
)}`,
},
{ limit: 0 },
);
if (log != null) {
for (const c of log.commits.values()) {
context.commits.push(c);
if (!context.authors.has(c.author.name)) {
context.authors.set(c.author.name, {
author: c.author.name,
avatarUrl: (await c.getAvatarUri()).toString(true),
email: c.author.email,
});
}
}
}
}
}
function parseRebaseTodoEntries(contents: string): RebaseEntry[];
function parseRebaseTodoEntries(document: TextDocument): RebaseEntry[];
function parseRebaseTodoEntries(contentsOrDocument: string | TextDocument): RebaseEntry[] {
const contents = typeof contentsOrDocument === 'string' ? contentsOrDocument : contentsOrDocument.getText();
const entries: RebaseEntry[] = [];
let match;
let action;
let sha;
let message;
do {
match = rebaseCommandsRegex.exec(contents);
if (match == null) break;
[, action, sha, 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
sha: ` ${sha}`.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.reverse();
}